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](https://img.shields.io/badge/License-Apache%202.0-green.svg)](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> { let memory = self - .memory_manager + .memory_manager() .get(¶ms.id) .await .map_err(|_| tower_lsp::jsonrpc::Error::internal_error())?; @@ -2790,7 +2893,7 @@ impl CodeGraphBackend { &self, params: crate::handlers::MemoryInvalidateParams, ) -> Result { - self.memory_manager + self.memory_manager() .invalidate(¶ms.id, "Invalidated via LSP command") .await .map_err(|_| tower_lsp::jsonrpc::Error::internal_error())?; @@ -2805,7 +2908,7 @@ impl CodeGraphBackend { ) -> Result { // Get all current memories let all_memories = self - .memory_manager + .memory_manager() .get_all_current() .await .map_err(|_| tower_lsp::jsonrpc::Error::internal_error())?; @@ -2892,7 +2995,7 @@ impl CodeGraphBackend { // Get existing memory let existing = self - .memory_manager + .memory_manager() .get(¶ms.id) .await .map_err(|_| tower_lsp::jsonrpc::Error::internal_error())?; @@ -2946,14 +3049,14 @@ impl CodeGraphBackend { // Store updated memory let id = self - .memory_manager + .memory_manager() .put(memory) .await .map_err(|_| tower_lsp::jsonrpc::Error::internal_error())?; // Get the updated memory for response let updated = self - .memory_manager + .memory_manager() .get(&id) .await .map_err(|_| tower_lsp::jsonrpc::Error::internal_error())?; @@ -3134,7 +3237,7 @@ impl CodeGraphBackend { .unwrap_or_default(); let results = self - .memory_manager + .memory_manager() .search(&query, &config, &code_context) .await .map_err(|_| tower_lsp::jsonrpc::Error::internal_error())?; @@ -3176,7 +3279,7 @@ impl CodeGraphBackend { /// Get memory store statistics. pub async fn handle_memory_stats(&self) -> Result { let stats = self - .memory_manager + .memory_manager() .stats() .await .map_err(|_| tower_lsp::jsonrpc::Error::internal_error())?; @@ -3266,7 +3369,7 @@ impl CodeGraphBackend { .map_err(|_| tower_lsp::jsonrpc::Error::invalid_request())?; let mut result = miner - .mine_repository(&self.memory_manager, &self.graph, &config) + .mine_repository(&self.memory_manager(), &self.graph, &config) .await .map_err(|e| { tracing::error!("Git mining failed: {}", e); @@ -3303,7 +3406,7 @@ impl CodeGraphBackend { .ok(); if let Some(m) = memory { - if let Ok(id) = self.memory_manager.put(m).await { + if let Ok(id) = self.memory_manager().put(m).await { result.memory_ids.push(id); hotspots_created += 1; } @@ -3355,7 +3458,7 @@ impl CodeGraphBackend { .ok(); if let Some(m) = memory { - if let Ok(id) = self.memory_manager.put(m).await { + if let Ok(id) = self.memory_manager().put(m).await { result.memory_ids.push(id); couplings_created += 1; } @@ -3423,7 +3526,7 @@ impl CodeGraphBackend { .map_err(|_| tower_lsp::jsonrpc::Error::invalid_request())?; let result = miner - .mine_file(&file_path, &self.memory_manager, &self.graph, &config) + .mine_file(&file_path, &self.memory_manager(), &self.graph, &config) .await .map_err(|e| { tracing::error!("Git mining for file failed: {}", e); @@ -3587,7 +3690,7 @@ impl CodeGraphBackend { current_only: true, ..Default::default() }; - match self.memory_manager.search(&path_str, &config, &[]).await { + match self.memory_manager().search(&path_str, &config, &[]).await { Ok(results) => { let memory_budget = max_tokens * 15 / 100; let mut mem_tokens = 0usize; @@ -3823,7 +3926,7 @@ impl CodeGraphBackend { current_only: true, ..Default::default() }; - if let Ok(results) = self.memory_manager.search(file, &config, &[]).await { + if let Ok(results) = self.memory_manager().search(file, &config, &[]).await { for r in &results { if mem_tokens >= memory_budget { break; @@ -3890,7 +3993,7 @@ impl CodeGraphBackend { current_only: false, ..Default::default() }; - if let Ok(mem_results) = self.memory_manager.search(query, &config, &[]).await { + if let Ok(mem_results) = self.memory_manager().search(query, &config, &[]).await { for r in &mem_results { if let crate::memory::MemorySource::GitHistory { ref commit_hash } = r.memory.source { @@ -4005,6 +4108,105 @@ mod tests { use std::path::Path; use tempfile::TempDir; + mod workspace_paths { + use super::*; + + fn params() -> InitializeParams { + InitializeParams::default() + } + + fn uri(path: &str) -> Url { + Url::from_file_path(path).expect("test path must be absolute") + } + + #[test] + fn prefers_workspace_folders() { + let mut p = params(); + p.workspace_folders = Some(vec![WorkspaceFolder { + uri: uri("/tmp/from-folders"), + name: "w".into(), + }]); + #[allow(deprecated)] + { + p.root_uri = Some(uri("/tmp/from-root-uri")); + } + + assert_eq!( + workspace_paths_from(&p), + vec![std::path::PathBuf::from("/tmp/from-folders")] + ); + } + + #[test] + fn falls_back_to_root_uri() { + // The case that was broken: a client sending only rootUri got a + // server whose memory subsystem never initialised. + let mut p = params(); + #[allow(deprecated)] + { + p.root_uri = Some(uri("/tmp/from-root-uri")); + } + + assert_eq!( + workspace_paths_from(&p), + vec![std::path::PathBuf::from("/tmp/from-root-uri")] + ); + } + + #[test] + fn falls_back_to_root_path_when_that_is_all_there_is() { + let mut p = params(); + #[allow(deprecated)] + { + p.root_path = Some("/tmp/from-root-path".into()); + } + + assert_eq!( + workspace_paths_from(&p), + vec![std::path::PathBuf::from("/tmp/from-root-path")] + ); + } + + #[test] + fn empty_folder_list_is_treated_as_absent() { + // Some clients send [] together with a usable rootUri; taking the + // empty list at face value would strand them. + let mut p = params(); + p.workspace_folders = Some(vec![]); + #[allow(deprecated)] + { + p.root_uri = Some(uri("/tmp/from-root-uri")); + } + + assert_eq!( + workspace_paths_from(&p), + vec![std::path::PathBuf::from("/tmp/from-root-uri")] + ); + } + + #[test] + fn keeps_every_workspace_folder() { + let mut p = params(); + p.workspace_folders = Some(vec![ + WorkspaceFolder { + uri: uri("/tmp/one"), + name: "one".into(), + }, + WorkspaceFolder { + uri: uri("/tmp/two"), + name: "two".into(), + }, + ]); + + assert_eq!(workspace_paths_from(&p).len(), 2); + } + + #[test] + fn nothing_at_all_yields_no_paths() { + assert!(workspace_paths_from(¶ms()).is_empty()); + } + } + /// Helper to create a test backend with an empty graph fn create_test_backend() -> CodeGraphBackend { let graph = Arc::new(RwLock::new( diff --git a/crates/codegraph-server/src/crash_phase.rs b/crates/codegraph-server/src/crash_phase.rs index f54692b..ed74501 100644 --- a/crates/codegraph-server/src/crash_phase.rs +++ b/crates/codegraph-server/src/crash_phase.rs @@ -41,6 +41,96 @@ pub fn clear() { } } +/// Markers we own and may delete. Deliberately excludes `last-recovery.`: +/// those are reported once with no freshness window, so a client that has not +/// read one yet still needs it, however old it is. +const SWEEPABLE_PREFIXES: [&str; 2] = ["last-phase.", "last-crash."]; + +/// How old a marker must be before we consider removing it. Both clients only +/// trust a breadcrumb within ~15 seconds of the crash it describes, so an hour +/// is far past the point where one can still explain anything - while leaving +/// an enormous margin for a client that is slow to read it. +const SWEEP_MIN_AGE: std::time::Duration = std::time::Duration::from_secs(60 * 60); + +/// Delete markers left behind by processes that no longer exist. +/// +/// [`clear`] only removes the current process's marker, and it runs after the +/// LSP serve loop returns - which does not happen when a client force-kills the +/// engine, as both clients do. So every killed process used to leave its marker +/// behind permanently: 310 of them accumulated on one machine over two months. +/// +/// The cost is not the disk space, it is that stale markers make the clients' +/// freshness window the only thing standing between a months-old marker and a +/// wrong crash diagnosis today. +/// +/// Two conditions, both required, so this can never destroy a live diagnosis: +/// the marker is older than [`SWEEP_MIN_AGE`], *and* its process is gone. Age +/// alone would delete the marker of a long-running engine that later crashes; +/// liveness alone would race a client that has not yet read a fresh crash. +/// Best-effort throughout - housekeeping must never break startup. +pub fn sweep_orphans() { + if let Some(dir) = codegraph_dir() { + sweep_orphans_in(&dir); + } +} + +/// [`sweep_orphans`] against an explicit directory. +/// +/// Split out so the policy can be tested without setting `HOME`, which is +/// process-global and would race the other tests in this binary. +fn sweep_orphans_in(dir: &std::path::Path) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + + let own_pid = std::process::id(); + let now = std::time::SystemTime::now(); + let mut system: Option = None; + let mut removed = 0usize; + + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + + let Some(pid) = SWEEPABLE_PREFIXES + .iter() + .find_map(|prefix| name.strip_prefix(prefix)) + .and_then(|rest| rest.strip_suffix(".json")) + .and_then(|pid| pid.parse::().ok()) + else { + continue; + }; + if pid == own_pid { + continue; + } + + let old_enough = entry + .metadata() + .and_then(|meta| meta.modified()) + .ok() + .and_then(|modified| now.duration_since(modified).ok()) + .is_some_and(|age| age >= SWEEP_MIN_AGE); + if !old_enough { + continue; + } + + // Only pay for the process table once, and only if something is + // actually old enough to be a candidate. + let system = system.get_or_insert_with(sysinfo::System::new); + if system.refresh_process(sysinfo::Pid::from_u32(pid)) { + continue; + } + + if std::fs::remove_file(entry.path()).is_ok() { + removed += 1; + } + } + + if removed > 0 { + tracing::info!("[crash_phase] swept {removed} orphaned breadcrumb(s)"); + } +} + /// RAII phase marker. Stamps `phase` on creation and resets to `serving` when /// dropped — i.e. on normal completion or unwind. A native crash (SIGSEGV / /// 0xC0000005 access violation) never runs the drop, so the phase stays @@ -64,3 +154,132 @@ impl Drop for PhaseGuard { mark("serving"); } } + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{Duration, SystemTime}; + + /// A pid that cannot be running: above the maximum any platform allocates, + /// so it can never collide with a live process on the test machine. + const DEAD_PID: u32 = 4_000_000_000; + + /// Scratch `.codegraph` directory, removed on drop. + struct Scratch(PathBuf); + + impl Scratch { + fn new(tag: &str) -> Self { + let dir = std::env::temp_dir().join(format!( + "codegraph-sweep-{tag}-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + Self(dir) + } + + /// Write a marker and backdate it, so the age branch can be exercised + /// without the test sleeping. + fn write(&self, name: &str, age: Duration) { + let path = self.0.join(name); + std::fs::write(&path, "{}").unwrap(); + std::fs::File::options() + .write(true) + .open(&path) + .unwrap() + .set_modified(SystemTime::now() - age) + .unwrap(); + } + + fn exists(&self, name: &str) -> bool { + self.0.join(name).exists() + } + + fn sweep(&self) { + sweep_orphans_in(&self.0); + } + } + + impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn sweeps_old_markers_from_dead_processes() { + let scratch = Scratch::new("dead"); + let phase = format!("last-phase.{DEAD_PID}.json"); + let crash = format!("last-crash.{DEAD_PID}.json"); + scratch.write(&phase, SWEEP_MIN_AGE * 2); + scratch.write(&crash, SWEEP_MIN_AGE * 2); + + scratch.sweep(); + + assert!(!scratch.exists(&phase)); + assert!(!scratch.exists(&crash)); + } + + #[test] + fn keeps_recent_markers_even_from_dead_processes() { + // The client may not have read this crash yet - deleting it would + // destroy the diagnosis for the crash that just happened. + let scratch = Scratch::new("recent"); + let name = format!("last-crash.{DEAD_PID}.json"); + scratch.write(&name, Duration::from_secs(5)); + + scratch.sweep(); + + assert!(scratch.exists(&name)); + } + + #[test] + fn keeps_markers_belonging_to_live_processes() { + // A long-running engine sitting idle: old marker, live process. Removing + // it would lose the phase attribution if it later crashes hard. + let scratch = Scratch::new("live"); + let name = format!("last-phase.{}.json", std::process::id()); + scratch.write(&name, SWEEP_MIN_AGE * 2); + + scratch.sweep(); + + assert!(scratch.exists(&name)); + } + + #[test] + fn never_touches_recovery_breadcrumbs() { + // Recovery markers are reported once with no freshness window, so an old + // one is still meaningful to a client that has not read it. + let scratch = Scratch::new("recovery"); + let name = format!("last-recovery.{DEAD_PID}.json"); + scratch.write(&name, SWEEP_MIN_AGE * 100); + + scratch.sweep(); + + assert!(scratch.exists(&name)); + } + + #[test] + fn ignores_files_that_are_not_markers() { + let scratch = Scratch::new("unrelated"); + scratch.write("graph.db", SWEEP_MIN_AGE * 2); + scratch.write("last-phase.not-a-pid.json", SWEEP_MIN_AGE * 2); + + scratch.sweep(); + + assert!(scratch.exists("graph.db")); + assert!(scratch.exists("last-phase.not-a-pid.json")); + } + + #[test] + fn missing_directory_is_not_an_error() { + let absent = std::env::temp_dir().join(format!( + "codegraph-sweep-absent-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + + sweep_orphans_in(&absent); + } +} diff --git a/crates/codegraph-server/src/domain/node_props.rs b/crates/codegraph-server/src/domain/node_props.rs index ef1739e..d5ffd5e 100644 --- a/crates/codegraph-server/src/domain/node_props.rs +++ b/crates/codegraph-server/src/domain/node_props.rs @@ -117,3 +117,115 @@ pub(crate) fn is_public(node: &Node) -> bool { pub(crate) fn is_test(node: &Node) -> bool { node.properties.get_bool("is_test").unwrap_or(false) } + +/// Whether a caller node looks like test code: the structural [`is_test`] +/// marker, or a name/path heuristic for languages that don't record it. Shared +/// by CodeLens per-symbol stats and PR-review coverage so the two classify +/// callers identically and can't silently diverge. +pub(crate) fn is_test_like(node: &Node) -> bool { + if is_test(node) { + return true; + } + if is_test_name(name(node)) { + return true; + } + is_test_path(node.properties.get_string("path").unwrap_or("")) +} + +/// Name heuristic for test functions: `test_foo` or `foo_test`. Anchored to the +/// ends of the name because a substring match would also claim `run_tests`, +/// `setup_test_env` and other harness helpers, which are production code that +/// happens to drive tests - and dropping those from the lens is a silent loss. +fn is_test_name(name: &str) -> bool { + let name = name.to_lowercase(); + name.starts_with("test_") || name.ends_with("_test") +} + +/// Path heuristic for test files: a `tests` directory component, a `test_` +/// prefix, or a file name following one of the suffix conventions the supported +/// languages use. Rust and Python put the marker in front (`test_foo.py`); Go, +/// JavaScript, Ruby and Java put it behind (`foo_test.go`, `foo.test.ts`, +/// `foo_spec.rb`, `FooTest.java`), and recognising only the prefix classified +/// every Go and Java test as a production caller. +/// +/// Separators are normalised first because node paths are stored with the +/// indexing host's native separator, so a Windows `tests\foo.rs` would +/// otherwise never match. +fn is_test_path(path: &str) -> bool { + let path = path.replace('\\', "/"); + if path.starts_with("tests/") + || path.starts_with("test_") + || path.contains("/tests/") + || path.contains("/test_") + { + return true; + } + + let file = path.rsplit('/').next().unwrap_or(""); + // Anchored on the separator before the extension so `contest.rs` and + // `manifest.go` stay production code. + if ["_test.", ".test.", "_spec.", ".spec."] + .iter() + .any(|marker| file.contains(marker)) + { + return true; + } + // Case-sensitive, and on the stem only: `Test`/`Tests` is the Java and C# + // convention, while a lowercase match would claim `latest.rs`. + let stem = file.split('.').next().unwrap_or(""); + stem.ends_with("Test") || stem.ends_with("Tests") +} + +#[cfg(test)] +mod tests { + use super::{is_test_name, is_test_path}; + + #[test] + fn test_name_matches_at_word_boundaries() { + assert!(is_test_name("test_parses_empty_input")); + assert!(is_test_name("lower_bound_test")); + assert!(is_test_name("TEST_Uppercase")); + } + + #[test] + fn test_name_rejects_harness_helpers() { + assert!(!is_test_name("run_tests")); + assert!(!is_test_name("setup_test_env")); + assert!(!is_test_name("latest_snapshot")); + } + + #[test] + fn test_path_matches_windows_separators() { + assert!(is_test_path(r"C:\repo\tests\navigation.rs")); + assert!(is_test_path(r"C:\repo\src\test_helpers.rs")); + assert!(is_test_path(r"tests\navigation.rs")); + } + + #[test] + fn test_path_matches_unix_separators() { + assert!(is_test_path("/repo/tests/navigation.rs")); + assert!(is_test_path("/repo/src/test_helpers.rs")); + assert!(is_test_path("tests/navigation.rs")); + } + + #[test] + fn test_path_matches_suffix_conventions() { + // Go, JavaScript/TypeScript, Ruby and Java all put the marker last. + assert!(is_test_path("/repo/internal/parser_test.go")); + assert!(is_test_path("/repo/src/parser.test.ts")); + assert!(is_test_path("/repo/spec/models/user_spec.rb")); + assert!(is_test_path("/repo/src/parser.spec.js")); + assert!(is_test_path("/repo/src/main/java/com/x/ParserTest.java")); + assert!(is_test_path(r"C:\repo\src\ParserTests.cs")); + } + + #[test] + fn test_path_rejects_production_paths() { + assert!(!is_test_path("/repo/src/latest/mod.rs")); + assert!(!is_test_path(r"C:\repo\src\contest.rs")); + // Ends in "test" only in lowercase, which is not a suffix convention. + assert!(!is_test_path("/repo/src/manifest.go")); + assert!(!is_test_path("/repo/src/latest.rs")); + assert!(!is_test_path("/repo/src/protest.java")); + } +} diff --git a/crates/codegraph-server/src/handlers/navigation.rs b/crates/codegraph-server/src/handlers/navigation.rs index ec04ff7..bb7ff71 100644 --- a/crates/codegraph-server/src/handlers/navigation.rs +++ b/crates/codegraph-server/src/handlers/navigation.rs @@ -101,6 +101,32 @@ pub struct WorkspaceSymbolsResponse { pub symbols: Vec, } +/// Request for per-document CodeLens / hover stats. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DocumentCodeLensParams { + pub uri: String, +} + +/// Graph-derived stats for one function/method, shown inline as a CodeLens and +/// on hover. Counts only; the editor formats them. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CodeLensSymbol { + pub name: String, + /// 0-based start line (LSP convention), so the client anchors without math. + pub line: u32, + pub caller_count: u32, + pub test_count: u32, + pub complexity: u32, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DocumentCodeLensResponse { + pub symbols: Vec, +} + impl CodeGraphBackend { /// Get workspace symbols, optionally filtered by query. pub async fn handle_get_workspace_symbols( @@ -181,6 +207,74 @@ impl CodeGraphBackend { Ok(WorkspaceSymbolsResponse { symbols }) } + + /// Compute per-function CodeLens stats for a single document in one pass: + /// caller count, test count, and cyclomatic complexity for every function + /// or method symbol in the file. Batched so the editor issues one request + /// per document rather than N per-symbol calls. Test functions are skipped + /// (a CodeLens on a test is noise), and incoming callers are split into + /// test vs non-test using the same rule as PR review. + pub async fn handle_get_document_code_lens( + &self, + params: DocumentCodeLensParams, + ) -> Result { + let path = Url::parse(¶ms.uri) + .ok() + .and_then(|u| u.to_file_path().ok()) + .ok_or_else(|| tower_lsp::jsonrpc::Error::invalid_params("Invalid uri"))?; + + let graph = self.graph.read().await; + let node_ids = self.symbol_index.get_file_symbols(&path); + + let mut symbols = Vec::new(); + for node_id in node_ids { + let Ok(node) = graph.get_node(node_id) else { + continue; + }; + // Skip non-functions and test functions themselves - a CodeLens on + // a test is noise. Use is_test_like (structural marker + name/path + // heuristic) so languages without a structural test marker (e.g. + // Python `test_*`) are skipped too, matching caller classification. + if node.node_type != codegraph::NodeType::Function || node_props::is_test_like(node) { + continue; + } + + let mut caller_count = 0u32; + let mut test_count = 0u32; + // Only genuine call edges count - a raw incoming-neighbor scan also + // returns the containing file/class `Contains` edge, which would + // inflate every function by one. Mirror the canonical + // `helpers::get_callers` Calls-edge filter, resolved during the + // adjacency walk so a hub function costs one pass over its own + // incoming edges rather than a pass over every caller's outgoing + // edges - this runs on every document edit. + if let Ok(callers) = graph.get_neighbors_by_edge_type( + node_id, + codegraph::Direction::Incoming, + codegraph::EdgeType::Calls, + ) { + for caller_id in callers { + if let Ok(caller) = graph.get_node(caller_id) { + if node_props::is_test_like(caller) { + test_count += 1; + } else { + caller_count += 1; + } + } + } + } + + symbols.push(CodeLensSymbol { + name: node_props::name(node).to_string(), + line: node_props::line_start(node).saturating_sub(1), + caller_count, + test_count, + complexity: node.properties.get_int("complexity").unwrap_or(0).max(0) as u32, + }); + } + + Ok(DocumentCodeLensResponse { symbols }) + } } #[cfg(test)] @@ -456,4 +550,100 @@ mod tests { assert_eq!(symbol.language, "rust"); assert!(!symbol.uri.is_empty()); } + + #[tokio::test] + async fn test_get_document_code_lens_counts_callers_tests_complexity() { + use codegraph::EdgeType; + + let graph = Arc::new(RwLock::new( + CodeGraph::in_memory().expect("Failed to create graph"), + )); + + let target_path = "/test/lens.rs"; + let (target_id, _prod_caller, _test_caller, _skipped_test) = { + let mut g = graph.write().await; + + let mk = |g: &mut CodeGraph, name: &str, path: &str, line: i64, is_test: bool| { + let mut p = PropertyMap::new(); + p.insert("name".to_string(), PropertyValue::String(name.to_string())); + p.insert("path".to_string(), PropertyValue::String(path.to_string())); + p.insert("start_line".to_string(), PropertyValue::Int(line)); + p.insert("end_line".to_string(), PropertyValue::Int(line + 5)); + p.insert("complexity".to_string(), PropertyValue::Int(7)); + p.insert("is_test".to_string(), PropertyValue::Bool(is_test)); + g.add_node(NodeType::Function, p).unwrap() + }; + + // Symbol under inspection, plus a test function in the same file + // (must be skipped in the output). + let target = mk(&mut g, "do_work", target_path, 5, false); + let skipped_test = mk(&mut g, "test_does_work", target_path, 40, true); + // A production caller and a test caller, both in other files. + let prod_caller = mk(&mut g, "run", "/test/main.rs", 3, false); + let test_caller = mk(&mut g, "test_do_work", "/test/lens_test.rs", 3, true); + + g.add_edge(prod_caller, target, EdgeType::Calls, PropertyMap::new()) + .unwrap(); + g.add_edge(test_caller, target, EdgeType::Calls, PropertyMap::new()) + .unwrap(); + + // The containing file's `Contains` edge is an incoming neighbor but + // must NOT be counted as a caller (regression guard). + let mut file_props = PropertyMap::new(); + file_props.insert( + "name".to_string(), + PropertyValue::String("lens.rs".to_string()), + ); + file_props.insert( + "path".to_string(), + PropertyValue::String(target_path.to_string()), + ); + let file_id = g.add_node(NodeType::CodeFile, file_props).unwrap(); + g.add_edge(file_id, target, EdgeType::Contains, PropertyMap::new()) + .unwrap(); + + (target, prod_caller, test_caller, skipped_test) + }; + + let query_engine = Arc::new(QueryEngine::new(Arc::clone(&graph))); + let backend = CodeGraphBackend::new_for_test(graph, query_engine); + let path = std::path::Path::new(target_path); + add_node_to_index(&backend, path, target_id, "do_work", "Function", 5, 10); + // The skipped in-file test must be indexed too, to prove it's filtered. + add_node_to_index( + &backend, + path, + _skipped_test, + "test_does_work", + "Function", + 40, + 45, + ); + + let uri = Url::from_file_path(target_path).unwrap().to_string(); + let response = backend + .handle_get_document_code_lens(DocumentCodeLensParams { uri }) + .await + .unwrap(); + + // Only the non-test function is reported. + assert_eq!(response.symbols.len(), 1); + let s = &response.symbols[0]; + assert_eq!(s.name, "do_work"); + assert_eq!(s.line, 4); // 1-based 5 -> 0-based 4 + assert_eq!(s.caller_count, 1); // run, not the test caller + assert_eq!(s.test_count, 1); // test_do_work + assert_eq!(s.complexity, 7); + } + + #[tokio::test] + async fn test_get_document_code_lens_invalid_uri_errors() { + let (backend, _, _) = create_backend_with_nodes().await; + let result = backend + .handle_get_document_code_lens(DocumentCodeLensParams { + uri: "not a uri".to_string(), + }) + .await; + assert!(result.is_err()); + } } diff --git a/crates/codegraph-server/src/lib.rs b/crates/codegraph-server/src/lib.rs index f0a2d87..ca02dd9 100644 --- a/crates/codegraph-server/src/lib.rs +++ b/crates/codegraph-server/src/lib.rs @@ -37,6 +37,7 @@ pub mod handlers; pub mod index; pub mod index_state; pub mod indexer; +pub mod lsp_exit; pub mod lsp_pro_hooks; pub mod mcp; pub mod memory; diff --git a/crates/codegraph-server/src/lsp_exit.rs b/crates/codegraph-server/src/lsp_exit.rs new file mode 100644 index 0000000..18df305 --- /dev/null +++ b/crates/codegraph-server/src/lsp_exit.rs @@ -0,0 +1,119 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +//! Makes the LSP `exit` notification actually terminate the process. +//! +//! tower-lsp 0.20's read loop only notices that the service has exited when the +//! *next* message arrives: `exit` is dispatched through `service.call()`, which +//! flips the state to `Exited` but does not break the loop, so +//! `framed_stdin.next().await` then blocks until another message shows up or +//! stdin reaches EOF. Measured against the real engine: `shutdown` answered in +//! 0.00s, the process was still alive 120 seconds after `exit`, and terminated +//! only when stdin closed. +//! +//! Both editor clients hide this by force-killing the engine - vscode- +//! languageclient after its stop timeout, LSP4IJ through +//! `ExecutionManagerImpl.stopProcess`. That is the important detail: the status +//! quo is already an abrupt kill, so returning from `main` a moment after +//! `shutdown` is *gentler* than what happens today, not riskier. It also fixes +//! the case no client covers - anything holding the pipe open after `exit`, +//! such as a supervisor reusing stdio, where the engine would otherwise linger +//! holding an entire graph in memory. +//! +//! The LSP specification says a client must send `exit` after the `shutdown` +//! response, and that no other request is valid in between, so treating +//! `shutdown` as the signal is safe: there is nothing legitimate left to serve. + +use std::time::Duration; +use tokio::sync::Notify; + +/// Signalled by the backend's `shutdown` handler. +static SHUTDOWN_REQUESTED: Notify = Notify::const_new(); + +/// How long to keep serving after `shutdown` before giving up on `exit`. +/// +/// A compliant client sends `exit` immediately, and tower-lsp handles it +/// without waking the read loop, so this is really just slack for in-flight +/// work to settle before the runtime is dropped. +const EXIT_GRACE: Duration = Duration::from_secs(2); + +/// Record that the client asked the server to shut down. +pub fn request_shutdown() { + signal(&SHUTDOWN_REQUESTED); +} + +/// Resolves once `shutdown` has been received and the grace period has passed. +/// +/// Intended to be raced against tower-lsp's `serve()` future. +pub async fn wait_for_exit() { + wait(&SHUTDOWN_REQUESTED).await; +} + +/// `notify_one` rather than `notify_waiters`: it stores a permit when nobody is +/// waiting yet, so a `shutdown` that arrives before `main` reaches the waiter +/// still counts. Losing it would reintroduce the hang this module exists to fix. +fn signal(notify: &Notify) { + notify.notify_one(); +} + +/// The waiting half, taking its [`Notify`] so the behaviour can be tested +/// without the process-global one - tests share a binary, and a permit stored +/// by one test would otherwise satisfy another's wait. +async fn wait(notify: &Notify) { + notify.notified().await; + tracing::info!("[lsp_exit] shutdown received; exiting in {EXIT_GRACE:?}"); + tokio::time::sleep(EXIT_GRACE).await; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test(start_paused = true)] + async fn waits_for_shutdown_before_resolving() { + // Without a shutdown request this must never resolve, or the engine + // would quit on its own while a client is still using it. + let notify = Notify::new(); + tokio::select! { + () = wait(¬ify) => panic!("resolved without a shutdown request"), + () = tokio::time::sleep(EXIT_GRACE * 100) => {} + } + } + + #[tokio::test(start_paused = true)] + async fn resolves_after_shutdown_plus_grace() { + let notify = Notify::new(); + signal(¬ify); + + tokio::time::timeout(EXIT_GRACE * 2, wait(¬ify)) + .await + .expect("should resolve once shutdown was requested"); + } + + #[tokio::test(start_paused = true)] + async fn does_not_resolve_before_the_grace_period() { + // Exiting the instant `shutdown` returns would cut off the response + // still being written, and any work settling behind it. + let notify = Notify::new(); + signal(¬ify); + + tokio::select! { + () = wait(¬ify) => panic!("exited before the grace period elapsed"), + () = tokio::time::sleep(EXIT_GRACE / 2) => {} + } + } + + #[tokio::test(start_paused = true)] + async fn signal_sent_before_waiting_is_not_lost() { + // The backend can call shutdown before main reaches the waiter; a + // dropped signal here would reintroduce the hang this module exists to + // fix. + let notify = Notify::new(); + signal(¬ify); + tokio::time::sleep(EXIT_GRACE * 5).await; + + tokio::time::timeout(EXIT_GRACE * 2, wait(¬ify)) + .await + .expect("a signal sent before the waiter existed must still count"); + } +} diff --git a/crates/codegraph-server/src/main.rs b/crates/codegraph-server/src/main.rs index 6ac0250..e4930fa 100644 --- a/crates/codegraph-server/src/main.rs +++ b/crates/codegraph-server/src/main.rs @@ -294,6 +294,10 @@ fn main() { async fn run() { install_crash_handlers(); codegraph_server::crash_phase::mark("startup"); + // Clean up after processes that were killed before they could clear their + // own marker, which is every engine a client force-kills. Runs after our + // own mark so this process's marker is never a candidate. + codegraph_server::crash_phase::sweep_orphans(); let args = Args::parse(); @@ -481,8 +485,34 @@ async fn run() { let (service, socket) = LspService::new(CodeGraphBackend::new); codegraph_server::crash_phase::mark("serving"); - Server::new(stdin, stdout, socket).serve(service).await; + // Race the serve loop against the exit signal: tower-lsp will not + // return from `serve()` on the `exit` notification alone. + let exited_on_request = tokio::select! { + () = Server::new(stdin, stdout, socket).serve(service) => { + tracing::info!("LSP stream closed"); + false + } + () = codegraph_server::lsp_exit::wait_for_exit() => { + tracing::info!("Exiting after client shutdown"); + true + } + }; codegraph_server::crash_phase::clear(); + + if exited_on_request { + // Returning here would hang. `tokio::io::stdin()` reads on a + // blocking-pool thread that cannot be cancelled, and dropping the + // runtime waits for blocking tasks to finish - a read that only + // completes when the client closes the pipe, which is exactly the + // wait we are trying to avoid. + // + // Cleanup that matters has already run: the crash breadcrumb is + // cleared above, and the client has had its shutdown response plus + // the grace period. This path replaces a SIGKILL from the client, + // so it is strictly the gentler of the two. + tracing::info!("Exit complete"); + std::process::exit(0); + } } } diff --git a/crates/codegraph-server/src/mcp/server.rs b/crates/codegraph-server/src/mcp/server.rs index e244e72..1ecf815 100644 --- a/crates/codegraph-server/src/mcp/server.rs +++ b/crates/codegraph-server/src/mcp/server.rs @@ -4098,26 +4098,28 @@ impl McpServer { } changed_func_names.push(func_name.as_str()); - // Collect callers + // Collect callers. Only genuine call edges: a raw + // incoming-neighbor scan also returns the containing + // file/class `Contains` edge, which would report the + // declaring file as a breaking caller of every function. + // Same filter as the CodeLens handler, so PR-review and + // CodeLens agree on the count as well as on what a test + // caller is. let mut caller_count = 0u32; let mut has_test_caller = false; - if let Ok(neighbors) = - graph.get_neighbors(*node_id, codegraph::Direction::Incoming) - { + if let Ok(neighbors) = graph.get_neighbors_by_edge_type( + *node_id, + codegraph::Direction::Incoming, + codegraph::EdgeType::Calls, + ) { for caller_id in neighbors { if let Ok(caller) = graph.get_node(caller_id) { let cname = crate::domain::node_props::name(caller); let cfile = caller.properties.get_string("path").unwrap_or(""); - // Prefer the structural is_test marker recorded at index time - // (#[test]/#[cfg(test)], @Test, …); fall back to name/path - // heuristics only for languages that don't populate it. The - // heuristics alone miss idiomatic Rust tests with descriptive - // names inside `#[cfg(test)] mod tests`. - let is_test = crate::domain::node_props::is_test(caller) - || cname.to_lowercase().starts_with("test_") - || cname.to_lowercase().contains("_test") - || cfile.contains("/tests/") - || cfile.contains("/test_"); + // Shared classifier (structural is_test marker + name/path + // heuristics) so PR-review and CodeLens agree on what a test + // caller is. + let is_test = crate::domain::node_props::is_test_like(caller); // Callers under examples/ (and doctests) exercise the // function at runtime — count them as coverage, not as // breakable production callers. This is what covers code diff --git a/crates/codegraph-server/src/mcp/tools.rs b/crates/codegraph-server/src/mcp/tools.rs index 6b61ecd..2f52552 100644 --- a/crates/codegraph-server/src/mcp/tools.rs +++ b/crates/codegraph-server/src/mcp/tools.rs @@ -9,7 +9,7 @@ use super::protocol::{PropertySchema, Tool, ToolInputSchema}; use std::collections::HashMap; -/// Scoped tool surface selector. The full 32-tool MCP surface is large +/// Scoped tool surface selector. The full 42-tool MCP surface is large /// enough that agents pay non-trivial prompt-context cost listing them; /// a profile lets the user expose only the subset relevant to their /// session (memory-heavy notetaking, structural refactoring, etc.). diff --git a/crates/codegraph-server/src/parser_registry.rs b/crates/codegraph-server/src/parser_registry.rs index af4a0cc..2ac7add 100644 --- a/crates/codegraph-server/src/parser_registry.rs +++ b/crates/codegraph-server/src/parser_registry.rs @@ -774,11 +774,38 @@ mod tests { let names: Vec<&str> = metrics.iter().map(|(n, _)| *n).collect(); #[cfg_attr(not(feature = "extra-languages"), allow(unused_mut))] let mut expected = vec![ - "bash", "c", "clojure", "cpp", "css", "csharp", "dockerfile", - "elixir", "elm", "erlang", "go", "groovy", "haskell", "hcl", "java", - "julia", "kotlin", "lua", "objc", "ocaml", "php", "python", "ruby", - "rust", "scala", "solidity", "swift", "tcl", "toml", "typescript", - "verilog", "yaml", + "bash", + "c", + "clojure", + "cpp", + "css", + "csharp", + "dockerfile", + "elixir", + "elm", + "erlang", + "go", + "groovy", + "haskell", + "hcl", + "java", + "julia", + "kotlin", + "lua", + "objc", + "ocaml", + "php", + "python", + "ruby", + "rust", + "scala", + "solidity", + "swift", + "tcl", + "toml", + "typescript", + "verilog", + "yaml", ]; // Gated grammars are appended after the base set (see `all_metrics`). #[cfg(feature = "extra-languages")] diff --git a/crates/codegraph/src/graph/codegraph.rs b/crates/codegraph/src/graph/codegraph.rs index 794b35a..1530c85 100644 --- a/crates/codegraph/src/graph/codegraph.rs +++ b/crates/codegraph/src/graph/codegraph.rs @@ -376,6 +376,53 @@ impl CodeGraph { Ok(neighbors.into_iter().collect()) } + /// Get neighbors reachable by edges of a single type. + /// + /// Equivalent to filtering [`get_neighbors`](Self::get_neighbors) by edge + /// type, but resolves the type during the adjacency walk. Callers that need + /// only one relation (e.g. incoming `Calls`) should use this rather than + /// pairing `get_neighbors` with a `get_edges_between` lookup per neighbor, + /// which costs an extra pass over each neighbor's full outgoing adjacency. + /// + /// # Errors + /// + /// Returns error if node not found. + pub fn get_neighbors_by_edge_type( + &self, + node_id: NodeId, + direction: Direction, + edge_type: EdgeType, + ) -> Result> { + self.get_node(node_id)?; + + let mut neighbors = HashSet::new(); + + let mut collect = |edges: Option<&HashSet>, incoming: bool| { + for edge_id in edges.into_iter().flatten() { + if let Ok(edge) = self.get_edge(*edge_id) { + if edge.edge_type == edge_type { + neighbors.insert(if incoming { + edge.source_id + } else { + edge.target_id + }); + } + } + } + }; + + match direction { + Direction::Outgoing => collect(self.adjacency_out.get(&node_id), false), + Direction::Incoming => collect(self.adjacency_in.get(&node_id), true), + Direction::Both => { + collect(self.adjacency_out.get(&node_id), false); + collect(self.adjacency_in.get(&node_id), true); + } + } + + Ok(neighbors.into_iter().collect()) + } + /// Get all edges between two nodes. /// /// Returns all edges from source to target. diff --git a/crates/codegraph/tests/unit/graph_ops_test.rs b/crates/codegraph/tests/unit/graph_ops_test.rs index 857073d..5c034d2 100644 --- a/crates/codegraph/tests/unit/graph_ops_test.rs +++ b/crates/codegraph/tests/unit/graph_ops_test.rs @@ -161,6 +161,84 @@ fn test_get_neighbors_both_directions() { assert!(neighbors.contains(&node_c)); } +#[test] +fn test_get_neighbors_by_edge_type_filters_other_relations() { + let mut graph = CodeGraph::in_memory().unwrap(); + + let target = graph + .add_node(NodeType::Function, PropertyMap::new()) + .unwrap(); + let caller = graph + .add_node(NodeType::Function, PropertyMap::new()) + .unwrap(); + let container = graph + .add_node(NodeType::CodeFile, PropertyMap::new()) + .unwrap(); + let callee = graph + .add_node(NodeType::Function, PropertyMap::new()) + .unwrap(); + + graph + .add_edge(caller, target, EdgeType::Calls, PropertyMap::new()) + .unwrap(); + // The containing file is an incoming neighbour too - counting it is what + // inflated every function's caller count by one. + graph + .add_edge(container, target, EdgeType::Contains, PropertyMap::new()) + .unwrap(); + graph + .add_edge(target, callee, EdgeType::Calls, PropertyMap::new()) + .unwrap(); + + let callers = graph + .get_neighbors_by_edge_type(target, Direction::Incoming, EdgeType::Calls) + .unwrap(); + assert_eq!(callers, vec![caller]); + + let callees = graph + .get_neighbors_by_edge_type(target, Direction::Outgoing, EdgeType::Calls) + .unwrap(); + assert_eq!(callees, vec![callee]); + + let both = graph + .get_neighbors_by_edge_type(target, Direction::Both, EdgeType::Calls) + .unwrap(); + assert_eq!(both.len(), 2); + assert!(both.contains(&caller)); + assert!(both.contains(&callee)); + + let contained = graph + .get_neighbors_by_edge_type(target, Direction::Incoming, EdgeType::Contains) + .unwrap(); + assert_eq!(contained, vec![container]); +} + +#[test] +fn test_get_neighbors_by_edge_type_deduplicates_parallel_edges() { + let mut graph = CodeGraph::in_memory().unwrap(); + + let target = graph + .add_node(NodeType::Function, PropertyMap::new()) + .unwrap(); + let caller = graph + .add_node(NodeType::Function, PropertyMap::new()) + .unwrap(); + + // A caller that calls the same function twice is one caller, matching + // get_neighbors. + graph + .add_edge(caller, target, EdgeType::Calls, PropertyMap::new()) + .unwrap(); + graph + .add_edge(caller, target, EdgeType::Calls, PropertyMap::new()) + .unwrap(); + + let callers = graph + .get_neighbors_by_edge_type(target, Direction::Incoming, EdgeType::Calls) + .unwrap(); + assert_eq!(callers, vec![caller]); +} + #[test] fn test_delete_node_cascades_to_edges() { let mut graph = CodeGraph::in_memory().unwrap(); diff --git a/docs/tool-calling-guide.md b/docs/tool-calling-guide.md index 6b80066..b125b95 100644 --- a/docs/tool-calling-guide.md +++ b/docs/tool-calling-guide.md @@ -1,6 +1,8 @@ # CodeGraph Tool Calling Guide -Reference for calling all 66 CodeGraph MCP tools (34 community + 32 pro, 22 security). Each tool is prefixed with `codegraph_` (e.g., `codegraph_symbol_search`). +Reference for calling the CodeGraph MCP tools — how to shape arguments and read results. +The [README's tool section](../README.md#tools) owns the inventory and the community/pro split; this guide does not repeat the counts. +Each tool is prefixed with `codegraph_` (e.g., `codegraph_symbol_search`). > **Pro tool extras (apply to every `codegraph_security_*` tool):** > All security detectors accept three cross-cutting parameters and emit shared diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..33b6205 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,58 @@ +# Troubleshooting + +## No files indexed + +CodeGraph reported "indexed 0 files". +The index is empty, so symbol search, the call graph, CodeLens and every agent tool have nothing to answer with. +The cause is almost always one of the four below, and the extension's notification already names which one it diagnosed. + +### No folder is open + +CodeGraph indexes workspace folders. +With no folder open there is nothing to walk. +Open the project folder (**File → Open Folder**) and run **CodeGraph: Reindex Workspace** from the command palette. + +### `codegraph.indexPaths` points at locations with no source files + +When `codegraph.indexPaths` is non-empty it *replaces* the whole-workspace scan: only those directories are indexed. +A path that was renamed, that lives outside the workspace folder, or that holds no supported source file yields an empty index even though the rest of the project is full of code. + +Fix it one of two ways: + +- Clear `codegraph.indexPaths` (set it to `[]`) to index every workspace folder. +- Correct the entries so they are workspace-relative paths that actually contain source, for example `["src", "crates"]`. + +Paths are resolved relative to the first workspace folder. + +### `codegraph.excludePatterns` matches everything + +`codegraph.excludePatterns` defaults to build and dependency directories (`**/node_modules/**`, `**/target/**`, `**/dist/**`, `**/build/**`, `**/vendor/**`, and similar). +A broad addition such as `**/src/**` or a lone `**` removes every candidate file and the index comes back empty. + +Remove or narrow the offending pattern. +Patterns are globs matched against the full path, so anchor them at the directory you mean: `**/generated/**`, not `**`. + +### No files in a supported language + +CodeGraph parses 38 languages (see the language table in the [README](../README.md#languages)). +A workspace made only of, say, Markdown, JSON and images has nothing for the parsers to do, and that is expected. + +Two non-obvious variants of this: + +- **Community build limits.** COBOL, Fortran, Perl, Dart, Zig and R are only compiled into builds made with `--features extra-languages`. A project written entirely in one of those indexes as zero files on the default community engine. +- **File size cap.** Files larger than `codegraph.maxFileSizeKB` (default 1024 KB) are skipped. Generated single-file sources can exceed it; raise the setting if that is your case. + +### Files are present and it still indexes zero + +If supported files exist, are inside the index scope, and survive the excludes, but the count is still zero, this is a bug rather than a configuration problem. + +Collect the details before reporting: + +1. Open the **CodeGraph** output channel (**View → Output**, then pick *CodeGraph*) and look for parse or engine-startup errors. +2. Check the engine responds: `codegraph-server --info`. +3. Open an issue at with the output channel contents, your `codegraph.*` settings, and the languages in the workspace. + +## Related + +- [README — Configuration](../README.md#configuration) for the full settings and MCP flag reference. +- [README — Quick Start](../README.md#quick-start) to verify the engine and client are installed as expected. diff --git a/jetbrains/.gitignore b/jetbrains/.gitignore new file mode 100644 index 0000000..76d36dd --- /dev/null +++ b/jetbrains/.gitignore @@ -0,0 +1,6 @@ +.gradle/ +build/ +.intellijPlatform/ +.idea/ +*.iml +local.properties diff --git a/jetbrains/README.md b/jetbrains/README.md new file mode 100644 index 0000000..e2a435b --- /dev/null +++ b/jetbrains/README.md @@ -0,0 +1,231 @@ + + +# CodeGraph for JetBrains IDEs + +A thin client for the CodeGraph engine, the same `codegraph-server` binary the VS Code extension drives. + +## Architecture + +All analysis lives in the Rust engine. +The plugin spawns it, speaks LSP over stdio, and renders the results. + +``` +IntelliJ IDEA / PyCharm / GoLand / Android Studio ... + │ + ├── LSP4IJ ......... JSON-RPC transport + document synchronisation + │ └── codegraph-server (Rust) LSP over stdio + │ + ├── CodeGraphClient every capability, as workspace/executeCommand + └── UI surfaces tool windows, Code Vision, graph panel +``` + +The engine exposes no editor-specific behaviour: every feature is a +`workspace/executeCommand` call listed in [`CodeGraphCommand`](src/main/kotlin/ai/codegraph/jetbrains/lsp/CodeGraphCommand.kt). +That is why a second editor client is mostly UI work. + +### Why LSP4IJ rather than the platform LSP API + +The IntelliJ Platform's own `com.intellij.platform.lsp` API is available only in +the paid IDEs. +Depending on it would exclude IntelliJ IDEA Community, PyCharm Community and +Android Studio, which is the larger share of the audience. +LSP4IJ is Apache-2.0, works on every JetBrains IDE from 2024.2, and exposes the +underlying LSP4J `LanguageServer`, so dropping to raw LSP4J stays available if +the dependency ever becomes a problem. + +## Engine resolution + +The plugin does **not** bundle engine binaries, and neither does any other +client any more: bundling all four platforms meant a ~120 MB download for the +one binary a given user can actually run. +The engine is published once as GitHub release assets and each client fetches +what its platform needs, into the shared `~/.codegraph/bin`. + +Resolution order, implemented in +[`CodeGraphServerResolver`](src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphServerResolver.kt): + +1. Explicit path from settings +2. CodeGraph Pro on `PATH`, then its known install directories +3. `codegraph-server` on `PATH` (npm or homebrew installs) +4. An engine under `~/.codegraph/bin` +5. Cargo build output, when the open project is the CodeGraph repo itself + +### Installing the engine + +When no engine is found, the plugin offers to download the one built for this +platform, verifying it against the published `.sha256` before installing it into +`~/.codegraph/bin`. +The release it asks for is `CodeGraphServerResolver.ENGINE_VERSION` - the +*engine's* version, not the plugin's, since the assets are tagged with the +former and a plugin-only patch would otherwise 404. +It is offered rather than done automatically: this is a native binary that will +run with the user's permissions, and starting that unasked on project open is +not the plugin's decision to make. + +On Windows the download also fetches `onnxruntime.dll`, which the engine loads +at runtime - fetching only the executable produces an install that succeeds and +then fails at startup. + +Users who prefer to manage it themselves can install the engine separately, +which step 3 then finds: + +```sh +npm i -g @astudioplus/codegraph-mcp +``` + +The release assets come from `scripts/publish-release-assets.sh` in the repo +root, run after the per-platform binaries are built. + +## Surfaces + +| Surface | Backed by | Notes | +|---|---|---| +| Code Vision | `codegraph.getDocumentCodeLens` | Callers, tests and complexity above declarations | +| Symbols tool window | `codegraph.getWorkspaceSymbols` | Tree with search; double-click navigates | +| Graph panel | `codegraph.getDependencyGraph`, `codegraph.getCallGraph` | JCEF, with a text fallback | +| Status bar | engine state | Distinguishes "no results" from "not running" | + +Code Vision never blocks the daemon: a cache miss returns nothing, schedules one +fetch and restarts the daemon when the answer lands. + +The graph panel renders a self-contained page - a small force simulation +emitting SVG, no external scripts. A CDN dependency would be less code and would +fail on exactly the machines that most need it to work: offline, air-gapped, or +behind a blocking proxy. JCEF is absent from some JBR builds and from Remote Dev +clients, so an unavailable browser degrades to a text listing. + +One caveat worth knowing when calling the engine directly: +`getWorkspaceSymbols` treats a **missing** `query` as "functions, classes and +modules" but an **empty string** as "modules only". Sending `""` for the +unfiltered view yields an empty tree on a perfectly healthy index. + +## AI tooling + +The VS Code client declares 28 `languageModelTools`. Those are Copilot-specific +and have no JetBrains equivalent, and reimplementing them would mean a second +hand-written tool list to keep in step with the engine. + +Instead, **Tools | CodeGraph | Register with AI Assistant** writes the engine's +own MCP mode into `/.mcp.json`, the `mcpServers` shape that Junie, +Claude Code, Cursor and the AI Assistant MCP settings all read: + +```json +{ + "mcpServers": { + "codegraph": { + "command": "/path/to/codegraph-server", + "args": ["--mcp", "--workspace", "/path/to/project", + "--embedding-model", "bge-small", "--full-body-embedding"] + } + } +} +``` + +Verified end to end: that exact command answers an MCP `initialize` and lists +**42 tools** - more than the VS Code client declares by hand, which is the +argument for this approach rather than a port. + +Registration merges rather than overwrites; a project that already points at +other MCP servers keeps them. The config is also offered on the clipboard, +because every AI client keeps its MCP configuration somewhere different and +pasting is the one path that always works. + +## Engine lifecycle + +The engine is a native process that things outside the plugin can kill: +antivirus, the OOM killer, a missing system library. + +[`EngineLifecycle`](src/main/kotlin/ai/codegraph/jetbrains/server/EngineLifecycle.kt) +turns an unexpected death into one explained message, using the crash +breadcrumbs the engine leaves in `~/.codegraph`, and +[`RestartCircuitBreaker`](src/main/kotlin/ai/codegraph/jetbrains/server/RestartCircuitBreaker.kt) +stops the restart loop after three crashes in a minute. +Without the breaker, a host where the engine simply cannot run produces an +endless crash-restart cycle - in the VS Code client that showed up as single +machines generating 50+ crash events a week. + +## Building + +Requires JDK 21. + +```sh +export JAVA_HOME=/opt/homebrew/opt/openjdk@21 # or any JDK 21 +./gradlew buildPlugin # -> build/distributions/*.zip +``` + +Run the tests: + +```sh +./gradlew test +``` + +Run a sandbox IDE with the plugin installed: + +```sh +./gradlew runIde -PsandboxProject=/path/to/some/project +``` + +## Checking the IDE side + +A sandbox IDE normally needs a human to click a menu item before anything is +exercised, which leaves the integration that matters most - LSP4IJ carrying a +CodeGraph `executeCommand` to a live engine - as the only part never checked +automatically. +Arming the self-check runs it on project open and writes the verdict to the IDE +log: + +```sh +./gradlew runIde -PsandboxProject=/path/to/some/project \ + -PrunIdeSystemProperty=codegraph.selfcheck=true + +grep codegraph-selfcheck \ + .intellijPlatform/sandbox/codegraph-jetbrains/*/log/idea.log +``` + +The activity is inert without that system property, so it costs users nothing. + +**Trust the sandbox project first.** IntelliJ holds back every project activity +until a project is trusted, and in a sandbox the trust dialog is easy to miss - +the symptom is a plugin that loads cleanly and then does nothing at all, with no +error anywhere. Pre-trust the path before launching: + +```sh +cat > .intellijPlatform/sandbox/codegraph-jetbrains/*/config/options/trusted-paths.xml <<'XML' + + + + + +XML +``` + +Only one sandbox IDE can run at a time: a second instance fails to start with +`MVStoreException: This store is read-only` because the first still holds the +config store. + +## Checking the engine contract + +`scripts/engine_probe.py` replays, over raw stdio, exactly what the plugin +sends: the `initialize` options built by `CodeGraphConnectionProvider` followed +by the `executeCommand` calls the plugin makes. +It needs no IDE, so it answers in seconds the question a sandbox IDE answers in +minutes, and it diffs `CodeGraphCommand.kt` against the command list the engine +advertises so that hand-transcribed enum cannot drift unnoticed. + +```sh +python3 scripts/engine_probe.py ../target/release/codegraph-server .. +``` + +Two engine deviations the probe was written to expose are now fixed in the +engine, and the probe asserts the fixed behaviour rather than tolerating the +old one: `codegraph.getDocumentCodeLens` is advertised as well as dispatched, +and the engine terminates on the LSP `exit` notification instead of waiting for +stdin to close. +The probe's `UNADVERTISED_BY_DESIGN` set is empty on purpose - a command that is +dispatched but not advertised is invisible to clients that gate on +`ServerCapabilities`, LSP4IJ among them, so a new entry needs a stated reason. diff --git a/jetbrains/build.gradle.kts b/jetbrains/build.gradle.kts new file mode 100644 index 0000000..0a1bb79 --- /dev/null +++ b/jetbrains/build.gradle.kts @@ -0,0 +1,140 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +import org.jetbrains.intellij.platform.gradle.TestFrameworkType +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion + +plugins { + id("java") + // 2.2.x is the oldest line with Gradle 9 support, which the IntelliJ + // Platform Gradle Plugin 2.18 now requires. + id("org.jetbrains.kotlin.jvm") version "2.2.21" + id("org.jetbrains.intellij.platform") version "2.18.1" +} + +group = "ai.codegraph" +version = providers.gradleProperty("pluginVersion").get() + +repositories { + mavenCentral() + intellijPlatform { + defaultRepositories() + } +} + +dependencies { + intellijPlatform { + create( + providers.gradleProperty("platformType"), + providers.gradleProperty("platformVersion"), + ) + // LSP4IJ carries the JSON-RPC transport and document synchronisation. + // It is a required runtime dependency, not a bundled library: the + // marketplace installs it alongside this plugin. + plugin( + providers.gradleProperty("lsp4ijVersion").map { "com.redhat.devtools.lsp4ij:$it" }, + ) + testFramework(TestFrameworkType.Platform) + } + + testImplementation("junit:junit:4.13.2") +} + +kotlin { + jvmToolchain(21) + compilerOptions { + jvmTarget = JvmTarget.JVM_21 + // Compile against the Kotlin API the *oldest supported* IDE actually + // guarantees - 2.0 for since-build 243, not the 2.1 the local compiler + // offers. Getting this wrong links against stdlib symbols that IDE does + // not ship, and the failure is a NoSuchMethodError at runtime on the + // user's machine rather than anything the build would show. + // Raise this only together with pluginSinceBuild. + apiVersion = KotlinVersion.KOTLIN_2_0 + languageVersion = KotlinVersion.KOTLIN_2_0 + freeCompilerArgs.add("-Xjvm-default=all") + } +} + +intellijPlatform { + pluginConfiguration { + id = "ai.codegraph.jetbrains" + name = "CodeGraph" + version = providers.gradleProperty("pluginVersion") + vendor { + name = "Andrey Vasilevsky" + email = "anvanster@gmail.com" + } + ideaVersion { + sinceBuild = providers.gradleProperty("pluginSinceBuild") + // Unbounded: the plugin uses stable platform APIs only, and an + // untilBuild pin would strand users on every IDE upgrade. + untilBuild = provider { null } + } + } + + pluginVerification { + ides { + // The two ends of the supported range, rather than `recommended()` + // - each IDE is a ~3 GB download and the middle tells us little. + // + // `current()` is what since-build promises. `latest` is what an + // unbounded until-build promises, and is deliberately not pinned: + // a pinned "newest" stops being newest without anyone noticing, + // which is precisely the break this is here to catch. + current() + latest {} + } + } +} + +/** + * Bakes the analytics key into the artifact from the release environment. + * Absent by default, so builds from source report nothing - matching how the + * VS Code client injects `__POSTHOG_KEY__` at bundle time. + */ +val generateTelemetryConfig = tasks.register("generateTelemetryConfig") { + val output = layout.buildDirectory.file("generated/telemetry/codegraph-telemetry.properties") + val key = providers.environmentVariable("CODEGRAPH_POSTHOG_KEY").orElse("") + val host = providers.environmentVariable("CODEGRAPH_POSTHOG_HOST").orElse("") + outputs.file(output) + inputs.property("key", key) + inputs.property("host", host) + doLast { + val file = output.get().asFile + file.parentFile.mkdirs() + file.writeText("posthogKey=${key.get()}\nposthogHost=${host.get()}\n") + } +} + +sourceSets { + main { + resources.srcDir(generateTelemetryConfig.map { it.outputs.files.singleFile.parentFile }) + } +} + +tasks { + // Generating searchable options boots a headless IDE purely to index the + // settings page. It roughly doubles build time for a marginal gain, and the + // settings this plugin exposes are reachable under an obvious name. + buildSearchableOptions { + enabled = false + } + + runIde { + // Open a project on launch so project-level services actually + // initialise; the welcome screen alone exercises almost nothing. + // Override with -PsandboxProject=/path/to/project. + val sandboxProject = providers.gradleProperty("sandboxProject").orNull + if (sandboxProject != null) { + args = listOf(sandboxProject) + } + // -PrunIdeSystemProperty=key=value, repeatable with commas. Used to arm + // the self-check activity without a bespoke Gradle task per flag. + providers.gradleProperty("runIdeSystemProperty").orNull + ?.split(",") + ?.mapNotNull { entry -> entry.split("=", limit = 2).takeIf { it.size == 2 } } + ?.forEach { (key, value) -> systemProperty(key, value) } + } +} diff --git a/jetbrains/gradle.properties b/jetbrains/gradle.properties new file mode 100644 index 0000000..5f1fccd --- /dev/null +++ b/jetbrains/gradle.properties @@ -0,0 +1,25 @@ +# Copyright 2026 Andrey Vasilevsky +# SPDX-License-Identifier: Apache-2.0 + +# The plugin's own release number, free to move independently of the other +# clients. The engine it fetches is pinned separately, in +# CodeGraphServerResolver.ENGINE_VERSION, so a plugin-only patch cannot start +# asking the release server for a tag that was never published. +pluginVersion=0.20.0 + +# Target platform. 243 = 2024.3, the oldest build LSP4IJ 0.20.x supports that +# also has a stable Code Vision API. Bumping this is a compatibility decision, +# not a convenience one. +platformType=IC +platformVersion=2024.3.5 +pluginSinceBuild=243 + +lsp4ijVersion=0.20.1 + +# The IDE bundles its own Kotlin stdlib; shipping a second copy in the plugin +# jar is the classic source of NoSuchMethodError at runtime. +kotlin.stdlib.default.dependency=false + +org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g +org.gradle.caching=true +org.gradle.configuration-cache=false diff --git a/jetbrains/gradle/wrapper/gradle-wrapper.jar b/jetbrains/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..b1b8ef5 Binary files /dev/null and b/jetbrains/gradle/wrapper/gradle-wrapper.jar differ diff --git a/jetbrains/gradle/wrapper/gradle-wrapper.properties b/jetbrains/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..1f2812c --- /dev/null +++ b/jetbrains/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,10 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14 +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/jetbrains/gradlew b/jetbrains/gradlew new file mode 100755 index 0000000..249efbb --- /dev/null +++ b/jetbrains/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/jetbrains/gradlew.bat b/jetbrains/gradlew.bat new file mode 100644 index 0000000..8508ef6 --- /dev/null +++ b/jetbrains/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/jetbrains/scripts/engine_probe.py b/jetbrains/scripts/engine_probe.py new file mode 100644 index 0000000..ddaee78 --- /dev/null +++ b/jetbrains/scripts/engine_probe.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +# Copyright 2026 Andrey Vasilevsky +# SPDX-License-Identifier: Apache-2.0 + +"""Contract check between the JetBrains plugin and the CodeGraph engine. + +Replays, over raw stdio, exactly what the plugin sends: the `initialize` request +built by `CodeGraphConnectionProvider.getInitializationOptions()`, followed by +the `workspace/executeCommand` calls the plugin makes. It needs no IDE, so it +runs in CI and answers the question the IDE cannot answer quickly: is the +protocol contract still intact? + +It also diffs `CodeGraphCommand.kt` against the command list the engine +advertises, which is the drift guard for that hand-transcribed enum. + +Usage: + python3 jetbrains/scripts/engine_probe.py +""" + +import json +import os +import re +import subprocess +import sys +import threading +import time + +if len(sys.argv) != 3: + sys.exit(__doc__) + +BIN, ROOT = sys.argv[1], os.path.abspath(sys.argv[2]) + +# Commands the engine dispatches but deliberately does not advertise. Each entry +# needs a reason: an unadvertised command is invisible to clients that gate on +# ServerCapabilities, which is how LSP4IJ behaves. +UNADVERTISED_BY_DESIGN = { + # Empty on purpose. getDocumentCodeLens used to live here - dispatched but + # not advertised - until the engine started advertising it. Add an entry + # only with a reason: an unadvertised command is invisible to clients that + # gate on ServerCapabilities, which is how LSP4IJ behaves. +} + +failures = [] + + +def check(ok, message): + print(("PASS " if ok else "FAIL ") + message) + if not ok: + failures.append(message) + + +proc = subprocess.Popen( + [BIN], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=ROOT, +) + +_next_id = [0] + + +def send(method, params, notify=False): + msg = {"jsonrpc": "2.0", "method": method} + # `shutdown` and `exit` take no params, and tower-lsp rejects an empty + # object with -32602 "Unexpected params" - which looks like a successful + # response to anything that only checks for a reply, and silently skips the + # server's shutdown handler entirely. Pass None to omit the field. + if params is not None: + msg["params"] = params + if not notify: + _next_id[0] += 1 + msg["id"] = _next_id[0] + body = json.dumps(msg).encode() + proc.stdin.write(b"Content-Length: %d\r\n\r\n" % len(body) + body) + proc.stdin.flush() + return msg.get("id") + + +def read_message(): + headers = {} + while True: + line = proc.stdout.readline() + if not line: + return None + line = line.strip() + if not line: + break + key, _, value = line.decode().partition(":") + headers[key.strip().lower()] = value.strip() + length = int(headers.get("content-length", 0)) + return json.loads(proc.stdout.read(length)) if length else None + + +def await_response(want_id, timeout=180): + deadline = time.time() + timeout + while time.time() < deadline: + msg = read_message() + if msg is None: + sys.exit("engine closed the stream") + if msg.get("id") == want_id and ("result" in msg or "error" in msg): + return msg + sys.exit(f"timed out waiting for response to id={want_id}") + + +def execute_command(command, arguments): + rid = send("workspace/executeCommand", {"command": command, "arguments": [arguments]}) + return await_response(rid) + + +threading.Thread( + target=lambda: [sys.stderr.write("[engine] " + line.decode(errors="replace")) + for line in iter(proc.stderr.readline, b"")], + daemon=True, +).start() + +# Mirrors CodeGraphConnectionProvider.getInitializationOptions(), except that +# indexOnStartup is forced off: the probe checks the protocol, not the indexer, +# and a full workspace index would dominate its runtime. +init_options = { + "extensionPath": os.path.expanduser("~/.codegraph/jetbrains"), + "indexOnStartup": False, + "excludePatterns": ["**/node_modules/**", "**/target/**", "**/.git/**"], + "indexPaths": [], + "maxFileSizeKB": 1024, + "embeddingModel": "bge-small", + "staticModelPath": None, + "fullBodyEmbedding": True, + "embedOnOpen": True, +} + +rid = send( + "initialize", + { + "processId": os.getpid(), + "rootUri": "file://" + ROOT, + "capabilities": {"workspace": {"executeCommand": {"dynamicRegistration": True}}}, + "initializationOptions": init_options, + "workspaceFolders": [{"uri": "file://" + ROOT, "name": os.path.basename(ROOT)}], + }, +) +response = await_response(rid) +capabilities = response["result"]["capabilities"] +advertised = set(capabilities.get("executeCommandProvider", {}).get("commands", [])) +check(bool(advertised), f"initialize -> {len(advertised)} commands advertised") + +send("initialized", {}, notify=True) + +response = execute_command("codegraph.getParserMetrics", {}) +check("error" not in response, f"getParserMetrics -> {str(response.get('error') or 'ok')[:120]}") + +response = execute_command("codegraph.symbolSearch", {"query": "main", "limit": 5}) +check("error" not in response, f"symbolSearch -> {json.dumps(response.get('result'))[:160]}") + +# getDocumentCodeLens backs the Code Vision surface. Called directly rather than +# inferred from the advertised list: it went unadvertised once, and a probe that +# only reads capabilities would have called that a clean run. +response = execute_command( + "codegraph.getDocumentCodeLens", {"uri": "file://" + os.path.join(ROOT, "README.md")} +) +check("error" not in response, f"getDocumentCodeLens -> {str(response.get('error') or 'ok')[:120]}") + +enum_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "src/main/kotlin/ai/codegraph/jetbrains/lsp/CodeGraphCommand.kt", +) +declared = set() +with open(enum_path) as handle: + for line in handle: + if '("codegraph.' in line: + declared.add(line.split('"')[1]) + +missing = sorted(advertised - declared) +check(not missing, f"CodeGraphCommand.kt covers every advertised command (missing: {missing})") + +undocumented = sorted(declared - advertised - set(UNADVERTISED_BY_DESIGN)) +check( + not undocumented, + f"every declared-but-unadvertised command has a recorded reason (undocumented: {undocumented})", +) + +# Settings defaults must agree with the VS Code client. They are separate +# hand-written files, and a divergence is invisible until it changes behaviour: +# defaulting indexOnStartup to true made the engine index during `initialize` +# while the plugin was still deciding whether to prompt for an index. +PARITY_KEYS = { + "indexOnStartup": "codegraph.indexOnStartup", + "maxFileSizeKB": "codegraph.maxFileSizeKB", + "embeddingModel": "codegraph.embeddingModel", + "fullBodyEmbedding": "codegraph.fullBodyEmbedding", + "embedOnOpen": "codegraph.embedOnOpen", +} + +KOTLIN_LITERALS = {"true": True, "false": False} + + +def kotlin_defaults(path): + """Parse `@JvmField var name: Type = value` declarations.""" + found = {} + pattern = re.compile(r"var\s+(\w+)\s*:\s*[\w<>]+\s*=\s*([^\n]+)") + with open(path) as handle: + for line in handle: + match = pattern.search(line) + if not match: + continue + name, raw = match.group(1), match.group(2).strip().rstrip(",") + if raw in KOTLIN_LITERALS: + found[name] = KOTLIN_LITERALS[raw] + elif raw.isdigit(): + found[name] = int(raw) + elif raw.startswith('"') and raw.endswith('"'): + found[name] = raw[1:-1] + return found + + +plugin_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +settings_path = os.path.join( + plugin_root, "src/main/kotlin/ai/codegraph/jetbrains/settings/CodeGraphSettings.kt" +) +vscode_package = os.path.join(os.path.dirname(plugin_root), "vscode/package.json") + +if os.path.exists(vscode_package): + kotlin = kotlin_defaults(settings_path) + with open(vscode_package) as handle: + contributes = json.load(handle)["contributes"]["configuration"] + properties = (contributes[0] if isinstance(contributes, list) else contributes)["properties"] + + drifted = [ + f"{kotlin_key}={kotlin.get(kotlin_key)!r} but {vscode_key}={properties[vscode_key].get('default')!r}" + for kotlin_key, vscode_key in PARITY_KEYS.items() + if vscode_key in properties and kotlin.get(kotlin_key) != properties[vscode_key].get("default") + ] + check(not drifted, f"settings defaults match the VS Code client ({'; '.join(drifted)})") +else: + print("SKIP settings-defaults parity (vscode/package.json not found)") + +rid = send("shutdown", None) +shutdown_response = await_response(rid, timeout=30) +check("error" not in shutdown_response, f"shutdown -> {str(shutdown_response.get('error') or 'ok')[:120]}") +send("exit", None, notify=True) + +# The engine must terminate on `exit` rather than waiting for stdin to close. +# It used to do the latter, which left it running under any client that keeps +# the pipe open. The clients mask it by force-killing, so this is asserted here +# rather than left to be noticed in the field. +EXIT_GRACE_SECONDS = 10 +try: + proc.wait(timeout=EXIT_GRACE_SECONDS) + check(True, f"engine honoured `exit` within {EXIT_GRACE_SECONDS}s") +except subprocess.TimeoutExpired: + check(False, f"engine ignored `exit`; still running after {EXIT_GRACE_SECONDS}s") + proc.stdin.close() + try: + proc.wait(timeout=30) + except subprocess.TimeoutExpired: + proc.kill() + +print() +print(f"{len(failures)} failure(s)") +sys.exit(1 if failures else 0) diff --git a/jetbrains/settings.gradle.kts b/jetbrains/settings.gradle.kts new file mode 100644 index 0000000..6bc36ff --- /dev/null +++ b/jetbrains/settings.gradle.kts @@ -0,0 +1,20 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +// The IntelliJ Platform Gradle Plugin resolves IDE distributions and marketplace +// plugins (LSP4IJ) through custom repositories that must be visible to the +// dependency-resolution layer as well as the plugin layer. +dependencyResolutionManagement { + repositories { + mavenCentral() + } +} + +rootProject.name = "codegraph-jetbrains" diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/actions/EngineRoundTripAction.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/actions/EngineRoundTripAction.kt new file mode 100644 index 0000000..13c187b --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/actions/EngineRoundTripAction.kt @@ -0,0 +1,70 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +package ai.codegraph.jetbrains.actions + +import ai.codegraph.jetbrains.lsp.CodeGraphClient +import ai.codegraph.jetbrains.lsp.CodeGraphCommand +import ai.codegraph.jetbrains.notify.CodeGraphNotifications +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.diagnostic.logger + +/** + * Diagnostic action: starts the engine and completes one `executeCommand` round + * trip, reporting what came back. + * + * This is the Phase 0 proof that the LSP4IJ transport carries CodeGraph's + * command surface unchanged. It stays in the plugin afterwards as the first + * thing to run when a user reports "CodeGraph does nothing" - it separates + * "engine never started" from "engine started but returned nothing". + */ +class EngineRoundTripAction : AnAction() { + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + override fun update(e: AnActionEvent) { + e.presentation.isEnabled = e.project != null + } + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val client = CodeGraphClient.getInstance(project) + + client.start() + CodeGraphNotifications.info(project, "Starting engine, status: ${client.status()}") + + client.execute(CodeGraphCommand.GET_PARSER_METRICS) + .thenCompose { metrics -> + val parsers = metrics?.takeIf { it.isJsonObject }?.asJsonObject?.size() ?: 0 + CodeGraphNotifications.info(project, "Engine replied: $parsers parser metric groups") + client.execute( + CodeGraphCommand.SYMBOL_SEARCH, + mapOf("query" to "main", "limit" to 5), + ) + } + .whenComplete { symbols, error -> + if (error != null) { + LOG.warn("Engine round trip failed", error) + CodeGraphNotifications.error( + project, + "Engine round trip failed: ${error.message ?: error::class.java.simpleName}", + ) + } else { + CodeGraphNotifications.info(project, "symbolSearch returned: ${summarize(symbols?.toString())}") + } + } + } + + private fun summarize(raw: String?): String = when { + raw == null -> "null" + raw.length <= MAX_PREVIEW -> raw + else -> raw.take(MAX_PREVIEW) + "..." + } + + private companion object { + val LOG = logger() + const val MAX_PREVIEW = 400 + } +} diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/actions/RegisterMcpAction.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/actions/RegisterMcpAction.kt new file mode 100644 index 0000000..da799f6 --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/actions/RegisterMcpAction.kt @@ -0,0 +1,74 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +package ai.codegraph.jetbrains.actions + +import ai.codegraph.jetbrains.mcp.McpRegistration +import ai.codegraph.jetbrains.notify.CodeGraphNotifications +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.ide.CopyPasteManager +import com.intellij.openapi.vfs.LocalFileSystem +import java.awt.datatransfer.StringSelection + +/** + * Points the IDE's AI tooling at the CodeGraph engine over MCP. + * + * Writing `.mcp.json` covers the clients that read it from the project root. + * The config is also offered on the clipboard, because MCP configuration lives + * in a different place in every AI client and pasting it is the one path that + * always works. + */ +class RegisterMcpAction : AnAction() { + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + override fun update(e: AnActionEvent) { + val project = e.project + e.presentation.isEnabled = project != null + e.presentation.text = if (project != null && McpRegistration.isRegistered(project)) { + "Update AI Assistant Registration" + } else { + "Register with AI Assistant" + } + } + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + + when (val result = McpRegistration.register(project)) { + is McpRegistration.Result.Written -> { + LocalFileSystem.getInstance().refreshAndFindFileByNioFile(result.path) + val note = if (result.merged) " alongside the servers already configured there" else "" + // Silently replacing a config we could not parse would lose + // whatever else was in it, so say what happened to it. + val rescued = result.backup?.let { + " The previous file could not be parsed and was kept as ${it.fileName}." + }.orEmpty() + CodeGraphNotifications.infoWithActions( + project, + "CodeGraph is registered as an MCP server in ${result.path.fileName}$note. " + + "Restart your AI client to pick it up.$rescued", + "Copy Config" to { notification -> + notification.expire() + copyConfig(e) + }, + ) + } + + is McpRegistration.Result.NoEngine -> + CodeGraphNotifications.warn(project, result.reason) + + is McpRegistration.Result.Failed -> + CodeGraphNotifications.error(project, "Could not write the MCP config: ${result.reason}") + } + } + + private fun copyConfig(e: AnActionEvent) { + val project = e.project ?: return + val snippet = McpRegistration.configSnippet(project) ?: return + CopyPasteManager.getInstance().setContents(StringSelection(snippet)) + CodeGraphNotifications.info(project, "MCP configuration copied to the clipboard.") + } +} diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/actions/ReindexWorkspaceAction.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/actions/ReindexWorkspaceAction.kt new file mode 100644 index 0000000..d78b5ad --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/actions/ReindexWorkspaceAction.kt @@ -0,0 +1,29 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +package ai.codegraph.jetbrains.actions + +import ai.codegraph.jetbrains.indexing.IndexingService +import ai.codegraph.jetbrains.lsp.CodeGraphClient +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent + +/** Rebuild the workspace graph from scratch. */ +class ReindexWorkspaceAction : AnAction() { + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + override fun update(e: AnActionEvent) { + e.presentation.isEnabled = e.project != null + } + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + // Reindexing is the usual reason a user reaches for this after the + // engine died, so make sure it is running rather than failing the + // command on a stopped engine. + CodeGraphClient.getInstance(project).start() + IndexingService.getInstance(project).reindexInBackground() + } +} diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/actions/ShowGraphAction.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/actions/ShowGraphAction.kt new file mode 100644 index 0000000..dd7bf44 --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/actions/ShowGraphAction.kt @@ -0,0 +1,87 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +package ai.codegraph.jetbrains.actions + +import ai.codegraph.jetbrains.graph.GraphKind +import ai.codegraph.jetbrains.graph.GraphPanel +import ai.codegraph.jetbrains.lsp.CodeGraphClient +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.wm.ToolWindowManager +import com.intellij.ui.content.ContentFactory + +/** + * Opens a graph for the current file in a tab of the CodeGraph tool window. + * + * A tool window tab rather than an editor tab: the graph is a companion to the + * code you are reading, and putting it in the editor area means it competes + * with the file it describes. + */ +sealed class ShowGraphAction(private val kind: GraphKind) : AnAction() { + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + override fun update(e: AnActionEvent) { + e.presentation.isEnabled = e.project != null && currentFile(e) != null + } + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val file = currentFile(e) ?: return + + CodeGraphClient.getInstance(project).start() + + val toolWindow = ToolWindowManager.getInstance(project).getToolWindow(TOOL_WINDOW_ID) ?: return + val contentManager = toolWindow.contentManager + val label = "${kind.title}: ${file.name}" + + // This is reachable from a lens above every declaration, not just from + // the Tools menu, so adding a tab per invocation means a handful of + // clicks in one file leaves a row of identical tabs, each holding its + // own JCEF browser. The same graph of the same file is one tab. + // + // Matched on the file's URL rather than the tab label, which is only + // the file name: two `index.ts` in different directories are different + // graphs and must not quietly replace one another. + val existing = contentManager.contents.firstOrNull { content -> + val panel = content.component as? GraphPanel + panel != null && panel.kind == kind && panel.fileUri == file.url + } + if (existing != null) { + contentManager.setSelectedContent(existing) + toolWindow.show() + (existing.component as GraphPanel).load(file.url) + return + } + + val panel = GraphPanel(project, kind) + val content = ContentFactory.getInstance().createContent(panel, label, true).apply { + isCloseable = true + setDisposer(panel) + } + Disposer.register(toolWindow.disposable, panel) + + contentManager.addContent(content) + contentManager.setSelectedContent(content) + toolWindow.show() + + panel.load(file.url) + } + + private fun currentFile(e: AnActionEvent): VirtualFile? = + e.getData(CommonDataKeys.VIRTUAL_FILE)?.takeIf { !it.isDirectory } + + private companion object { + const val TOOL_WINDOW_ID = "CodeGraph" + } +} + +class ShowDependencyGraphAction : ShowGraphAction(GraphKind.DEPENDENCIES) + +class ShowCallGraphAction : ShowGraphAction(GraphKind.CALLS) diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/diagnostics/SelfCheckActivity.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/diagnostics/SelfCheckActivity.kt new file mode 100644 index 0000000..bb67292 --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/diagnostics/SelfCheckActivity.kt @@ -0,0 +1,158 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +package ai.codegraph.jetbrains.diagnostics + +import ai.codegraph.jetbrains.graph.GraphKind +import ai.codegraph.jetbrains.graph.GraphPanel +import ai.codegraph.jetbrains.lsp.CodeGraphClient +import ai.codegraph.jetbrains.mcp.McpRegistration +import ai.codegraph.jetbrains.lsp.CodeGraphCommand +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.EDT +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.wm.ToolWindowManager +import com.intellij.ui.jcef.JBCefApp +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.project.Project +import com.intellij.openapi.startup.ProjectActivity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.concurrent.TimeUnit + +/** + * Opt-in smoke test that runs the plugin's own transport end to end and writes + * the verdict to the IDE log. + * + * A sandbox IDE otherwise needs a human to click a menu item before anything is + * exercised, which makes the one integration that matters - LSP4IJ actually + * carrying a CodeGraph `executeCommand` to a live engine - the only part never + * checked automatically. `scripts/engine_probe.py` covers the engine side of + * that contract; this covers the IDE side. + * + * Inert unless `-Dcodegraph.selfcheck=true` is set, so it costs users nothing: + * + * ./gradlew runIde -PsandboxProject=/some/project \ + * -PrunIdeSystemProperty=codegraph.selfcheck=true + */ +class SelfCheckActivity : ProjectActivity { + + override suspend fun execute(project: Project) { + if (System.getProperty(PROPERTY) != "true") return + if (ApplicationManager.getApplication().isUnitTestMode) return + + val client = CodeGraphClient.getInstance(project) + LOG.warn("$TAG starting, engine status: ${client.status()}") + client.start() + + runCheck("getParserMetrics") { + client.execute(CodeGraphCommand.GET_PARSER_METRICS) + .get(STARTUP_TIMEOUT_SECONDS, TimeUnit.SECONDS) + } + runCheck("symbolSearch") { + client.execute(CodeGraphCommand.SYMBOL_SEARCH, mapOf("query" to "helper", "limit" to 5)) + .get(COMMAND_TIMEOUT_SECONDS, TimeUnit.SECONDS) + } + + // The queries behind the visible surfaces. Checking them separately + // distinguishes "the engine has no answer" from "the UI dropped it". + runCheck("getWorkspaceSymbols") { + // No query key, exactly as the tool window sends it: an empty string + // would take the engine's modules-only branch and check nothing. + client.execute(CodeGraphCommand.GET_WORKSPACE_SYMBOLS, emptyMap()) + .get(COMMAND_TIMEOUT_SECONDS, TimeUnit.SECONDS) + } + firstSourceFileUri(project)?.let { uri -> + runCheck("getDocumentCodeLens") { + client.execute(CodeGraphCommand.GET_DOCUMENT_CODE_LENS, mapOf("uri" to uri)) + .get(COMMAND_TIMEOUT_SECONDS, TimeUnit.SECONDS) + } + } + + runCheck("memoryList") { + client.execute( + CodeGraphCommand.MEMORY_LIST, + mapOf("currentOnly" to true, "limit" to 5), + ).get(COMMAND_TIMEOUT_SECONDS, TimeUnit.SECONDS) + } + + // Writes into the open project, which is only acceptable because this + // whole activity is opt-in and runs against a sandbox project. + runCheck("mcpRegistration") { McpRegistration.register(project) } + + // JCEF availability is a property of the running JBR, not of the build, + // so it can only be answered here. + runCheck("graphPanel") { + val uri = firstSourceFileUri(project) ?: error("no source file to graph") + withContext(Dispatchers.EDT) { + val panel = GraphPanel(project, GraphKind.DEPENDENCIES) + try { + panel.load(uri) + "jcefSupported=${JBCefApp.isSupported()}" + } finally { + Disposer.dispose(panel) + } + } + } + + // Instantiating the tool window is the only way to catch a renderer or + // layout failure; a tool window that compiles can still throw the first + // time it is shown. + runCheck("toolWindow") { + withContext(Dispatchers.EDT) { + val toolWindow = ToolWindowManager.getInstance(project).getToolWindow("CodeGraph") + ?: error("CodeGraph tool window is not registered") + toolWindow.show() + "shown with ${toolWindow.contentManager.contentCount} tab(s)" + } + } + + LOG.warn("$TAG finished, engine status: ${client.status()}") + } + + /** Any indexable-looking source file, used as a concrete code-lens target. */ + private fun firstSourceFileUri(project: Project): String? { + val base = project.basePath?.let { java.nio.file.Paths.get(it) } ?: return null + return runCatching { + java.nio.file.Files.walk(base, SOURCE_SCAN_DEPTH).use { paths -> + paths.filter { java.nio.file.Files.isRegularFile(it) } + .filter { path -> SOURCE_SUFFIXES.any { path.toString().endsWith(it) } } + .findFirst() + .orElse(null) + ?.toUri() + ?.toString() + } + }.getOrNull() + } + + /** + * `runCatching` cannot wrap a suspending lambda, so the try/catch is + * explicit. Throwable rather than Exception: a check that trips an assertion + * or a linkage error should be reported, not propagated out of startup. + */ + private suspend fun runCheck(name: String, block: suspend () -> Any?) { + try { + val value = block() + LOG.warn("$TAG PASS $name -> ${value.toString().take(PREVIEW)}") + } catch (error: Throwable) { + LOG.warn("$TAG FAIL $name -> ${error.message ?: error::class.java.name}", error) + } + } + + private companion object { + val LOG = logger() + const val PROPERTY = "codegraph.selfcheck" + + /** Grep handle: one string to search the IDE log for. */ + const val TAG = "[codegraph-selfcheck]" + + /** The first command also waits for process spawn and engine init. */ + const val STARTUP_TIMEOUT_SECONDS = 120L + const val COMMAND_TIMEOUT_SECONDS = 60L + const val PREVIEW = 300 + + /** Shallow walk: enough to find a source file, cheap on a large repo. */ + const val SOURCE_SCAN_DEPTH = 4 + val SOURCE_SUFFIXES = listOf(".py", ".rs", ".go", ".ts", ".js", ".java", ".kt", ".c", ".cpp") + } +} diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/graph/GraphHtml.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/graph/GraphHtml.kt new file mode 100644 index 0000000..14d8148 --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/graph/GraphHtml.kt @@ -0,0 +1,232 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +package ai.codegraph.jetbrains.graph + +import com.google.gson.Gson +import com.intellij.ui.JBColor +import com.intellij.util.ui.UIUtil + +/** + * Builds the graph view. + * + * The page is fully self-contained: the layout is a small force simulation in + * plain JavaScript with SVG output, and nothing is fetched. A CDN script would + * be simpler to write and would fail on exactly the machines that most need + * this to work - offline, air-gapped, or behind a proxy that blocks it. + * + * Language colours match the VS Code client so the two views of the same graph + * read the same way. + */ +object GraphHtml { + + private val gson = Gson() + + private val LANGUAGE_COLORS = mapOf( + "typescript" to "#3178C6", + "javascript" to "#F7DF1E", + "python" to "#3572A5", + "rust" to "#DEA584", + "go" to "#00ADD8", + "java" to "#B07219", + "kotlin" to "#A97BFF", + "csharp" to "#178600", + "cpp" to "#F34B7D", + "c" to "#555555", + "ruby" to "#701516", + "php" to "#4F5D95", + "swift" to "#F05138", + "scala" to "#C22D40", + ) + + private const val DEFAULT_COLOR = "#888888" + + /** + * The layout is an all-pairs repulsion loop run to convergence before the + * first paint, so its cost is quadratic in the node count. A hub file in a + * large repository can return hundreds of nodes at depth 2, and the panel + * then sits frozen on the render thread with nothing to show and no way to + * cancel. Two hundred nodes is already past what anyone can read; beyond it + * the graph is a hairball whether it renders or not. + */ + private const val MAX_RENDERED_NODES = 200 + + /** + * Keep the most connected nodes and the edges between them. + * + * Degree rather than arrival order: the highly connected nodes are what the + * graph is about, and dropping them in favour of whichever leaves happened + * to come back first would leave a picture that says nothing. + */ + private fun capNodes(graph: GraphData): GraphData { + if (graph.nodes.size <= MAX_RENDERED_NODES) return graph + + val degree = graph.nodes.associate { it.id to 0 }.toMutableMap() + graph.edges.forEach { edge -> + degree.computeIfPresent(edge.from) { _, count -> count + 1 } + degree.computeIfPresent(edge.to) { _, count -> count + 1 } + } + val kept = graph.nodes + .sortedByDescending { degree[it.id] ?: 0 } + .take(MAX_RENDERED_NODES) + val keptIds = kept.mapTo(HashSet()) { it.id } + return GraphData(kept, graph.edges.filter { it.from in keptIds && it.to in keptIds }) + } + + fun render(graph: GraphData, title: String): String { + val drawn = capNodes(graph) + val omitted = graph.nodes.size - drawn.nodes.size + + val payload = gson.toJson( + mapOf( + "nodes" to drawn.nodes.map { node -> + mapOf( + "id" to node.id, + "label" to node.label, + "color" to (LANGUAGE_COLORS[node.language.lowercase()] ?: DEFAULT_COLOR), + "title" to "${node.label}\n${node.type}${if (node.language.isNotBlank()) " · ${node.language}" else ""}", + ) + }, + "edges" to drawn.edges.map { mapOf("from" to it.from, "to" to it.to) }, + ), + ) + val truncationNote = if (omitted > 0) { + "Showing the ${drawn.nodes.size} most connected of ${graph.nodes.size} nodes." + } else { + "" + } + + // The page inherits the IDE's theme rather than picking its own, so a + // graph opened in a dark IDE is not a white rectangle. + val background = hex(UIUtil.getPanelBackground()) + val foreground = hex(JBColor.foreground()) + + return """ + + + + + $title + + + + +
$truncationNote
+ + + + + """.trimIndent() + } + + /** Plain-text rendering for IDEs without JCEF. */ + fun renderText(graph: GraphData, title: String): String = buildString { + appendLine(title) + appendLine("=".repeat(title.length)) + appendLine() + if (graph.nodes.isEmpty()) { + appendLine("No relationships to show.") + return@buildString + } + val byId = graph.nodes.associateBy { it.id } + appendLine("Nodes (${graph.nodes.size})") + graph.nodes.forEach { node -> + appendLine(" ${node.label} [${node.type}${if (node.language.isNotBlank()) ", ${node.language}" else ""}]") + } + appendLine() + appendLine("Edges (${graph.edges.size})") + graph.edges.forEach { edge -> + val from = byId[edge.from]?.label ?: edge.from + val to = byId[edge.to]?.label ?: edge.to + appendLine(" $from -> $to (${edge.type})") + } + } + + private fun hex(color: java.awt.Color): String = "#%02x%02x%02x".format(color.red, color.green, color.blue) +} diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/graph/GraphPanel.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/graph/GraphPanel.kt new file mode 100644 index 0000000..1669aae --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/graph/GraphPanel.kt @@ -0,0 +1,157 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +package ai.codegraph.jetbrains.graph + +import ai.codegraph.jetbrains.lsp.CodeGraphClient +import ai.codegraph.jetbrains.lsp.CodeGraphCommand +import com.google.gson.Gson +import com.google.gson.JsonElement +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.ui.jcef.JBCefApp +import com.intellij.ui.jcef.JBCefBrowser +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import javax.swing.JPanel +import javax.swing.JTextArea + +/** Which graph the panel is showing. */ +enum class GraphKind(val command: CodeGraphCommand, val title: String) { + DEPENDENCIES(CodeGraphCommand.GET_DEPENDENCY_GRAPH, "Dependency Graph"), + CALLS(CodeGraphCommand.GET_CALL_GRAPH, "Call Graph"), +} + +/** + * Renders a graph for one file. + * + * Uses JCEF, because the graph is a force-directed layout that Swing would need + * a bespoke renderer for and a browser gets for free. JCEF is not always + * available - some JBR builds ship without it, and Remote Dev clients cannot use + * it - so an unavailable browser degrades to a readable text listing rather than + * an empty panel or a crash. + */ +class GraphPanel(private val project: Project, val kind: GraphKind) : + JPanel(BorderLayout()), + Disposable { + + /** The file this panel is showing, so a second request for it reuses the tab. */ + var fileUri: String? = null + private set + + private val gson = Gson() + private val browser: JBCefBrowser? = if (JBCefApp.isSupported()) JBCefBrowser() else null + private val fallback = JTextArea().apply { + isEditable = false + border = JBUI.Borders.empty(8) + } + private val status = JBLabel().apply { border = JBUI.Borders.empty(4, 8) } + + init { + if (browser != null) { + Disposer.register(this, browser) + add(browser.component, BorderLayout.CENTER) + } else { + LOG.info("JCEF is unavailable; the CodeGraph graph panel falls back to a text listing") + add(JBScrollPane(fallback), BorderLayout.CENTER) + } + add(status, BorderLayout.SOUTH) + } + + /** Load the graph for [fileUri]. */ + fun load(fileUri: String, depth: Int = DEFAULT_DEPTH) { + this.fileUri = fileUri + setStatus("Loading ${kind.title.lowercase()}...") + CodeGraphClient.getInstance(project) + .execute(kind.command, mapOf("uri" to fileUri, "depth" to depth)) + .whenComplete { json, error -> + if (error != null) { + setStatus("Could not load the graph: ${error.message}") + return@whenComplete + } + val graph = runCatching { GraphData.from(json, gson) }.getOrNull() + if (graph == null || graph.nodes.isEmpty()) { + setStatus("Nothing to show. Index the workspace, or pick a file with known relationships.") + render(GraphData(emptyList(), emptyList())) + return@whenComplete + } + setStatus("${graph.nodes.size} nodes, ${graph.edges.size} edges") + render(graph) + } + } + + private fun render(graph: GraphData) { + ApplicationManager.getApplication().invokeLater { + if (browser != null) { + browser.loadHTML(GraphHtml.render(graph, kind.title)) + } else { + fallback.text = GraphHtml.renderText(graph, kind.title) + fallback.caretPosition = 0 + } + } + } + + private fun setStatus(text: String) { + ApplicationManager.getApplication().invokeLater { status.text = text } + } + + override fun dispose() = Unit + + private companion object { + val LOG = logger() + const val DEFAULT_DEPTH = 2 + } +} + +/** Node and edge lists, normalised across the dependency and call graph shapes. */ +data class GraphData(val nodes: List, val edges: List) { + + companion object { + /** + * The two graph commands answer with different shapes: the dependency + * graph labels nodes with `label`/`type`, the call graph with `name`. + * Both are normalised here so the renderer only knows one shape. + */ + fun from(json: JsonElement?, gson: Gson): GraphData { + val obj = json?.takeIf { it.isJsonObject }?.asJsonObject ?: return GraphData(emptyList(), emptyList()) + + val nodes = obj.getAsJsonArray("nodes")?.mapNotNull { element -> + val node = element.takeIf { it.isJsonObject }?.asJsonObject ?: return@mapNotNull null + val id = node.get("id")?.asString ?: return@mapNotNull null + GraphNode( + id = id, + label = node.get("label")?.asString + ?: node.get("name")?.asString + ?: id, + type = node.get("type")?.asString ?: node.get("kind")?.asString ?: "unknown", + language = node.get("language")?.asString.orEmpty(), + uri = node.get("uri")?.asString.orEmpty(), + ) + }.orEmpty() + + val edges = obj.getAsJsonArray("edges")?.mapNotNull { element -> + val edge = element.takeIf { it.isJsonObject }?.asJsonObject ?: return@mapNotNull null + val from = edge.get("from")?.asString ?: return@mapNotNull null + val to = edge.get("to")?.asString ?: return@mapNotNull null + GraphEdge(from, to, edge.get("type")?.asString ?: "calls") + }.orEmpty() + + return GraphData(nodes, edges) + } + } +} + +data class GraphNode( + val id: String, + val label: String, + val type: String, + val language: String, + val uri: String, +) + +data class GraphEdge(val from: String, val to: String, val type: String) diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/indexing/IndexingService.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/indexing/IndexingService.kt new file mode 100644 index 0000000..7534edf --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/indexing/IndexingService.kt @@ -0,0 +1,160 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +package ai.codegraph.jetbrains.indexing + +import ai.codegraph.jetbrains.lsp.CodeGraphClient +import ai.codegraph.jetbrains.lsp.CodeGraphCommand +import ai.codegraph.jetbrains.notify.CodeGraphNotifications +import ai.codegraph.jetbrains.telemetry.TelemetryReporter +import ai.codegraph.jetbrains.vision.DocumentStatsCache +import com.google.gson.JsonElement +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.Project +import kotlinx.coroutines.future.await +import java.util.concurrent.TimeUnit + +/** + * Indexing state and the reindex operation. + * + * Everything here is a thin wrapper over engine commands; the value it adds is + * knowing what an empty result actually means, which is the difference between + * "nothing indexed yet" and "indexed, nothing matched". + */ +@Service(Service.Level.PROJECT) +class IndexingService(private val project: Project) { + + /** + * Whether the engine already holds a graph for this workspace. + * + * Asks for a single symbol rather than a count because that is the cheapest + * question the command surface can answer. Note the caller must not run this + * the instant the engine starts: the engine loads its persisted graph and + * rebuilds search indexes after the LSP handshake, so an immediate query can + * report an empty index while tens of thousands of nodes are still loading, + * and the user gets told to index a workspace that is already indexed. + */ + suspend fun isIndexed(timeoutSeconds: Long = QUERY_TIMEOUT_SECONDS): Boolean = + runCatching { + // Suspends rather than blocking: on a cold first index this waits + // the full timeout, and blocking here parks a dispatcher thread for + // the whole of it. The timeout stays on the future rather than + // becoming coroutine cancellation, so it arrives as an ordinary + // failure this `runCatching` can report. + val response = CodeGraphClient.getInstance(project) + .execute(CodeGraphCommand.SYMBOL_SEARCH, mapOf("query" to "*", "limit" to 1)) + .orTimeout(timeoutSeconds, TimeUnit.SECONDS) + .await() + resultCount(response) > 0 + }.getOrElse { error -> + LOG.info("Could not determine CodeGraph index state: ${error.message}") + false + } + + /** + * Reindex the workspace behind a cancellable progress bar, reporting the + * outcome once it finishes. + */ + fun reindexInBackground() { + ProgressManager.getInstance().run( + object : Task.Backgroundable(project, "Indexing workspace with CodeGraph", true) { + override fun run(indicator: ProgressIndicator) { + indicator.isIndeterminate = true + val startedAt = System.currentTimeMillis() + val outcome = runCatching { + CodeGraphClient.getInstance(project) + .execute(CodeGraphCommand.REINDEX_WORKSPACE, emptyMap()) + .get(REINDEX_TIMEOUT_MINUTES, TimeUnit.MINUTES) + } + val elapsed = System.currentTimeMillis() - startedAt + outcome.fold( + onSuccess = { response -> + val count = filesIndexed(response) + runCatching { + TelemetryReporter.getInstance(project) + .indexCompleted("ok", elapsed, count) + } + reportSuccess(count) + }, + onFailure = { error -> + LOG.warn("CodeGraph reindex failed", error) + runCatching { + TelemetryReporter.getInstance(project) + .indexCompleted("error", elapsed, 0) + } + CodeGraphNotifications.error( + project, + "Indexing failed: ${error.message ?: error::class.java.simpleName}", + ) + }, + ) + } + }, + ) + } + + /** + * A successful reindex that found nothing is a failure from the user's point + * of view, and the usual cause is an exclude pattern or an index-paths entry + * that matches everything. Saying so beats reporting "Indexed 0 files". + */ + private fun reportSuccess(fileCount: Int) { + // Code Vision entries are keyed by document modification stamp, so a + // reindex alone never expires them: every already-open file would keep + // showing its pre-index caller, test and complexity counts until the + // user typed in it. This is the JetBrains half of what + // `refreshCodeLenses` does for the VS Code client. + runCatching { DocumentStatsCache.getInstance(project).invalidateAll() } + .onFailure { LOG.warn("Could not refresh CodeGraph code vision after indexing", it) } + + if (fileCount > 0) { + CodeGraphNotifications.info(project, "Indexed $fileCount ${"file".pluralize(fileCount)}") + } else { + CodeGraphNotifications.warn( + project, + "Indexing finished without reading any files. Check the exclude patterns and " + + "index paths in Settings | Tools | CodeGraph.", + ) + } + } + + /** Number of results in a symbol-search response. */ + private fun resultCount(response: JsonElement?): Int = + response?.takeIf { it.isJsonObject } + ?.asJsonObject?.get("results") + ?.takeIf { it.isJsonArray } + ?.asJsonArray?.size() + ?: 0 + + private fun String.pluralize(count: Int): String = if (count == 1) this else this + "s" + + companion object { + private val LOG = logger() + + private const val QUERY_TIMEOUT_SECONDS = 30L + private const val REINDEX_TIMEOUT_MINUTES = 60L + + fun getInstance(project: Project): IndexingService = project.service() + + /** + * Files read during an index run. + * + * The engine answers `codegraph.reindexWorkspace` with snake_case keys + * (`files_indexed`, `files_parsed`, `by_language`, ...), unlike its + * camelCase query responses. Getting this wrong does not fail loudly - + * it reports zero files and sends the user to the "indexing found + * nothing" path with a healthy index. + */ + fun filesIndexed(response: JsonElement?): Int = + response?.takeIf { it.isJsonObject } + ?.asJsonObject?.get("files_indexed") + ?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isNumber } + ?.asInt + ?: 0 + } +} diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/indexing/IndexingStartupActivity.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/indexing/IndexingStartupActivity.kt new file mode 100644 index 0000000..81e6f57 --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/indexing/IndexingStartupActivity.kt @@ -0,0 +1,134 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +package ai.codegraph.jetbrains.indexing + +import ai.codegraph.jetbrains.lsp.CodeGraphClient +import ai.codegraph.jetbrains.notify.CodeGraphNotifications +import ai.codegraph.jetbrains.server.CodeGraphServerResolver +import ai.codegraph.jetbrains.server.EngineInstaller +import ai.codegraph.jetbrains.server.ResolvedServer +import ai.codegraph.jetbrains.settings.CodeGraphSettings +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.project.Project +import com.intellij.openapi.startup.ProjectActivity +import kotlinx.coroutines.delay +import kotlinx.coroutines.future.await + +/** + * Starts the engine when a project opens and, if the workspace has never been + * indexed, offers to index it. + * + * Without an index every CodeGraph surface is empty, and an empty surface reads + * as a broken plugin rather than as a missing first step. + */ +class IndexingStartupActivity : ProjectActivity { + + override suspend fun execute(project: Project) { + val application = ApplicationManager.getApplication() + // Headless runs - the plugin verifier, searchable-options generation, + // any CI inspection - open a project with no user and no UI. Starting a + // native engine there costs a process and a full index for nobody, and + // it is what made searchable-options generation hang. + if (application.isUnitTestMode || application.isHeadlessEnvironment) return + + val settings = CodeGraphSettings.getInstance(project).state + if (!settings.enabled) return + + val resolved = CodeGraphServerResolver.resolve(project.basePath, settings.serverPath) + if (resolved == null) { + // Offered rather than done automatically: this is a ~30 MB download + // of a native binary that will run with the user's permissions, and + // starting that unasked on project open is not a decision the + // plugin should make for them. + CodeGraphNotifications.infoWithActions( + project, + "The CodeGraph engine is not installed, so there is no graph to answer questions from. " + + "It can be downloaded for this platform, or installed separately with " + + "npm i -g @astudioplus/codegraph-mcp.", + "Download Engine" to { notification -> + notification.expire() + EngineInstaller.downloadInBackground(project) + }, + ) + return + } + + offerEngineUpdateIfStale(project, resolved) + + val client = CodeGraphClient.getInstance(project) + client.start() + + // Wait for the engine to finish `initialize` before starting the clock. + // `start()` only requests a launch - the process is spawned lazily - so + // sleeping straight after it times the grace period against the wrong + // event and provides no grace at all. + runCatching { client.awaitReady().await() } + .onFailure { error -> + LOG.info("CodeGraph engine did not become ready: ${error.message}") + return + } + + // Even once initialized, the engine loads its persisted graph and + // rebuilds search indexes in the background. Asking too early reports an + // empty index for a workspace that is already indexed, and sends the + // user to redo work that is already done. + delay(GRAPH_LOAD_GRACE_MILLIS) + + val indexing = IndexingService.getInstance(project) + val indexed = indexing.isIndexed() + // The single most common support question is "why is CodeGraph empty", + // and the answer is almost always this decision. Record it. + LOG.info("CodeGraph workspace index present: $indexed") + if (indexed) return + + CodeGraphNotifications.infoWithActions( + project, + "This workspace has not been indexed yet, so CodeGraph has no graph to answer questions from.", + "Index Now" to { notification -> + notification.expire() + indexing.reindexInBackground() + }, + ) + } + + /** + * A managed engine is found by file name, which says nothing about which + * build it is. The plugin ships in lockstep with the engine it was built + * against, so one installed by an earlier plugin would otherwise be reused + * for good, and this build would keep talking to it. + * + * Offered rather than forced: the engine on disk still runs, and an update + * that cannot reach the release must not cost the user a working install. + * Only managed installs are ours to replace - a Pro, PATH or locally built + * engine is the user's to manage - and only ones that are actually older, + * since the VS Code extension installs into the same directory and may + * legitimately be ahead of this plugin. + */ + private fun offerEngineUpdateIfStale(project: Project, resolved: ResolvedServer) { + if (resolved.origin != ResolvedServer.Origin.MANAGED_INSTALL) return + val expected = CodeGraphServerResolver.ENGINE_VERSION + val installed = CodeGraphServerResolver.managedEngineVersion() + if (!CodeGraphServerResolver.isManagedEngineStale(installed, expected)) return + + LOG.info("Managed CodeGraph engine reports version ${installed ?: "unknown"}, expected $expected") + CodeGraphNotifications.infoWithActions( + project, + "The installed CodeGraph engine (${installed ?: "unknown version"}) predates the one " + + "this plugin ships against ($expected). They ship together, so features this " + + "build expects may be missing.", + "Update Engine" to { notification -> + notification.expire() + EngineInstaller.downloadInBackground(project) + }, + ) + } + + private companion object { + val LOG = logger() + + /** Matches the VS Code client's post-handshake wait before probing the index. */ + const val GRAPH_LOAD_GRACE_MILLIS = 2_000L + } +} diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/lsp/CodeGraphClient.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/lsp/CodeGraphClient.kt new file mode 100644 index 0000000..c1a707f --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/lsp/CodeGraphClient.kt @@ -0,0 +1,187 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +package ai.codegraph.jetbrains.lsp + +import ai.codegraph.jetbrains.server.CODEGRAPH_SERVER_ID +import ai.codegraph.jetbrains.server.EngineLifecycle +import com.google.gson.Gson +import com.google.gson.JsonElement +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.project.Project +import com.redhat.devtools.lsp4ij.LanguageServerManager +import com.redhat.devtools.lsp4ij.ServerStatus +import org.eclipse.lsp4j.ExecuteCommandParams +import java.util.concurrent.CompletableFuture + +/** + * The single door to the CodeGraph engine. + * + * Every capability the engine exposes to an editor arrives as a + * `workspace/executeCommand` call; see [CodeGraphCommand] for the catalogue. + * Keeping that in one place means the UI layers never touch LSP4IJ directly and + * the command surface stays greppable. + */ +@Service(Service.Level.PROJECT) +class CodeGraphClient(private val project: Project) { + + private val gson = Gson() + + /** + * Current engine status, for the status bar and for guard checks. + * A server that has never been referenced reports no status at all, which + * is the same situation as [ServerStatus.none]. + */ + fun status(): ServerStatus = + LanguageServerManager.getInstance(project).getServerStatus(CODEGRAPH_SERVER_ID) ?: ServerStatus.none + + /** + * Start the engine if it is not already running. + * + * The engine is not tied to any one file type - its value is workspace-wide + * - so it is started explicitly rather than waiting for LSP4IJ's file + * mappings to trigger a lazy start. + * + * Does nothing once the restart breaker has opened: that state means the + * engine has already proved it cannot stay up on this machine, and the user + * has been told. Restarting anyway is what produces crash loops. + */ + fun start() { + if (EngineLifecycle.getInstance(project).isRestartBlocked) { + LOG.info("Not starting the CodeGraph engine: restarts are blocked after repeated crashes") + return + } + LanguageServerManager.getInstance(project).start(CODEGRAPH_SERVER_ID) + } + + /** True while the engine is up, or on its way up. */ + fun isRunning(): Boolean = status() in RUNNING_STATUSES + + /** + * Stop the engine and wait until its process is gone, up to [timeoutMillis]. + * Returns whether it went away in time. + * + * Replacing the engine binary needs the process gone first: on Windows a + * running `.exe` cannot be overwritten at all, and on every platform a + * binary swapped under a live process is simply not the engine that is + * running. Stopping is asynchronous, and `stopping` is not `stopped`, so + * asking is not enough - the caller has to know when it finished. + * + * Blocks, so callers must be on a background thread. [start] afterwards + * brings the engine back: LSP4IJ's default stop disables the server and its + * default start re-enables it. + */ + fun stopAndAwait(timeoutMillis: Long = STOP_TIMEOUT_MILLIS): Boolean { + LanguageServerManager.getInstance(project).stop(CODEGRAPH_SERVER_ID) + val deadline = System.currentTimeMillis() + timeoutMillis + while (!isStopped() && System.currentTimeMillis() < deadline) { + try { + Thread.sleep(STOP_POLL_MILLIS) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + return isStopped() + } + } + return isStopped() + } + + /** True once the engine process is definitely gone, rather than on its way out. */ + private fun isStopped(): Boolean = status() in STOPPED_STATUSES + + /** + * A future that completes once the engine has finished `initialize`. + * + * [start] only asks LSP4IJ to bring the engine up; the process is not + * spawned synchronously. Anything that needs to time itself against a live + * engine - rather than against the moment we asked for one - must wait on + * this instead. + */ + fun awaitReady(): CompletableFuture = + LanguageServerManager.getInstance(project) + .getLanguageServer(CODEGRAPH_SERVER_ID) + .thenCompose { server -> + server?.initializedServer?.thenApply { } ?: CompletableFuture.completedFuture(Unit) + } + + /** + * Send a `workspace/executeCommand` and return the raw JSON result. + * + * The future completes exceptionally if the engine cannot be started; the + * caller decides whether that is worth surfacing to the user. + */ + fun execute(command: CodeGraphCommand, arguments: Any? = null): CompletableFuture { + val params = ExecuteCommandParams( + command.id, + if (arguments == null) emptyList() else listOf(arguments), + ) + return withServer(command) { server -> server.workspaceService.executeCommand(params) } + .thenApply { raw -> raw?.let { toJson(it) } } + .whenComplete { _, error -> + if (error != null) LOG.warn("CodeGraph command ${command.id} failed", error) + } + } + + /** + * Resolve the engine and run [action] against it. + * + * `start()` is asynchronous, so a command issued straight after it can + * arrive before LSP4IJ has a server to hand and get back null. That is + * "not up yet", not "cannot run" - failing fast on it made the first + * command of a session fail spuriously, which is exactly the command a + * user is most likely to notice (the reindex they just asked for). So a + * null resolves once more after asking for a start. + */ + private fun withServer( + command: CodeGraphCommand, + action: (com.redhat.devtools.lsp4ij.LanguageServerItem) -> CompletableFuture, + ): CompletableFuture { + val manager = LanguageServerManager.getInstance(project) + return manager.getLanguageServer(CODEGRAPH_SERVER_ID) + .thenCompose { server -> + if (server != null) { + action(server) + } else { + start() + manager.getLanguageServer(CODEGRAPH_SERVER_ID).thenCompose { retried -> + if (retried != null) { + action(retried) + } else { + CompletableFuture.failedFuture(EngineUnavailableException(command)) + } + } + } + } + } + + /** Convenience wrapper that deserialises the result into [T]. */ + fun execute(command: CodeGraphCommand, arguments: Any?, type: Class): CompletableFuture = + execute(command, arguments).thenApply { json -> json?.let { gson.fromJson(it, type) } } + + /** + * LSP4J hands back whatever Gson produced for an untyped result, which is + * already a [JsonElement] in practice. Re-serialising anything else keeps + * callers from having to care. + */ + private fun toJson(raw: Any): JsonElement = + raw as? JsonElement ?: gson.toJsonTree(raw) + + class EngineUnavailableException(command: CodeGraphCommand) : + RuntimeException("CodeGraph engine is not running; cannot execute ${command.id}") + + companion object { + private val LOG = logger() + + private val RUNNING_STATUSES = setOf(ServerStatus.started, ServerStatus.starting) + + /** `stopping` is deliberately absent: the process is not gone yet. */ + private val STOPPED_STATUSES = setOf(ServerStatus.none, ServerStatus.stopped) + + /** Long enough for an orderly shutdown, short enough not to look hung. */ + private const val STOP_TIMEOUT_MILLIS = 10_000L + private const val STOP_POLL_MILLIS = 100L + + fun getInstance(project: Project): CodeGraphClient = project.service() + } +} diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/lsp/CodeGraphCommand.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/lsp/CodeGraphCommand.kt new file mode 100644 index 0000000..eec9072 --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/lsp/CodeGraphCommand.kt @@ -0,0 +1,71 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +package ai.codegraph.jetbrains.lsp + +/** + * The engine's `workspace/executeCommand` surface. + * + * Authority is `CodeGraphBackend::execute_command` in + * `crates/codegraph-server/src/backend.rs`; this enum is a transcription of the + * dispatch arms there. Transcription means drift, so it is on the roadmap to + * generate both this file and the VS Code client's equivalent from a + * `--dump-capabilities` output of the engine itself. + * + * The engine accepts an alternative command prefix and remaps it internally, so + * ids are always spelled `codegraph.*` here. + */ +enum class CodeGraphCommand(val id: String) { + // Graph structure + GET_DEPENDENCY_GRAPH("codegraph.getDependencyGraph"), + GET_CALL_GRAPH("codegraph.getCallGraph"), + TRAVERSE_GRAPH("codegraph.traverseGraph"), + GET_CALLERS("codegraph.getCallers"), + GET_CALLEES("codegraph.getCallees"), + ANALYZE_IMPACT("codegraph.analyzeImpact"), + FIND_IMPLEMENTORS("codegraph.findImplementors"), + FIND_ENTRY_POINTS("codegraph.findEntryPoints"), + + // Symbols and search + SYMBOL_SEARCH("codegraph.symbolSearch"), + GET_WORKSPACE_SYMBOLS("codegraph.getWorkspaceSymbols"), + GET_DETAILED_SYMBOL_INFO("codegraph.getDetailedSymbolInfo"), + GET_NODE_LOCATION("codegraph.getNodeLocation"), + FIND_BY_IMPORTS("codegraph.findByImports"), + FIND_BY_SIGNATURE("codegraph.findBySignature"), + FIND_RELATED_TESTS("codegraph.findRelatedTests"), + ANALYZE_COMPLEXITY("codegraph.analyzeComplexity"), + + // Editor surfaces + GET_DOCUMENT_CODE_LENS("codegraph.getDocumentCodeLens"), + + // AI context + GET_AI_CONTEXT("codegraph.getAIContext"), + GET_EDIT_CONTEXT("codegraph.getEditContext"), + GET_CURATED_CONTEXT("codegraph.getCuratedContext"), + + // Memory + MEMORY_STORE("codegraph.memoryStore"), + MEMORY_SEARCH("codegraph.memorySearch"), + MEMORY_GET("codegraph.memoryGet"), + MEMORY_UPDATE("codegraph.memoryUpdate"), + MEMORY_INVALIDATE("codegraph.memoryInvalidate"), + MEMORY_LIST("codegraph.memoryList"), + MEMORY_CONTEXT("codegraph.memoryContext"), + MEMORY_STATS("codegraph.memoryStats"), + + // Git mining + MINE_GIT_HISTORY("codegraph.mineGitHistory"), + MINE_GIT_HISTORY_FOR_FILE("codegraph.mineGitHistoryForFile"), + SEARCH_GIT_HISTORY("codegraph.searchGitHistory"), + + // Indexing and lifecycle + REINDEX_WORKSPACE("codegraph.reindexWorkspace"), + INDEX_FILES("codegraph.indexFiles"), + INDEX_DIRECTORY("codegraph.indexDirectory"), + UPDATE_CONFIGURATION("codegraph.updateConfiguration"), + GET_PARSER_METRICS("codegraph.getParserMetrics"), + ; + + override fun toString(): String = id +} diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/mcp/McpRegistration.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/mcp/McpRegistration.kt new file mode 100644 index 0000000..7e5d7b9 --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/mcp/McpRegistration.kt @@ -0,0 +1,155 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +package ai.codegraph.jetbrains.mcp + +import ai.codegraph.jetbrains.server.CodeGraphServerResolver +import ai.codegraph.jetbrains.settings.CodeGraphSettings +import com.google.gson.GsonBuilder +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import com.intellij.openapi.project.Project +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.StandardCopyOption + +/** + * Registers the CodeGraph engine as an MCP server for the IDE's AI tooling. + * + * The VS Code client exposes 28 `languageModelTools`, which are Copilot-specific + * and have no JetBrains equivalent. Rather than reimplement that surface, this + * points the AI tooling at the engine's own MCP mode - so the tool list stays + * correct as the engine gains tools, instead of drifting in a second hand-written + * declaration. + * + * The target is `/.mcp.json`, the `mcpServers` shape that Junie, Claude + * Code, Cursor and the AI Assistant MCP settings all read. + */ +object McpRegistration { + + const val SERVER_NAME = "codegraph" + const val CONFIG_FILE = ".mcp.json" + + private val gson = GsonBuilder().setPrettyPrinting().create() + + /** Suffix for the copy taken before a file we could not parse is replaced. */ + const val BACKUP_SUFFIX = ".codegraph-backup" + + sealed interface Result { + data class Written(val path: Path, val merged: Boolean, val backup: Path? = null) : Result + data class NoEngine(val reason: String) : Result + data class Failed(val reason: String) : Result + } + + /** The config that would be written, for previewing or copying. */ + fun configSnippet(project: Project): String? = + serverEntry(project)?.let { entry -> + gson.toJson(JsonObject().apply { add("mcpServers", JsonObject().apply { add(SERVER_NAME, entry) }) }) + } + + /** + * Write or update the `codegraph` entry in the project's `.mcp.json`. + * + * Existing entries are preserved: a project may already point at other MCP + * servers, and clobbering someone's config to add ourselves would be a + * hostile way to install a feature. + */ + fun register(project: Project): Result { + val entry = serverEntry(project) + ?: return Result.NoEngine( + "No CodeGraph engine found. Install it, or set its path in Settings | Tools | CodeGraph.", + ) + val basePath = project.basePath + ?: return Result.Failed("This project has no directory on disk.") + + val configPath = Paths.get(basePath, CONFIG_FILE) + return try { + val parsed = readConfig(configPath) + // A file we could not parse still holds the user's other MCP + // servers. Writing over it loses every one of them, so the + // unreadable original is kept before it is replaced. + val backup = if (parsed == null) backUp(configPath) else null + val existing = parsed ?: JsonObject() + + val servers = existing.getAsJsonObject("mcpServers") + ?: JsonObject().also { existing.add("mcpServers", it) } + val merged = existing.has("mcpServers") && servers.size() > 0 && !servers.has(SERVER_NAME) + + servers.add(SERVER_NAME, entry) + Files.writeString(configPath, gson.toJson(existing) + "\n") + Result.Written(configPath, merged, backup) + } catch (error: Exception) { + // The message alone is often just the path, which reads as though + // nothing went wrong; the exception type carries the actual reason. + Result.Failed("${error::class.java.simpleName}: ${error.message.orEmpty()}".trim(':', ' ')) + } + } + + /** True when the project already points at this engine. */ + fun isRegistered(project: Project): Boolean { + val basePath = project.basePath ?: return false + return runCatching { + readConfig(Paths.get(basePath, CONFIG_FILE)) + ?.getAsJsonObject("mcpServers") + ?.has(SERVER_NAME) == true + }.getOrDefault(false) + } + + /** + * The existing config, an empty object when there is no file yet, or null + * when there is a file we cannot parse. + * + * The three are deliberately distinct. Refusing to write because the + * existing JSON is broken would leave the user stuck with no way forward + * from inside the IDE, but treating "broken" as "absent" silently discards + * every other MCP server they had configured - a trailing comma is enough. + * Telling them apart lets the caller keep a copy before it replaces one. + */ + private fun readConfig(path: Path): JsonObject? { + if (!Files.exists(path)) return JsonObject() + return runCatching { + JsonParser.parseString(Files.readString(path)).asJsonObject + }.getOrNull() + } + + /** + * Copy the unparseable config aside, returning where it went. + * + * A failure here is not fatal to the registration, but it does mean there + * is no copy: null says so rather than implying one exists. + */ + private fun backUp(path: Path): Path? = + runCatching { + val backup = path.resolveSibling(path.fileName.toString() + BACKUP_SUFFIX) + Files.copy(path, backup, StandardCopyOption.REPLACE_EXISTING) + backup + }.getOrNull() + + private fun serverEntry(project: Project): JsonObject? { + val settings = CodeGraphSettings.getInstance(project).state + val server = CodeGraphServerResolver.resolve(project.basePath, settings.serverPath) ?: return null + + return JsonObject().apply { + addProperty("command", server.path.toString()) + add( + "args", + gson.toJsonTree( + buildList { + add("--mcp") + project.basePath?.let { + add("--workspace") + add(it) + } + // Pass the model through so an agent session embeds the + // same way the editor does; otherwise the two disagree + // about what "similar" means. + add("--embedding-model") + add(settings.embeddingModel) + if (settings.fullBodyEmbedding) add("--full-body-embedding") + }, + ), + ) + } + } +} diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/notify/CodeGraphNotifications.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/notify/CodeGraphNotifications.kt new file mode 100644 index 0000000..4e79a29 --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/notify/CodeGraphNotifications.kt @@ -0,0 +1,71 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +package ai.codegraph.jetbrains.notify + +import com.intellij.notification.Notification +import com.intellij.notification.NotificationAction +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType +import com.intellij.openapi.project.Project + +/** + * User-facing notifications. + * + * Every helper here is fire-and-forget by construction: nothing returns a + * future and nothing waits on a button. Agent-driven code paths hit the same + * functions as interactive ones, and a notification that blocks on user input + * turns a tool call into a hang. + */ +object CodeGraphNotifications { + private const val GROUP_ID = "CodeGraph" + + fun info(project: Project, message: String) = notify(project, message, NotificationType.INFORMATION) + + fun warn(project: Project, message: String) = notify(project, message, NotificationType.WARNING) + + fun error(project: Project, message: String) = notify(project, message, NotificationType.ERROR) + + fun infoWithActions( + project: Project, + message: String, + vararg actions: Pair Unit>, + ) = withActions(project, message, NotificationType.INFORMATION, *actions) + + fun errorWithActions( + project: Project, + message: String, + vararg actions: Pair Unit>, + ) = withActions(project, message, NotificationType.ERROR, *actions) + + /** + * A notification carrying buttons. + * + * Still fire-and-forget: this returns as soon as the balloon is posted, and + * each action runs later on its own. Callers must not treat an action as a + * reply they can wait for. + */ + private fun withActions( + project: Project, + message: String, + type: NotificationType, + vararg actions: Pair Unit>, + ) { + val notification = NotificationGroupManager.getInstance() + .getNotificationGroup(GROUP_ID) + .createNotification("CodeGraph", message, type) + actions.forEach { (label, handler) -> + notification.addAction( + NotificationAction.create(label) { _, shown -> handler(shown) }, + ) + } + notification.notify(project) + } + + private fun notify(project: Project, message: String, type: NotificationType) { + NotificationGroupManager.getInstance() + .getNotificationGroup(GROUP_ID) + .createNotification("CodeGraph", message, type) + .notify(project) + } +} diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphConnectionProvider.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphConnectionProvider.kt new file mode 100644 index 0000000..082ea97 --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphConnectionProvider.kt @@ -0,0 +1,107 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +package ai.codegraph.jetbrains.server + +import ai.codegraph.jetbrains.settings.CodeGraphSettings +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.redhat.devtools.lsp4ij.server.CannotStartProcessException +import com.redhat.devtools.lsp4ij.server.OSProcessStreamConnectionProvider +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths + +/** + * Spawns and configures the `codegraph-server` engine process. + * + * The engine speaks LSP over stdio; LSP4IJ owns the JSON-RPC framing and + * document synchronisation, so all this type does is build the command line and + * hand over the `initialize` options. + */ +class CodeGraphConnectionProvider(private val project: Project) : OSProcessStreamConnectionProvider() { + + /** Set on a successful resolve so the status bar and telemetry can read it. */ + @Volatile + var resolved: ResolvedServer? = null + private set + + override fun start() { + val settings = CodeGraphSettings.getInstance(project).state + val server = CodeGraphServerResolver.resolve(project.basePath, settings.serverPath) + ?: throw CannotStartProcessException( + "CodeGraph engine not found. Install it with `npm i -g @astudioplus/codegraph-mcp`, " + + "or set the engine path in Settings | Tools | CodeGraph.", + ) + resolved = server + + val commandLine = GeneralCommandLine(server.path.toString()).apply { + // No shell, so a path containing spaces is passed as a single argv + // entry. This is the class of bug that broke the VS Code client on + // Windows (issue #2); do not reintroduce a shell here. + withWorkDirectory(project.basePath) + withCharset(Charsets.UTF_8) + // Set only when the user names a directory. Unset, the engine + // resolves ~/.codegraph/static_models/jina-code-static-256 itself, + // which is where the npm postinstall puts it and is shared with + // every other client - so re-deriving that path here would just be + // a second copy of the same rule, free to drift. + settings.staticModelPath + .takeIf { it.isNotBlank() && settings.embeddingModel == "static" } + ?.let { withEnvironment("CODEGRAPH_STATIC_MODEL", it) } + } + setCommandLine(commandLine) + + val lifecycle = EngineLifecycle.getInstance(project) + lifecycle.publishResolvedServer(server) + // Registered before the process exists so a death during startup - the + // most common failure on a machine with antivirus or a missing runtime + // library - is still counted rather than silently retried. + addUnexpectedServerStopHandler { lifecycle.onUnexpectedStop() } + + LOG.info("Starting CodeGraph engine: ${server.path} (${server.edition}, via ${server.origin})") + super.start() + lifecycle.onEngineStarted() + } + + /** + * `initialize` options, matching the shape the engine parses in + * `backend.rs::initialize`. + * + * `extensionPath` is a VS Code-era name for "the directory the client owns + * for its resources", and it is optional: the engine reads the embedding + * settings whether or not it is sent, and falls back to + * `~/.codegraph/fastembed_cache` for the model cache. We pass a stable + * per-client directory so this plugin's downloads stay its own. + */ + override fun getInitializationOptions(rootUri: VirtualFile?): Any { + val settings = CodeGraphSettings.getInstance(project).state + return mapOf( + "extensionPath" to clientResourceDir().toString(), + "indexOnStartup" to settings.indexOnStartup, + "excludePatterns" to settings.excludePatterns.toList(), + "indexPaths" to settings.indexPaths.toList(), + "maxFileSizeKB" to settings.maxFileSizeKB, + "embeddingModel" to settings.embeddingModel, + "staticModelPath" to settings.staticModelPath.ifBlank { null }, + "fullBodyEmbedding" to settings.fullBodyEmbedding, + "embedOnOpen" to settings.embedOnOpen, + ) + } + + override fun getTrace(rootUri: VirtualFile?): String = + if (CodeGraphSettings.getInstance(project).state.debug) "verbose" else "off" + + private fun clientResourceDir(): Path { + val dir = Paths.get(System.getProperty("user.home"), ".codegraph", "jetbrains") + runCatching { Files.createDirectories(dir) } + .onFailure { LOG.warn("Could not create client resource dir $dir", it) } + return dir + } + + private companion object { + val LOG = logger() + } +} diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphLanguageServerFactory.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphLanguageServerFactory.kt new file mode 100644 index 0000000..f12f2c0 --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphLanguageServerFactory.kt @@ -0,0 +1,49 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +package ai.codegraph.jetbrains.server + +import ai.codegraph.jetbrains.settings.CodeGraphSettings +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiFile +import com.redhat.devtools.lsp4ij.LanguageServerFactory +import com.redhat.devtools.lsp4ij.client.LanguageClientImpl +import com.redhat.devtools.lsp4ij.client.features.LSPClientFeatures +import com.redhat.devtools.lsp4ij.client.features.LSPHoverFeature +import com.redhat.devtools.lsp4ij.server.StreamConnectionProvider + +/** Server id shared by `plugin.xml` and every call site that talks to the engine. */ +const val CODEGRAPH_SERVER_ID: String = "codegraph" + +/** Wires the CodeGraph engine into LSP4IJ. */ +class CodeGraphLanguageServerFactory : LanguageServerFactory { + + override fun createConnectionProvider(project: Project): StreamConnectionProvider = + CodeGraphConnectionProvider(project) + + override fun createLanguageClient(project: Project): LanguageClientImpl = + CodeGraphLanguageClient(project) + + /** + * The engine advertises `hoverProvider`, so LSP4IJ shows graph information + * on hover by default. Binding that to the setting is what makes the + * "Show graph information on hover" checkbox mean anything - without it the + * hover is on regardless of what the user chose. + */ + override fun createClientFeatures(): LSPClientFeatures = + LSPClientFeatures().setHoverFeature(CodeGraphHoverFeature()) +} + +private class CodeGraphHoverFeature : LSPHoverFeature() { + override fun isEnabled(file: PsiFile): Boolean = + CodeGraphSettings.getInstance(file.project).state.hoverEnabled && super.isEnabled(file) +} + +/** + * Client-side LSP endpoint. + * + * Kept deliberately thin for now. Phase 2 overrides [refreshCodeLenses] here so + * the engine can invalidate Code Vision after a reindex, mirroring + * `codeLensRefresh.ts` in the VS Code client. + */ +class CodeGraphLanguageClient(project: Project) : LanguageClientImpl(project) diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphServerResolver.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphServerResolver.kt new file mode 100644 index 0000000..29249f6 --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphServerResolver.kt @@ -0,0 +1,308 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +package ai.codegraph.jetbrains.server + +import com.intellij.openapi.diagnostic.logger +import java.io.File +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import kotlin.io.path.isExecutable +import kotlin.io.path.isRegularFile + +/** + * Which build of the engine we resolved. Mirrors `ServerInfo.edition` in the + * VS Code client so both report the same value to telemetry and the status bar. + */ +enum class ServerEdition { PRO, COMMUNITY } + +/** A resolved engine binary plus how we found it. */ +data class ResolvedServer( + val path: Path, + val edition: ServerEdition, + /** Where the binary came from, for diagnostics and telemetry. */ + val origin: Origin, +) { + enum class Origin { PRO_PATH, PRO_INSTALL_DIR, SYSTEM_PATH, MANAGED_INSTALL, CARGO_BUILD, USER_OVERRIDE } +} + +/** + * Everything about the machine that resolution depends on. + * + * Resolution reads the home directory, `PATH` and the OS/architecture, so + * without this seam its tests would pass or fail according to whatever the + * developer happens to have installed - which is exactly how the first version + * of these tests broke. + */ +data class ResolverEnvironment( + val homeDir: Path, + val pathEntries: List, + val osName: String, + val osArch: String, +) { + val isWindows: Boolean get() = osName.lowercase().contains("win") + + companion object { + fun fromSystem(): ResolverEnvironment = ResolverEnvironment( + homeDir = Paths.get(System.getProperty("user.home").orEmpty()), + pathEntries = System.getenv("PATH").orEmpty() + .split(File.pathSeparatorChar) + .filter { it.isNotBlank() } + .map { Paths.get(it) }, + osName = System.getProperty("os.name").orEmpty(), + osArch = System.getProperty("os.arch").orEmpty(), + ) + } +} + +/** + * Locates the `codegraph-server` engine binary. + * + * Resolution order mirrors `vscode/src/server.ts`, with one deliberate + * difference: the JetBrains plugin does not bundle platform binaries. The VSIX + * carries four of them (100-126 MB each) because VS Code can ship per-platform + * artifacts; the JetBrains Marketplace cannot, so a bundled plugin would be a + * ~120 MB download for every user regardless of platform. Instead the binary is + * resolved from an existing install and, failing that, downloaded once into the + * managed install directory (Phase 1). + * + * Order: + * 1. Explicit user override (settings) + * 2. CodeGraph Pro on PATH, then its known install directories + * 3. `codegraph-server` on PATH (npm / homebrew installs) + * 4. Previously downloaded binary under `~/.codegraph/bin` + * 5. Cargo build outputs, for developing CodeGraph itself + */ +object CodeGraphServerResolver { + private val LOG = logger() + + class UnsupportedPlatformException(os: String, arch: String) : + RuntimeException("CodeGraph does not ship an engine for $os/$arch") + + /** + * Binary name for this platform, or null when no engine is published for it. + * + * Only macOS is built for both architectures. Windows on ARM runs the x64 + * build under the OS's own emulation layer, so it is served the x64 asset; + * Linux has no such layer, and falling back to x64 there installs ~30 MB + * that cannot execute, which surfaces as an exec-format error at first use + * instead of as the unsupported platform it is. + * + * Mirrors `platformBinaryName()` in `mcp-package/bin/fetch-engine.js`, which + * is the same rule for the JavaScript channels. + */ + fun platformBinaryNameOrNull(env: ResolverEnvironment = ResolverEnvironment.fromSystem()): String? { + val os = env.osName.lowercase() + val arch = env.osArch.lowercase() + val isArm64 = arch in ARM64_ARCHES + val isX64 = arch in X64_ARCHES + return when { + os.contains("mac") || os.contains("darwin") -> when { + isArm64 -> "codegraph-server-darwin-arm64" + isX64 -> "codegraph-server-darwin-x64" + else -> null + } + os.contains("win") -> if (isX64 || isArm64) "codegraph-server-win32-x64.exe" else null + os.contains("linux") -> if (isX64) "codegraph-server-linux-x64" else null + else -> null + } + } + + /** Binary name for this platform, for callers that treat "no build" as an error. */ + fun platformBinaryName(env: ResolverEnvironment = ResolverEnvironment.fromSystem()): String = + platformBinaryNameOrNull(env) ?: throw UnsupportedPlatformException(env.osName, env.osArch) + + /** Where downloaded engines live. Shared with the CLI so installs are reused. */ + fun managedInstallDir(env: ResolverEnvironment = ResolverEnvironment.fromSystem()): Path = + env.homeDir.resolve(".codegraph").resolve("bin") + + /** True when a managed install already exists, used to skip the download prompt. */ + fun hasManagedInstall(env: ResolverEnvironment = ResolverEnvironment.fromSystem()): Boolean = + platformBinaryNameOrNull(env) + ?.let { Files.isRegularFile(managedInstallDir(env).resolve(it)) } + ?: false + + /** + * Which release the managed install came from, or null when unknown. + * + * The engine is resolved by filename, which says nothing about which build + * it is. Without this marker an engine installed by an older plugin is + * indistinguishable from the one this plugin was built against, and gets + * reused for good. Written by [EngineDownloader] and by the shared + * JavaScript installer, which use the same file name. + */ + fun managedEngineVersion(env: ResolverEnvironment = ResolverEnvironment.fromSystem()): String? = + runCatching { Files.readString(managedInstallDir(env).resolve(VERSION_MARKER)).trim() } + .getOrNull() + ?.takeIf { it.isNotEmpty() } + + /** + * True when the managed engine predates [expected] and is worth replacing. + * An unmarked or unreadable version counts as stale: it predates the + * marker, so which build it is cannot be established. + * + * Deliberately not `installed != expected`. The managed directory is shared + * with the VS Code extension and the CLI, which ship through their own + * channels on their own schedules, so finding a *newer* engine there is + * normal. Treating that as a mismatch has each client reinstall its own + * version over the other's on every launch, with a notification each time + * and no way for the user to end it. + */ + fun isManagedEngineStale(installed: String?, expected: String): Boolean { + val order = compareVersions(installed ?: return true, expected) ?: return true + return order < 0 + } + + /** + * Release order of two versions, or null when either is not a plain numeric + * version - a caller cannot tell older from newer then, and guessing is + * what produces the loop [isManagedEngineStale] exists to avoid. + */ + fun compareVersions(a: String, b: String): Int? { + val left = versionParts(a) ?: return null + val right = versionParts(b) ?: return null + for (i in 0 until maxOf(left.size, right.size)) { + val difference = (left.getOrNull(i) ?: 0).compareTo(right.getOrNull(i) ?: 0) + if (difference != 0) return difference + } + return 0 + } + + /** Numeric release components, ignoring any prerelease suffix. */ + private fun versionParts(version: String): List? = + version.trim() + .substringBefore('-') + .takeIf { it.isNotEmpty() } + ?.split('.') + ?.map { it.toIntOrNull() ?: return null } + + /** + * Resolve the engine, or return null when nothing is installed yet. A null + * result is a normal first-run state, not an error: the caller offers the + * download instead of failing activation. + * + * @param projectBasePath used only to find cargo build outputs when the + * open project *is* the CodeGraph repo. + * @param override an explicit path from settings; when set and valid it wins. + */ + fun resolve( + projectBasePath: String?, + override: String? = null, + env: ResolverEnvironment = ResolverEnvironment.fromSystem(), + ): ResolvedServer? { + override?.takeIf { it.isNotBlank() }?.let { raw -> + val path = Paths.get(raw) + if (path.isExecutableFile(env)) { + return ResolvedServer(path, editionForName(path), ResolvedServer.Origin.USER_OVERRIDE) + } + // A stale path in settings must not brick the plugin: warn and keep + // looking, which is what a user who just moved the binary expects. + LOG.warn("Configured CodeGraph engine path is not an executable file: $raw") + } + + findProBinary(env)?.let { return it } + + findOnPath(if (env.isWindows) "codegraph-server.exe" else "codegraph-server", env)?.let { + return ResolvedServer(it, ServerEdition.COMMUNITY, ResolvedServer.Origin.SYSTEM_PATH) + } + + platformBinaryNameOrNull(env) + ?.let { managedInstallDir(env).resolve(it) } + ?.takeIf { it.isExecutableFile(env) } + ?.let { return ResolvedServer(it, ServerEdition.COMMUNITY, ResolvedServer.Origin.MANAGED_INSTALL) } + + findCargoBuild(projectBasePath, env)?.let { + return ResolvedServer(it, ServerEdition.COMMUNITY, ResolvedServer.Origin.CARGO_BUILD) + } + return null + } + + private fun editionForName(path: Path): ServerEdition = + if (path.fileName.toString().startsWith("codegraph-pro")) ServerEdition.PRO else ServerEdition.COMMUNITY + + private fun findProBinary(env: ResolverEnvironment): ResolvedServer? { + val name = if (env.isWindows) "codegraph-pro.exe" else "codegraph-pro" + findOnPath(name, env)?.let { + return ResolvedServer(it, ServerEdition.PRO, ResolvedServer.Origin.PRO_PATH) + } + val candidates = listOf( + env.homeDir.resolve(".codegraph-pro").resolve("bin").resolve(name), + env.homeDir.resolve(".local").resolve("bin").resolve(name), + Paths.get("/usr/local/bin", name), + ) + return candidates.firstOrNull { it.isExecutableFile(env) } + ?.let { ResolvedServer(it, ServerEdition.PRO, ResolvedServer.Origin.PRO_INSTALL_DIR) } + } + + /** + * PATH lookup done in-process. The VS Code client shells out to + * `which`/`where`; doing it here avoids spawning a shell entirely, which + * also sidesteps the Windows path-with-spaces class of bug (issue #2). + */ + private fun findOnPath(binaryName: String, env: ResolverEnvironment): Path? { + val extensions = if (env.isWindows) listOf("", ".exe", ".cmd", ".bat") else listOf("") + return env.pathEntries + .asSequence() + .flatMap { dir -> + extensions.asSequence().map { ext -> + dir.resolve(if (binaryName.endsWith(ext)) binaryName else binaryName + ext) + } + } + .firstOrNull { it.isExecutableFile(env) } + } + + /** + * Cargo build outputs, for contributors running the plugin against a + * locally built engine. Release is preferred over debug: a contributor who + * has both almost always means the optimised one, and a debug engine + * indexes slowly enough to look like a hang. + */ + private fun findCargoBuild(projectBasePath: String?, env: ResolverEnvironment): Path? { + val base = projectBasePath?.let { Paths.get(it) } ?: return null + val exe = if (env.isWindows) ".exe" else "" + val candidates = listOf( + base.resolve("target/release/codegraph-server$exe"), + base.resolve("target/debug/codegraph-server$exe"), + // The plugin may be opened with `jetbrains/` itself as the project root. + base.resolve("../target/release/codegraph-server$exe"), + base.resolve("../target/debug/codegraph-server$exe"), + ) + return candidates.firstOrNull { it.isExecutableFile(env) }?.normalize() + } + + /** + * Windows has no executable bit, so file-ness is the only check available + * there; on POSIX both must hold. + */ + private fun Path.isExecutableFile(env: ResolverEnvironment): Boolean = + try { + isRegularFile() && (env.isWindows || isExecutable()) + } catch (_: SecurityException) { + false + } + + /** File name shared with the JavaScript installer, so all channels agree. */ + const val VERSION_MARKER = ".engine-version" + + /** + * The engine release this plugin fetches, and the version a managed install + * is expected to be. + * + * Deliberately not the plugin's own version. Release assets are tagged with + * the *engine's* version (`scripts/publish-release-assets.sh` reads + * Cargo.toml), so a plugin-only patch would ask for `v/…` + * and get a 404 - and the plugin no longer bundles an engine to fall back + * on. It is also what makes the shared `~/.codegraph/bin` marker meaningful: + * all three clients compare it against the same number rather than against + * three separately drifting client versions. + * + * Mirrors `ENGINE_VERSION` in `mcp-package/bin/fetch-engine.js`; both are + * held equal to Cargo.toml by `scripts/publish-release-assets.sh`, which + * refuses to publish while they disagree. + */ + const val ENGINE_VERSION = "0.20.0" + + private val ARM64_ARCHES = setOf("aarch64", "arm64") + private val X64_ARCHES = setOf("x86_64", "amd64", "x64") +} diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CrashBreadcrumbs.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CrashBreadcrumbs.kt new file mode 100644 index 0000000..1d2e790 --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CrashBreadcrumbs.kt @@ -0,0 +1,157 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +package ai.codegraph.jetbrains.server + +import com.google.gson.JsonParser +import com.intellij.openapi.diagnostic.logger +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths + +/** + * Why the engine died, as far as we can tell. + * + * [cause] is an enum-like token, never free text from the crash: the engine's + * panic hook deliberately writes a classification rather than a message so that + * nothing user-specific leaves the machine. + */ +data class CrashDiagnosis( + val cause: String, + val phase: String? = null, +) { + /** A sentence fit for a notification, not a stack trace. */ + fun describe(): String = when (cause) { + HARD_CRASH -> "the engine died without running its panic handler, which usually means a segfault, " + + "an out-of-memory kill, or antivirus terminating it" + SIGNAL -> "the engine was killed by a signal" + "oom" -> "the engine ran out of memory" + "rocksdb_lock" -> "the engine's database was locked by another CodeGraph process" + "mutex_poison" -> "the engine hit an internal lock poisoning error" + "utf8_parse" -> "the engine hit a text-encoding error while parsing" + else -> "the engine stopped unexpectedly ($cause)" + } + (phase?.let { ", during $it" } ?: "") + + companion object { + const val HARD_CRASH = "hard_crash" + const val SIGNAL = "signal" + } +} + +/** + * Reads the crash breadcrumbs the engine drops in `~/.codegraph`. + * + * The engine's panic hook writes `last-crash..json` with a classification, + * and marks the phase it was in via `last-phase..json`. Absence of a fresh + * crash file is itself information: it means the process died in a way that + * could not run the hook at all. + * + * Best effort throughout. A diagnosis is a nicety; failing to read one must + * never turn into a second error on top of the crash. + */ +class CrashBreadcrumbs( + private val directory: Path = Paths.get(System.getProperty("user.home").orEmpty(), ".codegraph"), + private val clock: () -> Long = System::currentTimeMillis, + private val isProcessAlive: (Long) -> Boolean = { pid -> ProcessHandle.of(pid).isPresent }, +) { + + /** + * Classify the most recent crash and delete the breadcrumbs it left, so a + * stale file can never be read as a diagnosis of some later crash. + * + * `~/.codegraph` is shared by every engine on the machine - a second IDE + * project, an open VS Code window - and only the ones whose process is gone + * describe a crash. Reading a live engine's marker would attribute its + * phase to a death that never happened, and deleting it would destroy the + * only evidence available if it later dies hard, which is the attribution + * the engine's own sweeper takes care to preserve. + * + * Only the two breadcrumbs actually consumed here are deleted. The rest + * belong to crashes no one has read yet - a VS Code engine that died + * moments ago and whose extension has not activated - and clearing them + * would leave that crash reported with no cause at all. Breadcrumbs nobody + * claims are the engine sweeper's to remove, which it does only once they + * are old enough to be certain of. + */ + fun readAndClear(): CrashDiagnosis { + val all = runCatching { Files.list(directory).use { it.toList() } }.getOrNull() + ?: return CrashDiagnosis(CrashDiagnosis.HARD_CRASH) + + val files = all.filter { isBreadcrumb(it) && !belongsToLiveProcess(it) } + + val crash = pickFresh(files, CRASH_PATTERN) + val cause = crash?.values?.let { crumb -> + when { + crumb["kind"] == "signal" -> CrashDiagnosis.SIGNAL + crumb["kind"] == "panic" -> crumb["class"] + else -> null + } + } ?: CrashDiagnosis.HARD_CRASH + + val phaseCrumb = pickFresh(files, PHASE_PATTERN) + + listOfNotNull(crash?.path, phaseCrumb?.path) + .forEach { runCatching { Files.deleteIfExists(it) } } + + return CrashDiagnosis(cause, phaseCrumb?.values?.get("phase")) + } + + /** A breadcrumb this call consumed: its file, and whatever could be read from it. */ + private data class Breadcrumb(val path: Path, val values: Map) + + private fun isBreadcrumb(path: Path): Boolean { + val name = path.fileName.toString() + return CRASH_PATTERN.matches(name) || PHASE_PATTERN.matches(name) + } + + /** + * A breadcrumb is named `last-..json`. An unparseable pid is + * treated as dead: it cannot belong to a process we could be harming, and + * leaving it forever would let it outlive every crash it might describe. + */ + private fun belongsToLiveProcess(path: Path): Boolean { + val pid = path.fileName.toString().split('.').getOrNull(1)?.toLongOrNull() ?: return false + return runCatching { isProcessAlive(pid) }.getOrDefault(false) + } + + /** + * Newest file matching [pattern], parsed to a flat string map - but only if + * it was written recently enough to belong to the crash we are diagnosing. + * Without the freshness window a breadcrumb from a previous session would + * mislabel today's crash. + * + * An unreadable file is still returned, with no values: it was selected, so + * it is this call's to clean up, and leaving it would let the same + * unparseable file be reselected ahead of every later crash in the window. + */ + private fun pickFresh(files: List, pattern: Regex): Breadcrumb? { + val newest = files + .filter { pattern.matches(it.fileName.toString()) } + .mapNotNull { path -> runCatching { path to Files.getLastModifiedTime(path).toMillis() }.getOrNull() } + .maxByOrNull { it.second } + ?: return null + + if (clock() - newest.second > FRESHNESS_WINDOW_MS) return null + + val values = runCatching { + JsonParser.parseString(Files.readString(newest.first)).asJsonObject + .entrySet() + .mapNotNull { (key, value) -> + val primitive = value.takeIf { it.isJsonPrimitive } ?: return@mapNotNull null + key to primitive.asString + } + .toMap() + }.onFailure { LOG.debug("Unreadable CodeGraph crash breadcrumb", it) }.getOrDefault(emptyMap()) + + return Breadcrumb(newest.first, values) + } + + private companion object { + val LOG = logger() + val CRASH_PATTERN = Regex("""^last-crash\..*\.json$""") + val PHASE_PATTERN = Regex("""^last-phase\..*\.json$""") + + /** How recent a breadcrumb must be to describe the crash at hand. */ + const val FRESHNESS_WINDOW_MS = 15_000L + } +} diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/EngineDownloader.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/EngineDownloader.kt new file mode 100644 index 0000000..8937a3c --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/EngineDownloader.kt @@ -0,0 +1,191 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +package ai.codegraph.jetbrains.server + +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.util.io.HttpRequests +import java.nio.file.AccessDeniedException +import java.nio.file.FileSystemException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.security.MessageDigest +import java.util.Locale + +/** + * Fetches the engine for this platform into the managed install directory. + * + * The plugin does not bundle engines: the JetBrains Marketplace serves one + * artifact to every platform, so bundling all four would mean a ~120 MB + * download for every user to obtain the ~30 MB they can run. The alternative + * for users without Node is worse - install a 498 MB npm package for one + * binary - so the engine is fetched directly from the release that + * `scripts/publish-release-assets.sh` produces. + * + * Downloads are verified against the checksum published beside each asset. An + * engine is a native binary that runs with the user's permissions; TLS says + * nothing about a mirror, a proxy, or a truncated transfer. + */ +class EngineDownloader( + private val env: ResolverEnvironment = ResolverEnvironment.fromSystem(), + private val releaseBaseUrl: String = DEFAULT_RELEASE_BASE_URL, +) { + + class ChecksumMismatchException(asset: String, expected: String, actual: String) : RuntimeException( + "$asset failed checksum verification (expected $expected, got $actual). The download was discarded.", + ) + + /** + * Raised when the engine on disk cannot be replaced because something still + * holds it open - on Windows, a running engine holds its own `.exe`. + * Distinguished from an ordinary I/O failure because the answer is + * different: stop the engine, then try again. + */ + class EngineInUseException(asset: String, cause: Throwable) : RuntimeException( + "$asset could not be replaced because it is in use. Stop the CodeGraph engine and try again.", + cause, + ) + + /** + * Download and install the engine for [version], returning its path. + * + * Every file is staged next to its destination and verified before anything + * is moved into place, so an interrupted or corrupted download can never + * leave something behind that later looks like a valid install - and a + * Windows install cannot end up with a new `.exe` beside the old sidecar. + * + * [beforeInstall] runs after the last download is verified and before the + * first file is moved. The engine holds its own binary open while it runs, + * so the caller uses this to stop it - at the last possible moment, since + * stopping it for the length of a transfer that may fail costs the user a + * working engine for nothing. + */ + fun download( + version: String, + indicator: ProgressIndicator? = null, + beforeInstall: () -> Unit = {}, + ): Path { + val binaryName = CodeGraphServerResolver.platformBinaryName(env) + val targetDir = CodeGraphServerResolver.managedInstallDir(env) + Files.createDirectories(targetDir) + + // Windows loads onnxruntime.dll at runtime. Fetching only the exe + // produces a download that succeeds and then fails at startup - the + // npm packaging script warns about exactly this - so the sidecar is + // part of the install, not an afterthought. + val assets = buildList { + add(binaryName) + if (env.isWindows) add(WINDOWS_SIDECAR) + } + + val staged = LinkedHashMap() + try { + assets.forEachIndexed { index, asset -> + indicator?.text = "Downloading the CodeGraph engine ($version): $asset" + indicator?.fraction = index.toDouble() / assets.size + staged[asset] = fetchVerified(version, asset, targetDir, indicator) + } + beforeInstall() + staged.forEach { (asset, file) -> install(file, targetDir.resolve(asset), asset) } + } finally { + staged.values.forEach { runCatching { Files.deleteIfExists(it) } } + } + + val engine = targetDir.resolve(binaryName) + engine.toFile().setExecutable(true, /* ownerOnly = */ true) + // Written only once every asset has been verified and moved into place: + // a marker recorded earlier would claim an install a later failure + // never completed. Without it the engine is identified by filename + // alone, and one left by an older plugin is reused for good. + Files.writeString(targetDir.resolve(CodeGraphServerResolver.VERSION_MARKER), "$version\n") + LOG.info("Installed CodeGraph engine $version at $engine") + return engine + } + + /** Downloads and verifies one asset, returning the staged file. */ + private fun fetchVerified( + version: String, + asset: String, + targetDir: Path, + indicator: ProgressIndicator?, + ): Path { + val assetUrl = "$releaseBaseUrl/v$version/$asset" + val expected = fetchChecksum("$assetUrl.sha256") + + val staged = Files.createTempFile(targetDir, "$asset.", ".partial") + try { + HttpRequests.request(assetUrl) + .productNameAsUserAgent() + .saveToFile(staged, indicator) + + val actual = sha256(staged) + if (!actual.equals(expected, ignoreCase = true)) { + throw ChecksumMismatchException(asset, expected, actual) + } + } catch (error: Throwable) { + // A file the caller was never handed back is this function's to + // clean up; leaving it behind is how a failed download turns into + // stray `.partial` files in the user's install directory. + runCatching { Files.deleteIfExists(staged) } + throw error + } + return staged + } + + private fun install(staged: Path, destination: Path, asset: String) { + try { + Files.move(staged, destination, StandardCopyOption.REPLACE_EXISTING) + } catch (e: FileSystemException) { + // Windows refuses to replace a file another process holds open, and + // reports that as AccessDenied or as a sharing violation depending + // on the call. Reporting either as a failed download sends the user + // to debug a network they have no problem with. + val locked = e is AccessDeniedException || + e.reason?.contains("another process", ignoreCase = true) == true + throw if (locked) EngineInUseException(asset, e) else e + } + } + + /** + * The checksum file is ` `, the format `shasum -a 256` + * and `sha256sum` both write. Only the digest matters here. + */ + private fun fetchChecksum(url: String): String = + HttpRequests.request(url) + .productNameAsUserAgent() + .readString() + .trim() + .substringBefore(' ') + .lowercase(Locale.ROOT) + + private fun sha256(file: Path): String { + val digest = MessageDigest.getInstance("SHA-256") + Files.newInputStream(file).use { stream -> + val buffer = ByteArray(DIGEST_BUFFER_BYTES) + while (true) { + val read = stream.read(buffer) + if (read < 0) break + digest.update(buffer, 0, read) + } + } + return digest.digest().joinToString("") { byte -> "%02x".format(byte) } + } + + companion object { + private val LOG = logger() + + /** + * Matches the tag scheme in `scripts/publish-release-assets.sh` and the + * repository name used by the npm package's model fetch - the casing is + * load-bearing on a case-sensitive redirect. + */ + const val DEFAULT_RELEASE_BASE_URL = + "https://github.com/codegraph-ai/CodeGraph/releases/download" + + const val WINDOWS_SIDECAR = "onnxruntime.dll" + + private const val DIGEST_BUFFER_BYTES = 1 shl 16 + } +} diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/EngineInstaller.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/EngineInstaller.kt new file mode 100644 index 0000000..04fe33c --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/EngineInstaller.kt @@ -0,0 +1,99 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +package ai.codegraph.jetbrains.server + +import ai.codegraph.jetbrains.lsp.CodeGraphClient +import ai.codegraph.jetbrains.notify.CodeGraphNotifications +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.Project + +/** Runs the engine download behind a progress bar and reports the outcome. */ +object EngineInstaller { + + fun downloadInBackground(project: Project) { + val version = CodeGraphServerResolver.ENGINE_VERSION + ProgressManager.getInstance().run( + object : Task.Backgroundable(project, "Downloading the CodeGraph engine", true) { + override fun run(indicator: ProgressIndicator) { + val client = CodeGraphClient.getInstance(project) + // The engine holds its own binary open, so an update issued + // while it runs cannot replace it - on Windows the move + // fails outright, and everywhere else the old process keeps + // going and the version marker records an engine nobody is + // running. It is stopped once the download is verified, and + // started again whichever way the install ends, so a failed + // update never leaves the user without an engine. + var stopped = false + runCatching { + EngineDownloader().download(version, indicator) { + if (client.isRunning()) { + indicator.text = "Stopping the CodeGraph engine to replace it" + stopped = true + // Without this the stop we asked for arrives as + // an unexpected one: it reports a crash nobody + // had, eats the breadcrumb an actual crash would + // have needed, and counts against the restart + // breaker, which three updates in a minute would + // open - leaving the engine unable to start + // again after a successful install. + EngineLifecycle.getInstance(project).expectShutdown() + if (!client.stopAndAwait()) { + LOG.warn("CodeGraph engine did not stop before the update; replacing anyway") + } + } + } + }.fold( + onSuccess = { path -> + LOG.info("CodeGraph engine installed at $path") + CodeGraphNotifications.info( + project, + "CodeGraph engine $version installed. Starting it now.", + ) + client.start() + }, + onFailure = { error -> + report(project, version, error) + if (stopped) client.start() + }, + ) + } + }, + ) + } + + /** + * Distinguishes "this platform has no published build" and "the download was + * tampered with or truncated" from an ordinary network failure, because the + * three call for completely different responses from the user. + */ + private fun report(project: Project, version: String, error: Throwable) { + LOG.warn("CodeGraph engine download failed", error) + val message = when { + error is EngineDownloader.ChecksumMismatchException -> + "The downloaded engine did not match its published checksum and was discarded. " + + "This can mean a corrupted transfer or an untrusted proxy; nothing was installed." + + error is EngineDownloader.EngineInUseException -> + "The CodeGraph engine could not be replaced because it is still running. " + + "Close other projects using it, or restart the IDE, and try the update again." + + error is CodeGraphServerResolver.UnsupportedPlatformException -> + "CodeGraph does not publish an engine for this platform. " + + "Point it at your own build in Settings | Tools | CodeGraph." + + error.message?.contains("404") == true -> + "No engine was published for version $version on this platform. " + + "Install it with npm i -g @astudioplus/codegraph-mcp instead." + + else -> + "Could not download the CodeGraph engine: ${error.message ?: error::class.java.simpleName}" + } + CodeGraphNotifications.error(project, message) + } + + private val LOG = logger() +} diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/EngineLifecycle.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/EngineLifecycle.kt new file mode 100644 index 0000000..1269d0e --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/EngineLifecycle.kt @@ -0,0 +1,142 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +package ai.codegraph.jetbrains.server + +import ai.codegraph.jetbrains.notify.CodeGraphNotifications +import ai.codegraph.jetbrains.telemetry.TelemetryReporter +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.project.Project +import com.redhat.devtools.lsp4ij.LanguageServerManager +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong + +/** + * Owns what happens when the engine starts, stops, or dies. + * + * The engine is a native process doing heavy work, so it can be killed by + * things the plugin cannot control: antivirus, the OOM killer, a missing system + * library. This service turns those events into one clear message and, when the + * engine cannot stay up at all, into a decision to stop trying. + */ +@Service(Service.Level.PROJECT) +class EngineLifecycle(private val project: Project) { + + private val breaker = RestartCircuitBreaker() + private val breadcrumbs = CrashBreadcrumbs() + + private val startedAt = AtomicLong(0) + private val restarts = AtomicInteger(0) + + /** True while the engine is being deliberately stopped, so it is not counted as a crash. */ + @Volatile + private var shutdownExpected = false + + /** + * The engine we most recently resolved, published by + * [CodeGraphConnectionProvider] as it starts. + * + * Callers that only want to *display* which engine is in play read this + * instead of resolving again. Resolution walks PATH and stats several + * files, and the status bar repaints often enough that doing it there would + * put filesystem I/O on the EDT. + */ + @Volatile + var resolvedServer: ResolvedServer? = null + private set + + fun publishResolvedServer(server: ResolvedServer) { + resolvedServer = server + runCatching { TelemetryReporter.getInstance(project).serverEdition = server.edition } + } + + /** How many times the engine has come back up since the project opened. */ + val restartCount: Int get() = restarts.get() + + /** Milliseconds the engine has been up, or 0 when it is not running. */ + val uptimeMillis: Long + get() = startedAt.get().takeIf { it > 0 }?.let { System.currentTimeMillis() - it } ?: 0 + + /** + * True when the engine has crashed too often to keep restarting. + * [ai.codegraph.jetbrains.lsp.CodeGraphClient] refuses to start while this + * holds, which is what actually breaks the loop. + */ + val isRestartBlocked: Boolean get() = breaker.isOpen + + fun onEngineStarted() { + // A deliberate stop that never produced a stop event would otherwise + // leave the flag armed for the lifetime of the project, and the first + // real crash after it would be swallowed silently. + shutdownExpected = false + if (startedAt.getAndSet(System.currentTimeMillis()) > 0) { + restarts.incrementAndGet() + } + } + + /** Mark the next stop as deliberate. Consumed by the following stop event. */ + fun expectShutdown() { + shutdownExpected = true + } + + /** + * Called when the engine process disappears without being asked to. + * + * Reads the crash breadcrumb for a cause, counts the crash, and once the + * breaker opens, stops the engine and explains why rather than letting + * LSP4IJ start it again on the next request. + */ + fun onUnexpectedStop() { + if (shutdownExpected) { + shutdownExpected = false + return + } + val uptime = uptimeMillis + startedAt.set(0) + + val diagnosis = breadcrumbs.readAndClear() + LOG.warn("CodeGraph engine stopped after ${uptime}ms: ${diagnosis.cause} (phase=${diagnosis.phase})") + + runCatching { + TelemetryReporter.getInstance(project).engineCrashed( + cause = diagnosis.cause, + phase = diagnosis.phase, + uptimeSeconds = uptime / 1000, + restartCount = restarts.get(), + ) + } + + if (!breaker.recordCrash(System.currentTimeMillis())) return + + expectShutdown() + runCatching { LanguageServerManager.getInstance(project).stop(CODEGRAPH_SERVER_ID) } + .onFailure { LOG.warn("Could not stop the CodeGraph engine after tripping the restart breaker", it) } + + CodeGraphNotifications.errorWithActions( + project, + "The CodeGraph engine crashed ${breaker.describeTripCondition()}, so it will not be restarted " + + "automatically. Diagnosis: ${diagnosis.describe()}. This is most often caused by antivirus " + + "software, a missing system library, or too little memory.", + "Retry" to { notification -> + notification.expire() + retry() + }, + ) + } + + /** Close the breaker and start the engine again. */ + fun retry() { + breaker.reset() + restarts.set(0) + runCatching { LanguageServerManager.getInstance(project).start(CODEGRAPH_SERVER_ID) } + .onFailure { LOG.warn("Retrying the CodeGraph engine failed", it) } + } + + companion object { + private val LOG = logger() + + fun getInstance(project: Project): EngineLifecycle = project.service() + } +} diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/RestartCircuitBreaker.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/RestartCircuitBreaker.kt new file mode 100644 index 0000000..7addea6 --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/RestartCircuitBreaker.kt @@ -0,0 +1,65 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +package ai.codegraph.jetbrains.server + +/** + * Stops the engine being restarted forever on a machine where it cannot run. + * + * Without this, a host with antivirus interference, a missing runtime library + * or too little memory produces an endless crash-restart loop. In the VS Code + * client this showed up as single machines generating 50+ crash events a week, + * which is both useless to the user and noise in the data. + * + * After [maxCrashes] crashes inside [windowMillis] the breaker opens and stays + * open until [reset] is called, which is what the "Retry" button does. + */ +class RestartCircuitBreaker( + private val maxCrashes: Int = DEFAULT_MAX_CRASHES, + private val windowMillis: Long = DEFAULT_WINDOW_MILLIS, +) { + private val crashTimestamps = ArrayDeque() + + var isOpen: Boolean = false + private set + + /** + * Record a crash at [now]. + * + * @return true if this crash opened the breaker, meaning the caller should + * stop the engine and tell the user rather than restarting again. Returns + * false on subsequent crashes while already open, so the user is warned + * once rather than repeatedly. + */ + @Synchronized + fun recordCrash(now: Long): Boolean { + if (isOpen) return false + + crashTimestamps.addLast(now) + while (crashTimestamps.isNotEmpty() && now - crashTimestamps.first() >= windowMillis) { + crashTimestamps.removeFirst() + } + + if (crashTimestamps.size >= maxCrashes) { + isOpen = true + return true + } + return false + } + + /** Close the breaker and forget the crash history. */ + @Synchronized + fun reset() { + crashTimestamps.clear() + isOpen = false + } + + /** Human-readable summary of the trip condition, for the error message. */ + fun describeTripCondition(): String = + "$maxCrashes times in ${windowMillis / 1000}s" + + companion object { + const val DEFAULT_MAX_CRASHES = 3 + const val DEFAULT_WINDOW_MILLIS = 60_000L + } +} diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/settings/CodeGraphConfigurable.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/settings/CodeGraphConfigurable.kt new file mode 100644 index 0000000..bb70954 --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/settings/CodeGraphConfigurable.kt @@ -0,0 +1,173 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +package ai.codegraph.jetbrains.settings + +import ai.codegraph.jetbrains.lsp.CodeGraphClient +import ai.codegraph.jetbrains.lsp.CodeGraphCommand +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.options.BoundConfigurable +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogPanel +import com.intellij.ui.dsl.builder.bindIntText +import com.intellij.ui.dsl.builder.bindItem +import com.intellij.ui.dsl.builder.bindSelected +import com.intellij.ui.dsl.builder.bindText +import com.intellij.ui.dsl.builder.columns +import com.intellij.ui.dsl.builder.panel + +/** + * Settings | Tools | CodeGraph. + * + * Mirrors the `codegraph.*` keys the VS Code client exposes so a user moving + * between editors finds the same knobs under the same names. + */ +class CodeGraphConfigurable(private val project: Project) : BoundConfigurable(DISPLAY_NAME) { + + private val state get() = CodeGraphSettings.getInstance(project).state + + override fun createPanel(): DialogPanel = panel { + group("Engine") { + row { + checkBox("Enable CodeGraph") + .bindSelected(state::enabled) + } + row("Engine path:") { + textFieldWithBrowseButton() + .columns(COLUMNS_WIDE) + .bindText(state::serverPath) + .comment( + "Leave empty to resolve automatically: CodeGraph Pro, then PATH, " + + "then a downloaded engine under ~/.codegraph/bin.", + ) + } + } + + group("Indexing") { + row { + checkBox("Index the workspace on startup") + .bindSelected(state::indexOnStartup) + } + row("Exclude patterns:") { + expandableTextField({ text -> splitList(text) }, { values -> joinList(values) }) + .columns(COLUMNS_WIDE) + .bindText( + getter = { joinList(state.excludePatterns) }, + setter = { text -> state.excludePatterns = splitList(text) }, + ) + .comment("Comma-separated globs.") + } + row("Index only these paths:") { + expandableTextField({ text -> splitList(text) }, { values -> joinList(values) }) + .columns(COLUMNS_WIDE) + .bindText( + getter = { joinList(state.indexPaths) }, + setter = { text -> state.indexPaths = splitList(text) }, + ) + .comment("Comma-separated. Empty means the whole workspace.") + } + row("Maximum file size (KB):") { + intTextField(range = MIN_FILE_SIZE_KB..MAX_FILE_SIZE_KB) + .bindIntText(state::maxFileSizeKB) + } + } + + group("Embeddings") { + row("Model:") { + comboBox(EMBEDDING_MODELS) + .bindItem( + getter = { state.embeddingModel }, + setter = { value -> state.embeddingModel = value ?: DEFAULT_EMBEDDING_MODEL }, + ) + } + row("Static model directory:") { + textFieldWithBrowseButton() + .columns(COLUMNS_WIDE) + .bindText(state::staticModelPath) + .comment("Only used when the model is set to static.") + } + row { + checkBox("Embed whole symbol bodies") + .bindSelected(state::fullBodyEmbedding) + .comment( + "Turning this off degrades duplicate detection, clustering and " + + "similarity search. Leave it on unless indexing time is a problem.", + ) + } + row { + checkBox("Embed files as they are opened") + .bindSelected(state::embedOnOpen) + } + } + + group("Editor") { + row { + checkBox("Show graph information above declarations") + .bindSelected(state::codeLensEnabled) + } + row { + checkBox("Show graph information on hover") + .bindSelected(state::hoverEnabled) + } + } + + group("Diagnostics") { + row { + checkBox("Send anonymous usage data") + .bindSelected(state::telemetryEnabled) + } + row { + checkBox("Send error reports only") + .bindSelected(state::telemetryErrorReportsOnly) + } + row { + checkBox("Verbose logging") + .bindSelected(state::debug) + } + } + } + + /** + * Push the new configuration to a running engine. + * + * Some settings, such as the embedding model, are only read at + * `initialize`, so this covers the ones the engine can adopt live and the + * rest take effect on the next start. + */ + override fun apply() { + super.apply() + + val client = CodeGraphClient.getInstance(project) + val updated = mapOf( + "indexOnStartup" to state.indexOnStartup, + "excludePatterns" to state.excludePatterns.toList(), + "indexPaths" to state.indexPaths.toList(), + "maxFileSizeKB" to state.maxFileSizeKB, + "embedOnOpen" to state.embedOnOpen, + ) + client.execute(CodeGraphCommand.UPDATE_CONFIGURATION, updated) + .whenComplete { _, error -> + // The engine simply may not be running, which is not worth + // interrupting someone who just clicked OK in a settings dialog. + if (error != null) LOG.info("Could not push CodeGraph settings to the engine: ${error.message}") + } + } + + private companion object { + val LOG = logger() + + const val DISPLAY_NAME = "CodeGraph" + const val COLUMNS_WIDE = 40 + const val MIN_FILE_SIZE_KB = 1 + const val MAX_FILE_SIZE_KB = 1024 * 64 + const val DEFAULT_EMBEDDING_MODEL = "bge-small" + + val EMBEDDING_MODELS = listOf("bge-small", "granite-97m", "static") + + fun joinList(values: List): String = values.joinToString(", ") + + /** Returns a MutableList because the platform's SAM type demands one. */ + fun splitList(text: String): MutableList = + text.split(',').map { it.trim() }.filter { it.isNotEmpty() }.toMutableList() + } +} diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/settings/CodeGraphSettings.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/settings/CodeGraphSettings.kt new file mode 100644 index 0000000..2ce5e26 --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/settings/CodeGraphSettings.kt @@ -0,0 +1,98 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +package ai.codegraph.jetbrains.settings + +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.util.xmlb.XmlSerializerUtil + +/** + * Project-scoped CodeGraph settings. + * + * These mirror the `codegraph.*` keys in `vscode/package.json`. Indexing scope + * is inherently per-project, so the whole set is stored at project level rather + * than split across application/project scopes. + * + * Only the keys the engine actually consumes at `initialize` time live here so + * far; the remaining VS Code keys land with the settings UI in Phase 1. + */ +@Service(Service.Level.PROJECT) +@State(name = "CodeGraphSettings", storages = [Storage("codegraph.xml")]) +class CodeGraphSettings : PersistentStateComponent { + + /** + * Mutable state bag. Kept as plain JVM types with public fields because + * [XmlSerializerUtil] serialises fields, not Kotlin properties with custom + * accessors. + */ + class State { + @JvmField var enabled: Boolean = true + + /** Explicit engine binary path; empty means "resolve automatically". */ + @JvmField var serverPath: String = "" + + /** + * Off by default, matching the VS Code client and the engine itself. + * Turning it on makes the engine index during `initialize`, which races + * the "not indexed yet" prompt and can index the workspace twice. + */ + @JvmField var indexOnStartup: Boolean = false + @JvmField var excludePatterns: MutableList = mutableListOf( + "**/node_modules/**", + "**/target/**", + "**/.git/**", + "**/dist/**", + "**/build/**", + "**/__pycache__/**", + "**/venv/**", + "**/.venv/**", + ) + @JvmField var indexPaths: MutableList = mutableListOf() + @JvmField var maxFileSizeKB: Int = 1024 + + /** One of `bge-small`, `granite-97m`, `static`. */ + @JvmField var embeddingModel: String = "bge-small" + + /** Overrides the bundled model directory when [embeddingModel] is `static`. */ + @JvmField var staticModelPath: String = "" + + /** + * Embed whole symbol bodies rather than signatures. Must default to + * true: duplicate detection, clustering and similarity search all + * degrade badly without it. + */ + @JvmField var fullBodyEmbedding: Boolean = true + + @JvmField var embedOnOpen: Boolean = true + + @JvmField var codeLensEnabled: Boolean = true + @JvmField var hoverEnabled: Boolean = true + + /** + * Opt-in. The IntelliJ Platform exposes no statistics-consent signal to + * third-party plugins, so there is nothing to honour and no basis for + * collecting by default. + */ + @JvmField var telemetryEnabled: Boolean = false + @JvmField var telemetryErrorReportsOnly: Boolean = false + + @JvmField var debug: Boolean = false + } + + private var state = State() + + override fun getState(): State = state + + override fun loadState(loaded: State) { + XmlSerializerUtil.copyBean(loaded, state) + } + + companion object { + fun getInstance(project: Project): CodeGraphSettings = project.service() + } +} diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/telemetry/TelemetryConfig.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/telemetry/TelemetryConfig.kt new file mode 100644 index 0000000..8c75705 --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/telemetry/TelemetryConfig.kt @@ -0,0 +1,32 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +package ai.codegraph.jetbrains.telemetry + +import java.util.Properties + +/** + * The analytics endpoint, injected at build time. + * + * The key comes from `CODEGRAPH_POSTHOG_KEY` in the release build environment + * and is absent everywhere else, which is the point: a developer build, a fork, + * or anyone building from source reports nothing at all, with no setting to + * remember to turn off. + */ +object TelemetryConfig { + + private val properties: Properties = Properties().apply { + TelemetryConfig::class.java.getResourceAsStream(RESOURCE)?.use { load(it) } + } + + val key: String = properties.getProperty("posthogKey").orEmpty() + + val host: String = properties.getProperty("posthogHost") + ?.takeIf { it.isNotBlank() } + ?: "https://us.posthog.com" + + /** No key means the whole reporter is inert. */ + val hasKey: Boolean get() = key.isNotBlank() + + private const val RESOURCE = "/codegraph-telemetry.properties" +} diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/telemetry/TelemetryGate.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/telemetry/TelemetryGate.kt new file mode 100644 index 0000000..947997e --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/telemetry/TelemetryGate.kt @@ -0,0 +1,60 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +package ai.codegraph.jetbrains.telemetry + +/** + * Whether one event may be sent. + * + * Kept as a pure function, separate from any transport or IDE service, because + * this is the code where a mistake means sending data from someone who asked + * not to be measured. That deserves to be readable and directly testable rather + * than tangled in a class that needs a running IDE to exercise. + * + * Every gate must pass; they are deliberately expressed as reasons to refuse. + */ +object TelemetryGate { + + /** + * @param hasKey false when no PostHog key was compiled in - the default for + * any build that is not an official release, so a local or forked build + * reports nothing at all. + * @param pluginEnabled the plugin's `telemetry.enabled` setting, which + * defaults to **off**. The VS Code client can default to on because VS + * Code exposes `env.isTelemetryEnabled`, a platform-level consent the + * extension can honour. The IntelliJ Platform exposes no equivalent to + * third-party plugins - the only way to read the IDE's statistics consent + * is an `@ApiStatus.Internal` API that plugins are not meant to call - so + * there is no signal here to honour, and collecting by default would mean + * collecting from people who never agreed to anything. + * @param errorReportsOnly the plugin's `telemetry.errorReportsOnly` setting. + * @param isErrorEvent whether the event being considered reports a failure. + */ + fun allows( + hasKey: Boolean, + pluginEnabled: Boolean, + errorReportsOnly: Boolean, + isErrorEvent: Boolean, + ): Boolean = when { + !hasKey -> false + !pluginEnabled -> false + errorReportsOnly && !isErrorEvent -> false + else -> true + } + + /** + * Drop null and blank values. + * + * A property whose value is unknown must be absent, never the string + * "unknown" or "undefined": those look like real values in a dashboard and + * silently inflate whatever bucket they land in. + */ + fun clean(properties: Map): Map = + properties.mapNotNull { (key, value) -> + when { + value == null -> null + value is String && value.isBlank() -> null + else -> key to value + } + }.toMap() +} diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/telemetry/TelemetryReporter.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/telemetry/TelemetryReporter.kt new file mode 100644 index 0000000..8b8aad1 --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/telemetry/TelemetryReporter.kt @@ -0,0 +1,176 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +package ai.codegraph.jetbrains.telemetry + +import ai.codegraph.jetbrains.server.ServerEdition +import ai.codegraph.jetbrains.settings.CodeGraphSettings +import com.google.gson.Gson +import com.intellij.openapi.application.ApplicationInfo +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.Disposable +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.project.Project +import com.intellij.openapi.application.PermanentInstallationID +import java.io.OutputStream +import java.net.HttpURLConnection +import java.net.URI +import java.util.UUID +import java.util.concurrent.Executors + +/** + * Sends the same events as the VS Code client, so both editors land in one + * funnel rather than two that have to be reconciled. + * + * Event names and property names are deliberately identical to + * `vscode/src/telemetry/reporter.ts`; only `ide`, `ideProduct` and `ideBuild` + * are added, so a dashboard can split by editor without a second schema. + * + * Nothing is sent unless every gate in [TelemetryGate] passes, and no build + * without a compiled-in key can send at all. + */ +@Service(Service.Level.PROJECT) +class TelemetryReporter(private val project: Project) : Disposable { + + private val gson = Gson() + private val sessionId = UUID.randomUUID().toString() + + /** + * A single daemon thread. Telemetry must never delay anything the user is + * waiting for, and it must never keep the IDE alive on shutdown. + */ + private val sender = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "CodeGraph telemetry").apply { isDaemon = true } + } + + /** Set once the engine is resolved, so events can be split by edition. */ + @Volatile + var serverEdition: ServerEdition? = null + + fun activationStarted(workspaceFolders: Int) = + send("activation_start", mapOf("workspaceFolders" to workspaceFolders), isError = false) + + fun engineStartResult(outcome: String, durationMs: Long, errorHint: String? = null) = + send( + "activation_server_start_result", + mapOf("outcome" to outcome, "durationMs" to durationMs, "errorHint" to errorHint), + isError = outcome != "ok", + ) + + fun engineCrashed(cause: String, phase: String?, uptimeSeconds: Long, restartCount: Int) = + send( + "server_crash", + mapOf( + "crashCause" to cause, + "crashPhase" to phase, + "uptimeSeconds" to uptimeSeconds, + "restartCount" to restartCount, + ), + isError = true, + ) + + fun indexCompleted(outcome: String, durationMs: Long, fileCount: Int) = + send( + "index_completed", + mapOf("outcome" to outcome, "durationMs" to durationMs, "fileCount" to fileCount), + isError = outcome != "ok", + ) + + /** + * Properties every event carries. + * + * `machineId` is the IDE's own installation id rather than anything derived + * from the user or the workspace: it is already the identifier JetBrains + * uses for this purpose, and it is one the user can reset. + */ + private fun commonProperties(): Map { + val info = ApplicationInfo.getInstance() + return mapOf( + "ide" to "jetbrains", + "ideProduct" to info.build.productCode, + "ideBuild" to info.build.asStringWithoutProductCode(), + "pluginVersion" to pluginVersion(), + "os" to System.getProperty("os.name"), + "serverEdition" to serverEdition?.name?.lowercase(), + "machineId" to PermanentInstallationID.get(), + "sessionId" to sessionId, + ) + } + + private fun send(event: String, properties: Map, isError: Boolean) { + // Explicit rather than relying on test builds happening to have no key. + if (ApplicationManager.getApplication()?.isUnitTestMode == true) return + + val settings = CodeGraphSettings.getInstance(project).state + + val allowed = TelemetryGate.allows( + hasKey = TelemetryConfig.hasKey, + pluginEnabled = settings.telemetryEnabled, + errorReportsOnly = settings.telemetryErrorReportsOnly, + isErrorEvent = isError, + ) + if (!allowed) return + + val payload = TelemetryGate.clean(commonProperties() + properties) + if (settings.debug) LOG.info("telemetry $event $payload") + + sender.execute { post(event, payload) } + } + + private fun pluginVersion(): String = + runCatching { + com.intellij.ide.plugins.PluginManagerCore + .getPlugin(com.intellij.openapi.extensions.PluginId.getId(PLUGIN_ID)) + ?.version + }.getOrNull().orEmpty() + + private fun post(event: String, properties: Map) { + runCatching { + val body = gson.toJson( + mapOf( + "api_key" to TelemetryConfig.key, + "event" to event, + "properties" to properties + mapOf("distinct_id" to properties["machineId"]), + ), + ) + val connection = URI("${TelemetryConfig.host}/capture/").toURL().openConnection() as HttpURLConnection + connection.apply { + requestMethod = "POST" + doOutput = true + connectTimeout = TIMEOUT_MS + readTimeout = TIMEOUT_MS + setRequestProperty("Content-Type", "application/json") + } + connection.outputStream.use { stream: OutputStream -> stream.write(body.toByteArray()) } + connection.responseCode + connection.disconnect() + }.onFailure { + // Never surface or retry: a machine that cannot reach the endpoint + // is not a machine whose user should hear about it. + LOG.debug("Telemetry send failed", it) + } + } + + /** + * Closed with the project. The thread is a daemon, so a leaked executor + * never keeps the IDE alive - but this is a project service, and one idle + * thread per project opened in a session is still one too many. + * + * Nothing is waited on: project disposal runs on the EDT, and an in-flight + * POST to an unreachable endpoint would hold the UI for as long as the + * socket timeouts allow. A dropped telemetry event costs nothing. + */ + override fun dispose() { + sender.shutdownNow() + } + + companion object { + private val LOG = logger() + private const val PLUGIN_ID = "ai.codegraph.jetbrains" + private const val TIMEOUT_MS = 5_000 + + fun getInstance(project: Project): TelemetryReporter = project.service() + } +} diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/ui/CodeGraphStatusBarWidget.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/ui/CodeGraphStatusBarWidget.kt new file mode 100644 index 0000000..4e1cb98 --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/ui/CodeGraphStatusBarWidget.kt @@ -0,0 +1,114 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +package ai.codegraph.jetbrains.ui + +import ai.codegraph.jetbrains.lsp.CodeGraphClient +import ai.codegraph.jetbrains.server.EngineLifecycle +import ai.codegraph.jetbrains.server.ServerEdition +import ai.codegraph.jetbrains.settings.CodeGraphSettings +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.wm.StatusBar +import com.intellij.openapi.wm.StatusBarWidget +import com.intellij.openapi.wm.StatusBarWidgetFactory +import com.redhat.devtools.lsp4ij.ServerStatus +import java.awt.event.MouseEvent + +/** + * Status bar entry showing whether the engine is up and which edition is + * running. + * + * The engine is an out-of-process dependency the user never sees, so when it is + * not running every CodeGraph surface is simply empty with no explanation. + * This is the one always-visible place that distinguishes "no results" from + * "nothing is running". + */ +class CodeGraphStatusBarWidgetFactory : StatusBarWidgetFactory { + + override fun getId(): String = WIDGET_ID + + override fun getDisplayName(): String = "CodeGraph" + + override fun isAvailable(project: Project): Boolean = + CodeGraphSettings.getInstance(project).state.enabled + + override fun createWidget(project: Project): StatusBarWidget = CodeGraphStatusBarWidget(project) + + override fun disposeWidget(widget: StatusBarWidget) = Disposer.dispose(widget) + + override fun canBeEnabledOn(statusBar: StatusBar): Boolean = true + + private companion object { + const val WIDGET_ID = "CodeGraphStatusBar" + } +} + +/** + * Every accessor here runs on the EDT during repaint, so all of them read + * cached state only. Resolving the engine walks PATH and stats several files; + * doing that per repaint would be filesystem I/O on the UI thread. + */ +private class CodeGraphStatusBarWidget(private val project: Project) : + StatusBarWidget, + StatusBarWidget.TextPresentation, + DumbAware { + + override fun ID(): String = "CodeGraphStatusBar" + + override fun getPresentation(): StatusBarWidget.WidgetPresentation = this + + override fun install(statusBar: StatusBar) = Unit + + override fun dispose() = Unit + + override fun getAlignment(): Float = 0f + + override fun getText(): String { + val lifecycle = EngineLifecycle.getInstance(project) + val edition = lifecycle.resolvedServer + ?.takeIf { it.edition == ServerEdition.PRO } + ?.let { " Pro" } + .orEmpty() + val state = if (lifecycle.isRestartBlocked) { + "stopped after repeated crashes" + } else { + describe(CodeGraphClient.getInstance(project).status()) + } + return "CodeGraph$edition: $state" + } + + override fun getTooltipText(): String { + val lifecycle = EngineLifecycle.getInstance(project) + if (lifecycle.isRestartBlocked) { + return "The CodeGraph engine crashed repeatedly and will not restart automatically. " + + "Use Tools | CodeGraph | Check Engine Connection to try again." + } + val resolved = lifecycle.resolvedServer + ?: return "The CodeGraph engine has not started yet." + + return buildString { + append("Engine: ${resolved.path}") + append("\nEdition: ${resolved.edition.name.lowercase()}") + append("\nFound via: ${resolved.origin.name.lowercase().replace('_', ' ')}") + if (lifecycle.restartCount > 0) append("\nRestarts this session: ${lifecycle.restartCount}") + } + } + + override fun getClickConsumer(): com.intellij.util.Consumer? = null + + /** + * LSP4IJ's status names are transport-level. Users care about whether the + * graph can answer questions, so they are collapsed to that. + */ + private fun describe(status: ServerStatus): String = when (status) { + ServerStatus.started -> "ready" + ServerStatus.starting -> "starting" + ServerStatus.stopping -> "stopping" + ServerStatus.stopped, ServerStatus.none -> "not running" + ServerStatus.installing, ServerStatus.checking_installed -> "installing" + ServerStatus.installed -> "ready to start" + ServerStatus.not_installed -> "not installed" + } +} diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/ui/MemoriesToolWindow.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/ui/MemoriesToolWindow.kt new file mode 100644 index 0000000..7062fc0 --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/ui/MemoriesToolWindow.kt @@ -0,0 +1,248 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +package ai.codegraph.jetbrains.ui + +import ai.codegraph.jetbrains.lsp.CodeGraphClient +import ai.codegraph.jetbrains.lsp.CodeGraphCommand +import ai.codegraph.jetbrains.notify.CodeGraphNotifications +import com.google.gson.Gson +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.actionSystem.ToggleAction +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.ui.ColoredListCellRenderer +import com.intellij.ui.SearchTextField +import com.intellij.ui.SimpleTextAttributes +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBList +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import javax.swing.DefaultListModel +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.JTextArea +import javax.swing.JSplitPane +import javax.swing.ListSelectionModel + +/** A memory as the engine reports it. */ +data class MemoryEntry( + val id: String = "", + val kind: String = "", + val title: String = "", + val content: String = "", + val tags: List = emptyList(), + val score: Double = 0.0, + val isCurrent: Boolean = true, + val agentSource: String? = null, +) + +private data class MemoryListResponse( + val memories: List = emptyList(), + val total: Int = 0, + val hasMore: Boolean = false, +) + +private data class MemorySearchResponse( + val results: List = emptyList(), + val total: Int = 0, +) + +/** + * Memories: the durable notes the graph accumulates about this codebase, from + * agents and from git-history mining. + * + * They are invisible without a view like this, which makes them easy to + * mistrust - you cannot check what you cannot see. + */ +class MemoriesPanel(private val project: Project) : JPanel(BorderLayout()), com.intellij.openapi.Disposable { + + private val gson = Gson() + private val model = DefaultListModel() + private val list = JBList(model) + private val detail = JTextArea().apply { + isEditable = false + lineWrap = true + wrapStyleWord = true + border = JBUI.Borders.empty(8) + } + private val status = JBLabel().apply { border = JBUI.Borders.empty(4, 8) } + + /** Invalidated memories are hidden by default; they are history, not advice. */ + private var showInvalidated = false + + private val search = SearchTextField().apply { + textEditor.emptyText.text = "Search memories" + textEditor.addActionListener { reload() } + } + + init { + list.selectionMode = ListSelectionModel.SINGLE_SELECTION + list.cellRenderer = MemoryCellRenderer() + list.addListSelectionListener { + if (!it.valueIsAdjusting) showDetail(list.selectedValue) + } + + val header = JPanel(BorderLayout()).apply { + add(toolbar(), BorderLayout.WEST) + add(search, BorderLayout.CENTER) + } + + val split = JSplitPane( + JSplitPane.VERTICAL_SPLIT, + JBScrollPane(list), + JBScrollPane(detail), + ).apply { resizeWeight = LIST_WEIGHT } + + add(header, BorderLayout.NORTH) + add(split, BorderLayout.CENTER) + add(status, BorderLayout.SOUTH) + } + + private fun toolbar(): JComponent { + val group = DefaultActionGroup( + object : AnAction("Refresh", "Reload memories", AllIcons.Actions.Refresh), DumbAware { + override fun actionPerformed(e: AnActionEvent) = reload() + }, + // ToggleAction, not ToggleActionButton: the latter is deprecated + // and scheduled for removal, and until-build here is unbounded. + object : ToggleAction("Show Invalidated", "Include memories that have been invalidated", AllIcons.Actions.Show), + DumbAware { + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT + override fun isSelected(e: AnActionEvent) = showInvalidated + override fun setSelected(e: AnActionEvent, state: Boolean) { + showInvalidated = state + reload() + } + }, + object : AnAction("Statistics", "Show memory statistics", AllIcons.Actions.Preview), DumbAware { + override fun actionPerformed(e: AnActionEvent) = showStats() + }, + ) + val toolbar = ActionManager.getInstance().createActionToolbar("CodeGraphMemories", group, true) + toolbar.targetComponent = this + return toolbar.component + } + + /** + * Search and list are different commands with different response shapes, so + * the query decides which one to call. + */ + fun reload() { + val query = search.text.trim() + setStatus(if (query.isEmpty()) "Loading memories..." else "Searching for \"$query\"...") + + val client = CodeGraphClient.getInstance(project) + val request = if (query.isEmpty()) { + client.execute( + CodeGraphCommand.MEMORY_LIST, + mapOf("currentOnly" to !showInvalidated, "limit" to PAGE_SIZE), + ).thenApply { json -> + runCatching { gson.fromJson(json, MemoryListResponse::class.java) }.getOrNull() + ?.let { it.memories to it.total } + } + } else { + client.execute( + CodeGraphCommand.MEMORY_SEARCH, + mapOf("query" to query, "limit" to PAGE_SIZE, "currentOnly" to !showInvalidated), + ).thenApply { json -> + runCatching { gson.fromJson(json, MemorySearchResponse::class.java) }.getOrNull() + ?.let { it.results to it.total } + } + } + + request.whenComplete { result, error -> + // Both commands take currentOnly, so the engine has already applied + // the filter; re-filtering here would only hide a disagreement. + val entries = result?.first.orEmpty() + val total = result?.second ?: 0 + val message = when { + error != null -> "CodeGraph engine unavailable" + entries.isEmpty() && query.isNotEmpty() -> "No memories match \"$query\"" + entries.isEmpty() -> "No memories yet. Agents add them as they work, " + + "or mine them from git history." + else -> "${entries.size} of $total" + } + ApplicationManager.getApplication().invokeLater { + model.clear() + entries.forEach { model.addElement(it) } + detail.text = "" + status.text = message + } + } + } + + private fun showDetail(entry: MemoryEntry?) { + detail.text = entry?.let { + buildString { + appendLine(it.title) + appendLine("=".repeat(it.title.length.coerceAtMost(TITLE_RULE_MAX))) + appendLine() + appendLine(it.content) + appendLine() + appendLine("Kind: ${it.kind}") + if (it.tags.isNotEmpty()) appendLine("Tags: ${it.tags.joinToString(", ")}") + it.agentSource?.let { source -> appendLine("Recorded by: $source") } + if (!it.isCurrent) appendLine("This memory has been invalidated.") + } + }.orEmpty() + detail.caretPosition = 0 + } + + private fun showStats() { + CodeGraphClient.getInstance(project) + .execute(CodeGraphCommand.MEMORY_STATS, emptyMap()) + .whenComplete { json, error -> + if (error != null) { + CodeGraphNotifications.warn(project, "Could not read memory statistics: ${error.message}") + } else { + CodeGraphNotifications.info(project, json?.toString().orEmpty().take(STATS_PREVIEW)) + } + } + } + + private fun setStatus(text: String) { + ApplicationManager.getApplication().invokeLater { status.text = text } + } + + override fun dispose() = Unit + + private companion object { + const val PAGE_SIZE = 100 + const val LIST_WEIGHT = 0.6 + const val TITLE_RULE_MAX = 60 + const val STATS_PREVIEW = 500 + } +} + +private class MemoryCellRenderer : ColoredListCellRenderer() { + override fun customizeCellRenderer( + list: javax.swing.JList, + value: MemoryEntry?, + index: Int, + selected: Boolean, + hasFocus: Boolean, + ) { + val entry = value ?: return + icon = if (entry.isCurrent) AllIcons.Nodes.Bookmark else AllIcons.General.Warning + // Struck through rather than hidden: an invalidated memory that still + // shows is a signal, and silently rendering it as current would be worse. + val titleStyle = if (entry.isCurrent) { + SimpleTextAttributes.REGULAR_ATTRIBUTES + } else { + SimpleTextAttributes.GRAYED_ATTRIBUTES + } + append(entry.title.ifBlank { "(untitled)" }, titleStyle) + append(" ${entry.kind}", SimpleTextAttributes.GRAYED_SMALL_ATTRIBUTES) + if (entry.tags.isNotEmpty()) { + append(" ${entry.tags.joinToString(" ") { "#$it" }}", SimpleTextAttributes.GRAYED_SMALL_ATTRIBUTES) + } + } +} diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/ui/SymbolsToolWindow.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/ui/SymbolsToolWindow.kt new file mode 100644 index 0000000..bfc2290 --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/ui/SymbolsToolWindow.kt @@ -0,0 +1,231 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +package ai.codegraph.jetbrains.ui + +import ai.codegraph.jetbrains.lsp.CodeGraphClient +import ai.codegraph.jetbrains.lsp.CodeGraphCommand +import com.google.gson.Gson +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.OpenFileDescriptor +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.vfs.VirtualFileManager +import com.intellij.openapi.wm.ToolWindow +import com.intellij.openapi.wm.ToolWindowFactory +import com.intellij.ui.SearchTextField +import com.intellij.ui.SimpleTextAttributes +import com.intellij.ui.components.JBLabel +import com.intellij.ui.content.ContentFactory +import com.intellij.ui.treeStructure.SimpleTree +import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.JPanel +import javax.swing.JComponent +import javax.swing.ScrollPaneConstants +import javax.swing.JScrollPane +import javax.swing.tree.DefaultMutableTreeNode +import javax.swing.tree.DefaultTreeModel +import javax.swing.tree.TreeSelectionModel + +/** One symbol as the engine reports it for the tree view. */ +data class SymbolInfo( + val id: String = "", + val name: String = "", + val kind: String = "", + val language: String = "", + val uri: String = "", + val range: SymbolRange? = null, + val children: List? = null, +) + +data class SymbolRange(val start: SymbolPosition? = null, val end: SymbolPosition? = null) + +data class SymbolPosition(val line: Int = 0, val character: Int = 0) + +private data class WorkspaceSymbolsResponse(val symbols: List = emptyList()) + +/** + * The Symbols tool window: the workspace graph as a navigable tree. + * + * Along with the inline lenses this is where non-agent usage concentrates, so + * it is worth more than the agent-facing command surface despite being simpler. + */ +class SymbolsToolWindowFactory : ToolWindowFactory, DumbAware { + + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { + val factory = ContentFactory.getInstance() + + val symbols = SymbolsPanel(project) + Disposer.register(toolWindow.disposable, symbols) + toolWindow.contentManager.addContent(factory.createContent(symbols, "Symbols", false)) + + // Memories share the tool window rather than claiming their own slot in + // the sidebar: they are the same graph seen from a different angle, and + // two CodeGraph icons would be two things to learn. + val memories = MemoriesPanel(project) + Disposer.register(toolWindow.disposable, memories) + toolWindow.contentManager.addContent(factory.createContent(memories, "Memories", false)) + + symbols.refresh() + memories.reload() + } +} + +private class SymbolsPanel(private val project: Project) : JPanel(BorderLayout()), com.intellij.openapi.Disposable { + + private val gson = Gson() + private val root = DefaultMutableTreeNode("Workspace") + private val model = DefaultTreeModel(root) + private val tree = SimpleTree(model) + private val status = JBLabel().apply { border = JBUI.Borders.empty(4, 8) } + + /** + * Searches on Enter rather than on every keystroke: each query is a round + * trip to the engine over a graph that can hold hundreds of thousands of + * symbols. + */ + private val search = SearchTextField().apply { + textEditor.emptyText.text = "Search symbols" + textEditor.addActionListener { refresh(text.trim()) } + } + + init { + tree.isRootVisible = false + tree.selectionModel.selectionMode = TreeSelectionModel.SINGLE_TREE_SELECTION + tree.cellRenderer = SymbolCellRenderer() + tree.addMouseListener(object : MouseAdapter() { + override fun mouseClicked(event: MouseEvent) { + if (event.clickCount == 2) navigateToSelection() + } + }) + + val header = JPanel(BorderLayout()).apply { + add(toolbar(), BorderLayout.WEST) + add(search, BorderLayout.CENTER) + } + add(header, BorderLayout.NORTH) + add(JScrollPane(tree).apply { + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED + }, BorderLayout.CENTER) + add(status, BorderLayout.SOUTH) + } + + private fun toolbar(): JComponent { + val group = DefaultActionGroup( + object : AnAction("Refresh", "Reload symbols from the graph", AllIcons.Actions.Refresh), DumbAware { + override fun actionPerformed(e: AnActionEvent) = refresh(search.text.trim()) + }, + ) + val toolbar = ActionManager.getInstance().createActionToolbar("CodeGraphSymbols", group, true) + toolbar.targetComponent = this + return toolbar.component + } + + fun refresh(filter: String = "") { + setStatus("Loading symbols...") + // The query key must be *absent* for the unfiltered view. The engine + // treats a missing query as "functions, classes and modules" but an + // empty string as "modules only", so sending "" yields an empty tree on + // a perfectly healthy index. + val arguments = if (filter.isBlank()) emptyMap() else mapOf("query" to filter) + CodeGraphClient.getInstance(project) + .execute(CodeGraphCommand.GET_WORKSPACE_SYMBOLS, arguments) + .whenComplete { json, error -> + val symbols = if (error != null) { + emptyList() + } else { + runCatching { gson.fromJson(json, WorkspaceSymbolsResponse::class.java)?.symbols } + .getOrNull().orEmpty() + } + val message = when { + error != null -> "CodeGraph engine unavailable" + symbols.isEmpty() -> "No symbols yet - index this workspace to populate the graph" + else -> "${symbols.size} top-level ${"symbol".plural(symbols.size)}" + } + // Swing model mutation belongs on the EDT; this callback runs on + // whichever thread completed the LSP future. + ApplicationManager.getApplication().invokeLater { + root.removeAllChildren() + symbols.forEach { root.add(nodeFor(it)) } + model.reload() + setStatus(message) + } + } + } + + private fun nodeFor(symbol: SymbolInfo): DefaultMutableTreeNode { + val node = DefaultMutableTreeNode(symbol) + symbol.children?.forEach { node.add(nodeFor(it)) } + return node + } + + private fun navigateToSelection() { + val symbol = (tree.lastSelectedPathComponent as? DefaultMutableTreeNode)?.userObject as? SymbolInfo ?: return + // The fallback parses the URI itself, and both steps throw on anything + // malformed or non-`file:`. This runs on the EDT from a double-click, + // so an escape surfaces as an IDE error dialog instead of the status + // message the fallback exists to produce. + val file = VirtualFileManager.getInstance().findFileByUrl(symbol.uri) + ?: runCatching { + VirtualFileManager.getInstance().findFileByNioPath( + java.nio.file.Paths.get(java.net.URI.create(symbol.uri)), + ) + }.getOrNull() + ?: run { + setStatus("Cannot open ${symbol.uri}") + return + } + val line = symbol.range?.start?.line ?: 0 + val column = symbol.range?.start?.character ?: 0 + OpenFileDescriptor(project, file, line, column).navigate(true) + } + + private fun setStatus(text: String) { + ApplicationManager.getApplication().invokeLater { status.text = text } + } + + private fun String.plural(count: Int): String = if (count == 1) this else this + "s" + + override fun dispose() = Unit +} + +private class SymbolCellRenderer : com.intellij.ui.ColoredTreeCellRenderer() { + override fun customizeCellRenderer( + tree: javax.swing.JTree, + value: Any?, + selected: Boolean, + expanded: Boolean, + leaf: Boolean, + row: Int, + hasFocus: Boolean, + ) { + val symbol = (value as? DefaultMutableTreeNode)?.userObject as? SymbolInfo + if (symbol == null) { + append(value?.toString().orEmpty()) + return + } + icon = iconFor(symbol.kind) + append(symbol.name) + append(" ${symbol.language} ${symbol.kind.lowercase()}", SimpleTextAttributes.GRAYED_ATTRIBUTES) + } + + private fun iconFor(kind: String) = when (kind.lowercase()) { + "function", "method" -> AllIcons.Nodes.Method + "class", "struct" -> AllIcons.Nodes.Class + "interface", "trait" -> AllIcons.Nodes.Interface + "module", "file" -> AllIcons.Nodes.Module + "variable", "field", "constant" -> AllIcons.Nodes.Field + "enum" -> AllIcons.Nodes.Enum + else -> AllIcons.Nodes.Unknown + } +} diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/vision/CodeGraphCodeVisionProvider.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/vision/CodeGraphCodeVisionProvider.kt new file mode 100644 index 0000000..75b6fd9 --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/vision/CodeGraphCodeVisionProvider.kt @@ -0,0 +1,128 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +package ai.codegraph.jetbrains.vision + +import ai.codegraph.jetbrains.settings.CodeGraphSettings +import com.intellij.codeInsight.codeVision.CodeVisionAnchorKind +import com.intellij.codeInsight.codeVision.CodeVisionEntry +import com.intellij.codeInsight.codeVision.CodeVisionRelativeOrdering +import com.intellij.codeInsight.codeVision.ui.model.ClickableTextCodeVisionEntry +import com.intellij.codeInsight.hints.codeVision.DaemonBoundCodeVisionProvider +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.ActionPlaces +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.util.TextRange +import com.intellij.psi.PsiFile +import java.awt.event.MouseEvent + +/** + * Inline graph facts above declarations: how many callers a function has, how + * many tests reach it, and how complex it is. + * + * This is the surface people actually use. Telemetry from the VS Code client + * showed the inline lenses and tree views getting far more engagement than the + * agent-facing tools, because they put the graph where someone is already + * reading code rather than requiring them to go and ask a question. + */ +class CodeGraphCodeVisionProvider : DaemonBoundCodeVisionProvider { + + override val id: String get() = ID + + override val name: String get() = "CodeGraph" + + override val groupId: String get() = ID + + override val defaultAnchor: CodeVisionAnchorKind get() = CodeVisionAnchorKind.Top + + override val relativeOrderings: List + get() = listOf(CodeVisionRelativeOrdering.CodeVisionRelativeOrderingLast) + + override fun computeForEditor(editor: Editor, file: PsiFile): List> { + val project = file.project + if (!CodeGraphSettings.getInstance(project).state.codeLensEnabled) return emptyList() + + val document = editor.document + // A miss schedules a fetch and restarts the daemon when it lands, so + // returning nothing here means "not yet", not "nothing to show". + val symbols = DocumentStatsCache.getInstance(project).get(file, document.modificationStamp) + ?: return emptyList() + + return symbols.mapNotNull { symbol -> + val range = lineRange(document, symbol.line) ?: return@mapNotNull null + entryFor(symbol)?.let { range to it } + } + } + + /** + * One entry per declaration rather than one per statistic: three separate + * lenses above every function is visual noise in a dense file. + * + * Clicking opens the call graph for the file, which is what the counts are + * a summary of - the VS Code CodeLens does the same. A lens that renders as + * clickable and does nothing is worse than a plain one. + */ + private fun entryFor(symbol: CodeLensSymbol): CodeVisionEntry? { + val parts = buildList { + if (symbol.callerCount > 0) add("${symbol.callerCount} ${"caller".plural(symbol.callerCount)}") + if (symbol.testCount > 0) add("${symbol.testCount} ${"test".plural(symbol.testCount)}") + if (symbol.complexity >= COMPLEXITY_FLOOR) add("complexity ${symbol.complexity}") + } + if (parts.isEmpty()) return null + + return ClickableTextCodeVisionEntry( + parts.joinToString(" · "), + ID, + { event, clickedIn -> showCallGraph(event, clickedIn) }, + null, + parts.joinToString(", "), + tooltipFor(symbol), + emptyList(), + ) + } + + /** + * Runs the same action as Tools | CodeGraph | Show Call Graph, rather than + * duplicating its tool-window plumbing here. + */ + private fun showCallGraph(event: MouseEvent?, clickedIn: Editor) { + val manager = ActionManager.getInstance() + val action = manager.getAction(SHOW_CALL_GRAPH_ACTION_ID) ?: return + // The editor component, not the focus owner: the action reads the + // current file out of the data context, and an inlay click does not + // necessarily leave focus where that would resolve. + manager.tryToExecute(action, event, clickedIn.contentComponent, ActionPlaces.EDITOR_INLAY, true) + } + + private fun tooltipFor(symbol: CodeLensSymbol): String = buildString { + append(symbol.name) + append("\nCallers: ${symbol.callerCount}") + append("\nTests reaching this: ${symbol.testCount}") + append("\nCyclomatic complexity: ${symbol.complexity}") + } + + /** + * The engine reports 0-based lines. A stale graph can point past the end of + * a document the user has since shortened, so the bound is checked rather + * than trusted. + */ + private fun lineRange(document: com.intellij.openapi.editor.Document, line: Int): TextRange? { + if (line < 0 || line >= document.lineCount) return null + return TextRange(document.getLineStartOffset(line), document.getLineEndOffset(line)) + } + + private fun String.plural(count: Int): String = if (count == 1) this else this + "s" + + private companion object { + const val ID = "CodeGraph" + + /** Declared in `plugin.xml`; the lens runs the action rather than copying it. */ + const val SHOW_CALL_GRAPH_ACTION_ID = "CodeGraph.ShowCallGraph" + + /** + * Complexity is only worth screen space once it is high enough to be a + * signal; every small function scoring 1 or 2 would just add noise. + */ + const val COMPLEXITY_FLOOR = 5 + } +} diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/vision/DocumentStatsCache.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/vision/DocumentStatsCache.kt new file mode 100644 index 0000000..bf94a09 --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/vision/DocumentStatsCache.kt @@ -0,0 +1,121 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +package ai.codegraph.jetbrains.vision + +import ai.codegraph.jetbrains.lsp.CodeGraphClient +import ai.codegraph.jetbrains.lsp.CodeGraphCommand +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName +import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiFile +import java.util.concurrent.ConcurrentHashMap + +/** Graph-derived stats for one declaration, as the engine reports them. */ +data class CodeLensSymbol( + val name: String = "", + /** 0-based start line, matching the LSP convention the engine uses. */ + val line: Int = 0, + @SerializedName("callerCount") val callerCount: Int = 0, + @SerializedName("testCount") val testCount: Int = 0, + val complexity: Int = 0, +) + +private data class DocumentCodeLensResponse(val symbols: List = emptyList()) + +/** + * Per-document stats, cached by document modification stamp. + * + * The Code Vision daemon asks for entries synchronously and often - on every + * scroll and re-render. The engine answers over LSP, so fetching inline would + * either block the daemon or hammer the engine. Instead a miss returns nothing, + * schedules one fetch, and restarts the daemon when the answer arrives. + * + * Entries are stored even when the engine returns no symbols. Without that, a + * file the engine knows nothing about would miss forever and re-request on + * every single pass. + */ +@Service(Service.Level.PROJECT) +class DocumentStatsCache(private val project: Project) { + + private data class Entry(val stamp: Long, val symbols: List) + + private val entries = ConcurrentHashMap() + + /** URIs with a fetch in flight, so concurrent daemon passes issue one request. */ + private val inFlight = ConcurrentHashMap.newKeySet() + + private val gson = Gson() + + /** + * Cached stats for [file] at [stamp], or null when a fetch is needed. + * A null return also schedules that fetch. + */ + fun get(file: PsiFile, stamp: Long): List? { + val uri = uriOf(file) ?: return emptyList() + entries[uri]?.takeIf { it.stamp == stamp }?.let { return it.symbols } + requestRefresh(file, uri, stamp) + return null + } + + /** Drop everything, for when the graph itself changed under us. */ + fun invalidateAll() { + entries.clear() + DaemonCodeAnalyzer.getInstance(project).restart() + } + + /** + * Drop one file's entry, for when its editor closes. + * + * Entries hold a full symbol list each and are keyed by URI, so without a + * per-file eviction a session that browses a large tree keeps one for every + * file it ever opened, for as long as the project stays open. + */ + fun evict(file: VirtualFile) { + uriOf(file)?.let { entries.remove(it) } + } + + private fun requestRefresh(file: PsiFile, uri: String, stamp: Long) { + if (!inFlight.add(uri)) return + + CodeGraphClient.getInstance(project) + .execute(CodeGraphCommand.GET_DOCUMENT_CODE_LENS, mapOf("uri" to uri)) + .whenComplete { json, error -> + try { + if (error != null) { + // Usually just "the engine is not running yet". Caching + // an empty result here would hide the stats until the + // next edit, so leave the miss in place instead. + LOG.debug("Code vision fetch failed for $uri", error) + return@whenComplete + } + val symbols = runCatching { + gson.fromJson(json, DocumentCodeLensResponse::class.java)?.symbols + }.getOrNull().orEmpty() + + entries[uri] = Entry(stamp, symbols) + if (file.isValid) { + DaemonCodeAnalyzer.getInstance(project).restart(file) + } + } finally { + inFlight.remove(uri) + } + } + } + + private fun uriOf(file: PsiFile): String? = file.virtualFile?.let { uriOf(it) } + + private fun uriOf(file: VirtualFile): String? = + file.takeIf { it.isInLocalFileSystem }?.let { java.io.File(it.path).toURI().toString() } + + companion object { + private val LOG = logger() + + fun getInstance(project: Project): DocumentStatsCache = project.service() + } +} diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/vision/DocumentStatsEvictor.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/vision/DocumentStatsEvictor.kt new file mode 100644 index 0000000..9eee377 --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/vision/DocumentStatsEvictor.kt @@ -0,0 +1,23 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +package ai.codegraph.jetbrains.vision + +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.FileEditorManagerListener +import com.intellij.openapi.vfs.VirtualFile + +/** + * Drops a file's cached code-vision stats when its editor closes. + * + * [DocumentStatsCache] is keyed by URI and otherwise only cleared wholesale on + * reindex, so a long session that browses a large tree accumulates one entry - + * with its full symbol list - per file it ever opened. A closed editor asks for + * nothing, so its entry is pure residue. + */ +class DocumentStatsEvictor : FileEditorManagerListener { + + override fun fileClosed(source: FileEditorManager, file: VirtualFile) { + runCatching { DocumentStatsCache.getInstance(source.project).evict(file) } + } +} diff --git a/jetbrains/src/main/resources/META-INF/plugin.xml b/jetbrains/src/main/resources/META-INF/plugin.xml new file mode 100644 index 0000000..0e6d46e --- /dev/null +++ b/jetbrains/src/main/resources/META-INF/plugin.xml @@ -0,0 +1,135 @@ + + + + + + CodeGraph builds a symbol- and call-level graph of your whole workspace + across 40+ languages, then exposes it as call graphs, dependency graphs, + impact analysis, related-test discovery and semantic symbol search. +

+

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](https://img.shields.io/badge/License-Apache%202.0-green.svg)](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;