From b48becc70792aed1d3a6ccb150cd5c5fe9708aee Mon Sep 17 00:00:00 2001 From: Andrey Vasilevsky Date: Mon, 6 Jul 2026 23:13:50 -0700 Subject: [PATCH 01/31] feat(vscode): onboarding-funnel repair - zero-file recovery, first-index steer, walkthrough Telemetry (30d, ~2,540 active machines) showed two large leaks: ~445 machines produced a zero-file index and 84% never returned, and of ~2,394 that activate cleanly only ~20% ever open a visible surface. This addresses both. - funnel.ts: diagnose why an index came back empty (no folder / no supported languages / indexPaths misconfig / all excluded / server gap) and offer the one recovery that fixes that cause, replacing the dead-end "Indexed 0 files" toast. Diagnosis scopes to indexPaths when set so the misconfig case is actually reachable. Notifications are fire-and-forget so an agent-driven index never hangs awaiting a dialog it can't answer. - First successful index shows a one-time (globalState-gated) steer to the surfaces telemetry shows convert best (Symbols tree, Call Graph). - codegraphSymbols empty state: actionable "Index Workspace / Open Walkthrough" welcome gated on a new codegraph.indexed context key (was a blank panel). - Getting-started walkthrough (index -> explore -> call graph -> AI assistant) plus a codegraph.openWalkthrough command. - New funnel.* telemetry (zeroFileIndex, zeroFileCta, firstIndexCta) with bounded enums; the command-palette reindex now also emits index.completed (it previously emitted none). - Shared filesIndexed()/reportIndexTelemetry() helpers remove the response coercion + telemetry mapping duplicated across 5 sites. - 12 unit tests for the diagnosis and glob logic (inline vscode mock; the legacy vsforge suites remain quarantined). Co-Authored-By: Claude Fable 5 --- vscode/media/walkthrough/ai.md | 15 ++ vscode/media/walkthrough/callgraph.md | 15 ++ vscode/media/walkthrough/explore.md | 16 ++ vscode/media/walkthrough/index.md | 15 ++ vscode/package.json | 65 ++++++ vscode/src/ai/toolManager.ts | 48 ++-- vscode/src/commands/index.ts | 38 +++- vscode/src/extension.ts | 50 ++--- vscode/src/funnel.test.ts | 159 +++++++++++++ vscode/src/funnel.ts | 312 ++++++++++++++++++++++++++ vscode/src/telemetry/allowlists.ts | 37 +++ vscode/src/telemetry/reporter.ts | 38 ++++ 12 files changed, 754 insertions(+), 54 deletions(-) create mode 100644 vscode/media/walkthrough/ai.md create mode 100644 vscode/media/walkthrough/callgraph.md create mode 100644 vscode/media/walkthrough/explore.md create mode 100644 vscode/media/walkthrough/index.md create mode 100644 vscode/src/funnel.test.ts create mode 100644 vscode/src/funnel.ts 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..2093939 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -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", @@ -1687,10 +1692,70 @@ ] }, "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/extension.ts b/vscode/src/extension.ts index 8caf67b..3540276 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -18,6 +18,7 @@ import { CodeGraphToolManager } from './ai/toolManager'; import { getServerPath } from './server'; 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; @@ -494,7 +495,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 +538,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 +559,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', @@ -672,30 +687,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..517c736 --- /dev/null +++ b/vscode/src/funnel.test.ts @@ -0,0 +1,159 @@ +// 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, + 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..d25753a --- /dev/null +++ b/vscode/src/funnel.ts @@ -0,0 +1,312 @@ +// 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'; + +/** 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); + + 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 Explorer sidebar. + 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/telemetry/allowlists.ts b/vscode/src/telemetry/allowlists.ts index c007690..9ae754a 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]; diff --git a/vscode/src/telemetry/reporter.ts b/vscode/src/telemetry/reporter.ts index 78af29e..763f3a6 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; @@ -314,6 +325,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( From 34157f1d16a672421f6a8f47dd3414f5975967c4 Mon Sep 17 00:00:00 2001 From: Andrey Vasilevsky Date: Tue, 7 Jul 2026 00:04:10 -0700 Subject: [PATCH 02/31] feat(vscode): Phase 1 human surfaces - activity bar, CodeLens, hover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telemetry showed humans engage visible surfaces (tree views: 483 machines, call graph: 116) far more than agent tools (238), yet those surfaces were buried in the Explorer and there was nothing inline in the editor. This adds first-class discoverable surfaces. Activity bar: - Dedicated CodeGraph activity-bar container with an SVG icon; the Symbols and Memories tree views move out of the Explorer into it (registration is by view id, unchanged). Server: - New batched LSP request codegraph/getDocumentCodeLens(uri): per function/ method, returns caller count, related-test count, and cyclomatic complexity in one pass, so the editor issues one request per document instead of N. - Caller/test counting filters to EdgeType::Calls (mirrors helpers::get_callers) so structural Contains edges don't inflate counts; test-vs-production split uses a new shared node_props::is_test_like(), which the PR-review path now also calls so the two can't diverge. Editor: - CodeLens above every function ("N callers · M tests · complexity X"), click reveals the symbol and opens its call graph; matching hover with the same stats. Backed by one version-cached request per document, evicted on close and invalidated on reindex. Registered for all on-disk files (server returns empty for unindexed files, so no language list to drift). - codegraph.codeLens.enabled / codegraph.hover.enabled toggles (default on), in the settings snapshot; engagement.codeLensClicked telemetry. Tests: 2 net-new server tests (counts incl. a Contains-edge regression guard, invalid-uri error); extension tsc + 12 vitest green. Verified via workflow code-review (6 findings, all fixed). Note: the bundled platform binaries need a cross-platform rebuild before CodeLens works end-to-end in a shipped extension. Co-Authored-By: Claude Fable 5 --- .../codegraph-server/src/custom_requests.rs | 7 + .../codegraph-server/src/domain/node_props.rs | 16 ++ .../src/handlers/navigation.rs | 181 ++++++++++++++++ crates/codegraph-server/src/mcp/server.rs | 14 +- vscode/media/codegraph-activitybar.svg | 9 + vscode/package.json | 27 ++- vscode/src/extension.ts | 2 + vscode/src/funnel.test.ts | 8 + vscode/src/funnel.ts | 7 +- vscode/src/telemetry/allowlists.ts | 2 + vscode/src/telemetry/reporter.ts | 5 + vscode/src/views/codeLensProvider.ts | 205 ++++++++++++++++++ vscode/src/views/codeLensRefresh.ts | 19 ++ 13 files changed, 488 insertions(+), 14 deletions(-) create mode 100644 vscode/media/codegraph-activitybar.svg create mode 100644 vscode/src/views/codeLensProvider.ts create mode 100644 vscode/src/views/codeLensRefresh.ts diff --git a/crates/codegraph-server/src/custom_requests.rs b/crates/codegraph-server/src/custom_requests.rs index f24ebf5..a74b725 100644 --- a/crates/codegraph-server/src/custom_requests.rs +++ b/crates/codegraph-server/src/custom_requests.rs @@ -81,6 +81,13 @@ impl CodeGraphBackend { serde_json::to_value(response).map_err(|_| Error::internal_error()) } + "codegraph/getDocumentCodeLens" => { + let params: DocumentCodeLensParams = serde_json::from_value(params) + .map_err(|e| Error::invalid_params(format!("Invalid params: {e}")))?; + let response = self.handle_get_document_code_lens(params).await?; + serde_json::to_value(response).map_err(|_| Error::internal_error()) + } + "codegraph/analyzeComplexity" => { let params: ComplexityParams = serde_json::from_value(params) .map_err(|e| Error::invalid_params(format!("Invalid params: {e}")))?; diff --git a/crates/codegraph-server/src/domain/node_props.rs b/crates/codegraph-server/src/domain/node_props.rs index ef1739e..26efddf 100644 --- a/crates/codegraph-server/src/domain/node_props.rs +++ b/crates/codegraph-server/src/domain/node_props.rs @@ -117,3 +117,19 @@ 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; + } + let name = name(node).to_lowercase(); + if name.starts_with("test_") || name.contains("_test") { + return true; + } + let path = node.properties.get_string("path").unwrap_or(""); + path.contains("/tests/") || path.contains("/test_") +} diff --git a/crates/codegraph-server/src/handlers/navigation.rs b/crates/codegraph-server/src/handlers/navigation.rs index ec04ff7..92b2f2f 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,73 @@ 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; + }; + if node.node_type != codegraph::NodeType::Function || node_props::is_test(node) { + continue; + } + + let mut caller_count = 0u32; + let mut test_count = 0u32; + if let Ok(neighbors) = graph.get_neighbors(node_id, codegraph::Direction::Incoming) { + for caller_id in neighbors { + // 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. + let calls = graph + .get_edges_between(caller_id, node_id) + .ok() + .into_iter() + .flatten() + .filter_map(|eid| graph.get_edge(eid).ok()) + .any(|edge| edge.edge_type == codegraph::EdgeType::Calls); + if !calls { + continue; + } + 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 +549,92 @@ 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/mcp/server.rs b/crates/codegraph-server/src/mcp/server.rs index e244e72..18837c3 100644 --- a/crates/codegraph-server/src/mcp/server.rs +++ b/crates/codegraph-server/src/mcp/server.rs @@ -4108,16 +4108,10 @@ impl McpServer { 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/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/package.json b/vscode/package.json index 2093939..67a2074 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -206,6 +206,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, @@ -1677,16 +1689,25 @@ } ] }, + "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" } ] diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index 3540276..58f5420 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -13,6 +13,7 @@ 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'; @@ -617,6 +618,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( diff --git a/vscode/src/funnel.test.ts b/vscode/src/funnel.test.ts index 517c736..8808632 100644 --- a/vscode/src/funnel.test.ts +++ b/vscode/src/funnel.test.ts @@ -44,6 +44,14 @@ vi.mock('vscode', () => { }), }, 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() }, diff --git a/vscode/src/funnel.ts b/vscode/src/funnel.ts index d25753a..715a353 100644 --- a/vscode/src/funnel.ts +++ b/vscode/src/funnel.ts @@ -23,6 +23,7 @@ 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'; @@ -185,6 +186,10 @@ export async function handleIndexOutcome( // 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. @@ -273,7 +278,7 @@ async function showFirstIndexCta(reporter: Reporter | undefined, fileCount: numb if (choice === EXPLORE) { reporter?.funnelFirstIndexCta({ action: 'explore_symbols', fileCount }); - // Reveal the Symbols tree in the Explorer sidebar. + // 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 }); diff --git a/vscode/src/telemetry/allowlists.ts b/vscode/src/telemetry/allowlists.ts index 9ae754a..ec21438 100644 --- a/vscode/src/telemetry/allowlists.ts +++ b/vscode/src/telemetry/allowlists.ts @@ -413,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 763f3a6..5fffe9e 100644 --- a/vscode/src/telemetry/reporter.ts +++ b/vscode/src/telemetry/reporter.ts @@ -141,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; @@ -421,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.ts b/vscode/src/views/codeLensProvider.ts new file mode 100644 index 0000000..d3e92b2 --- /dev/null +++ b/vscode/src/views/codeLensProvider.ts @@ -0,0 +1,205 @@ +// 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, RequestType } 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[]; +} + +namespace GetDocumentCodeLensRequest { + export const type = new RequestType<{ uri: string }, DocumentCodeLensResponse, void>( + 'codegraph/getDocumentCodeLens', + ); +} + +// 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 { + 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()); + } + + async 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; + } + try { + const response = await this.client.sendRequest(GetDocumentCodeLensRequest.type, { + uri: document.uri.toString(), + }); + const symbols = response?.symbols ?? []; + this.entries.set(key, { version: document.version, symbols }); + return symbols; + } catch { + // Server not ready / not indexed / unsupported file - no lenses. + return []; + } + } +} + +function formatLensTitle(s: CodeLensSymbol): string { + const parts: string[] = []; + parts.push(`$(references) ${s.callerCount} caller${s.callerCount === 1 ? '' : 's'}`); + parts.push(`$(beaker) ${s.testCount} test${s.testCount === 1 ? '' : 's'}`); + if (s.complexity > 0) { + parts.push(`$(pulse) complexity ${s.complexity}`); + } + return parts.join(' · '); +} + +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 range = document.lineAt(s.line).range; + lenses.push( + new vscode.CodeLens(range, { + title: formatLensTitle(s), + 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(); +} From 25517b41232250c8010e545c43db0839fac26f75 Mon Sep 17 00:00:00 2001 From: Andrey Vasilevsky Date: Thu, 9 Jul 2026 01:01:39 -0700 Subject: [PATCH 03/31] fix(vscode): wire getDocumentCodeLens to the live executeCommand dispatch The Phase 1 CodeLens endpoint was registered only in handle_custom_request (the `codegraph/*` slash-method dispatcher), which is dead code: the LSP service is built with `LspService::new` and never registers those custom methods, so slash requests return -32601. The live custom-request path is `workspace/executeCommand` matching `codegraph.*` dot-commands in backend.rs::execute_command. As shipped, codegraph/getDocumentCodeLens would have 404'd in the real extension - the unit test passed only because it calls the handler directly, and the code review didn't exercise the runtime dispatch. Building the vsix and probing the running server surfaced it. - backend.rs: add the live `codegraph.getDocumentCodeLens` arm (mirrors getWorkspaceSymbols), and drop the dead slash registration from custom_requests.rs. - codeLensProvider.ts: call via workspace/executeCommand instead of the unregistered slash RequestType. - navigation.rs: skip test functions via is_test_like (structural marker + name/path heuristic) so languages without a structural test marker (Python test_*) don't get a noise lens, matching caller classification. Verified end-to-end against a real indexed workspace: do_work -> 1 caller, 1 test, complexity 2; test functions suppressed. 11 navigation tests pass. Co-Authored-By: Claude Fable 5 --- crates/codegraph-server/src/backend.rs | 12 +++++++++++ .../codegraph-server/src/custom_requests.rs | 7 ------- .../src/handlers/navigation.rs | 6 +++++- vscode/src/views/codeLensProvider.ts | 21 ++++++++++--------- 4 files changed, 28 insertions(+), 18 deletions(-) diff --git a/crates/codegraph-server/src/backend.rs b/crates/codegraph-server/src/backend.rs index 14d4985..b0b2e01 100644 --- a/crates/codegraph-server/src/backend.rs +++ b/crates/codegraph-server/src/backend.rs @@ -2009,6 +2009,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") diff --git a/crates/codegraph-server/src/custom_requests.rs b/crates/codegraph-server/src/custom_requests.rs index a74b725..f24ebf5 100644 --- a/crates/codegraph-server/src/custom_requests.rs +++ b/crates/codegraph-server/src/custom_requests.rs @@ -81,13 +81,6 @@ impl CodeGraphBackend { serde_json::to_value(response).map_err(|_| Error::internal_error()) } - "codegraph/getDocumentCodeLens" => { - let params: DocumentCodeLensParams = serde_json::from_value(params) - .map_err(|e| Error::invalid_params(format!("Invalid params: {e}")))?; - let response = self.handle_get_document_code_lens(params).await?; - serde_json::to_value(response).map_err(|_| Error::internal_error()) - } - "codegraph/analyzeComplexity" => { let params: ComplexityParams = serde_json::from_value(params) .map_err(|e| Error::invalid_params(format!("Invalid params: {e}")))?; diff --git a/crates/codegraph-server/src/handlers/navigation.rs b/crates/codegraph-server/src/handlers/navigation.rs index 92b2f2f..bb95ede 100644 --- a/crates/codegraph-server/src/handlers/navigation.rs +++ b/crates/codegraph-server/src/handlers/navigation.rs @@ -231,7 +231,11 @@ impl CodeGraphBackend { let Ok(node) = graph.get_node(node_id) else { continue; }; - if node.node_type != codegraph::NodeType::Function || node_props::is_test(node) { + // 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; } diff --git a/vscode/src/views/codeLensProvider.ts b/vscode/src/views/codeLensProvider.ts index d3e92b2..bc1143f 100644 --- a/vscode/src/views/codeLensProvider.ts +++ b/vscode/src/views/codeLensProvider.ts @@ -9,7 +9,7 @@ //! request per document backs both the lenses and the hovers. import * as vscode from 'vscode'; -import { LanguageClient, RequestType } from 'vscode-languageclient/node'; +import { LanguageClient } from 'vscode-languageclient/node'; import type { Reporter } from '../telemetry/reporter'; import { onDidRefreshCodeLenses, refreshCodeLenses } from './codeLensRefresh'; @@ -27,12 +27,6 @@ interface DocumentCodeLensResponse { symbols: CodeLensSymbol[]; } -namespace GetDocumentCodeLensRequest { - export const type = new RequestType<{ uri: string }, DocumentCodeLensResponse, void>( - 'codegraph/getDocumentCodeLens', - ); -} - // 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 @@ -75,9 +69,16 @@ class DocumentStatsCache { return cached.symbols; } try { - const response = await this.client.sendRequest(GetDocumentCodeLensRequest.type, { - uri: document.uri.toString(), - }); + // 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() }], + }, + ); const symbols = response?.symbols ?? []; this.entries.set(key, { version: document.version, symbols }); return symbols; From bee22dfff9f14b2aade3c5e5589fc7a2bb7bd17a Mon Sep 17 00:00:00 2001 From: Andrey Vasilevsky Date: Thu, 9 Jul 2026 21:41:15 -0700 Subject: [PATCH 04/31] fix(vscode): Symbols tree view uses live executeCommand dispatch The Symbols pane was empty on indexed workspaces: SymbolTreeProvider sent `codegraph/getWorkspaceSymbols` via the slash RequestType, which routes to the dead handle_custom_request (unregistered on the LSP service) and returns method-not-found, so the provider caught the error and rendered nothing. Same root cause as the CodeLens dispatch fix. Switch to workspace/executeCommand with the live `codegraph.getWorkspaceSymbols` dot-command (already handled in backend.rs::execute_command). Verified: returns symbols on an indexed workspace. Memories provider already used executeCommand and was unaffected. Co-Authored-By: Claude Fable 5 --- vscode/src/views/treeProviders.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) 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; From 7cb12a4e4b9db077b3a6354a3d2399ff0d3c25b5 Mon Sep 17 00:00:00 2001 From: Andrey Vasilevsky Date: Sun, 19 Jul 2026 11:04:34 -0700 Subject: [PATCH 05/31] docs(npm): make the memory/macOS embedding note a discoverable Troubleshooting section The CODEGRAPH_SKIP_MEMORY_CHECK / 0-MB-detection-failure guidance (added with the #13 fix) was unlabeled prose after the Options table, so a user hitting 'embeddings disabled on my Mac' wouldn't find it by scanning headings. Give it a Troubleshooting heading and tighten the wording. Docs only. Co-Authored-By: Claude Fable 5 --- mcp-package/README.md | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/mcp-package/README.md b/mcp-package/README.md index 960dcd2..0084dbc 100644 --- a/mcp-package/README.md +++ b/mcp-package/README.md @@ -54,10 +54,22 @@ Pass flags after `--`: | `--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) From 3990af2410debf87b465cc99db657e2b9506aa56 Mon Sep 17 00:00:00 2001 From: Andrey Vasilevsky Date: Sat, 25 Jul 2026 22:05:21 -0700 Subject: [PATCH 06/31] feat(jetbrains): add JetBrains IDE plugin as a thin LSP client All analysis stays in codegraph-server. The plugin spawns it, speaks LSP over stdio via LSP4IJ, and renders the results - so this is a client shell, not a second implementation. LSP4IJ rather than the platform's own LSP API: the latter is limited to the paid IDEs, which would exclude IDEA CE, PyCharm CE and Android Studio. LSP4IJ is Apache-2.0 and exposes the underlying LSP4J server, so dropping to raw LSP4J stays available if that dependency ever becomes a problem. Surfaces: Code Vision (callers/tests/complexity above declarations), a Symbols tool window, dependency and call graph panels on JCEF with a text fallback, a status bar widget, settings, and the indexing prompt/reindex flow. Engine binaries are not bundled. The Marketplace ships one artifact for every platform, so bundling all four would mean a ~120 MB download for every user to get the ~30 MB they can use. The engine is resolved from an existing install instead; a downloader was written and removed because the per-platform release assets it would fetch do not exist yet. Two verification harnesses, because neither covers the other: - scripts/engine_probe.py replays the plugin's exact wire traffic with no IDE, and diffs the command enum and settings defaults against the engine and the VS Code client so those hand-written files cannot drift silently. - SelfCheckActivity (inert without -Dcodegraph.selfcheck=true) answers what only a running IDE can: JCEF availability, tool window instantiation. Notable fixes found while building this: - getWorkspaceSymbols must omit `query` entirely for the unfiltered view; an empty string takes the engine's modules-only branch and yields an empty tree on a healthy index. Verified against an indexed workspace: 0 vs 7 symbols. - indexOnStartup defaults to false, matching VS Code and the engine. Defaulting it to true raced the index prompt and indexed the workspace twice. - The pre-index grace period now waits for the engine to finish `initialize` rather than for the non-blocking start() call, which provided no grace at all. - Startup activity returns early in headless environments, where there is no user to prompt - this also stops searchable-options generation from hanging. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013iTPP3eSpGSgWN6c5w4Fm1 --- jetbrains/.gitignore | 6 + jetbrains/README.md | 187 +++++++++++++ jetbrains/build.gradle.kts | 103 +++++++ jetbrains/gradle.properties | 23 ++ jetbrains/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48462 bytes .../gradle/wrapper/gradle-wrapper.properties | 9 + jetbrains/gradlew | 248 +++++++++++++++++ jetbrains/gradlew.bat | 82 ++++++ jetbrains/scripts/engine_probe.py | 251 ++++++++++++++++++ jetbrains/settings.gradle.kts | 20 ++ .../actions/EngineRoundTripAction.kt | 70 +++++ .../actions/ReindexWorkspaceAction.kt | 29 ++ .../jetbrains/actions/ShowGraphAction.kt | 67 +++++ .../diagnostics/SelfCheckActivity.kt | 146 ++++++++++ .../ai/codegraph/jetbrains/graph/GraphHtml.kt | 189 +++++++++++++ .../codegraph/jetbrains/graph/GraphPanel.kt | 152 +++++++++++ .../jetbrains/indexing/IndexingService.kt | 130 +++++++++ .../indexing/IndexingStartupActivity.kt | 93 +++++++ .../jetbrains/lsp/CodeGraphClient.kt | 120 +++++++++ .../jetbrains/lsp/CodeGraphCommand.kt | 71 +++++ .../notify/CodeGraphNotifications.kt | 71 +++++ .../server/CodeGraphConnectionProvider.kt | 109 ++++++++ .../server/CodeGraphLanguageServerFactory.kt | 31 +++ .../server/CodeGraphServerResolver.kt | 211 +++++++++++++++ .../jetbrains/server/CrashBreadcrumbs.kt | 115 ++++++++ .../jetbrains/server/EngineLifecycle.kt | 127 +++++++++ .../jetbrains/server/RestartCircuitBreaker.kt | 65 +++++ .../settings/CodeGraphConfigurable.kt | 173 ++++++++++++ .../jetbrains/settings/CodeGraphSettings.kt | 93 +++++++ .../jetbrains/ui/CodeGraphStatusBarWidget.kt | 114 ++++++++ .../jetbrains/ui/SymbolsToolWindow.kt | 215 +++++++++++++++ .../vision/CodeGraphCodeVisionProvider.kt | 105 ++++++++ .../jetbrains/vision/DocumentStatsCache.kt | 107 ++++++++ .../src/main/resources/META-INF/plugin.xml | 102 +++++++ .../messages/CodeGraphBundle.properties | 4 + .../jetbrains/graph/GraphDataTest.kt | 117 ++++++++ .../jetbrains/indexing/IndexingServiceTest.kt | 62 +++++ .../server/CodeGraphServerResolverTest.kt | 172 ++++++++++++ .../jetbrains/server/CrashBreadcrumbsTest.kt | 125 +++++++++ .../server/RestartCircuitBreakerTest.kt | 102 +++++++ 40 files changed, 4216 insertions(+) create mode 100644 jetbrains/.gitignore create mode 100644 jetbrains/README.md create mode 100644 jetbrains/build.gradle.kts create mode 100644 jetbrains/gradle.properties create mode 100644 jetbrains/gradle/wrapper/gradle-wrapper.jar create mode 100644 jetbrains/gradle/wrapper/gradle-wrapper.properties create mode 100755 jetbrains/gradlew create mode 100644 jetbrains/gradlew.bat create mode 100644 jetbrains/scripts/engine_probe.py create mode 100644 jetbrains/settings.gradle.kts create mode 100644 jetbrains/src/main/kotlin/ai/codegraph/jetbrains/actions/EngineRoundTripAction.kt create mode 100644 jetbrains/src/main/kotlin/ai/codegraph/jetbrains/actions/ReindexWorkspaceAction.kt create mode 100644 jetbrains/src/main/kotlin/ai/codegraph/jetbrains/actions/ShowGraphAction.kt create mode 100644 jetbrains/src/main/kotlin/ai/codegraph/jetbrains/diagnostics/SelfCheckActivity.kt create mode 100644 jetbrains/src/main/kotlin/ai/codegraph/jetbrains/graph/GraphHtml.kt create mode 100644 jetbrains/src/main/kotlin/ai/codegraph/jetbrains/graph/GraphPanel.kt create mode 100644 jetbrains/src/main/kotlin/ai/codegraph/jetbrains/indexing/IndexingService.kt create mode 100644 jetbrains/src/main/kotlin/ai/codegraph/jetbrains/indexing/IndexingStartupActivity.kt create mode 100644 jetbrains/src/main/kotlin/ai/codegraph/jetbrains/lsp/CodeGraphClient.kt create mode 100644 jetbrains/src/main/kotlin/ai/codegraph/jetbrains/lsp/CodeGraphCommand.kt create mode 100644 jetbrains/src/main/kotlin/ai/codegraph/jetbrains/notify/CodeGraphNotifications.kt create mode 100644 jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphConnectionProvider.kt create mode 100644 jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphLanguageServerFactory.kt create mode 100644 jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphServerResolver.kt create mode 100644 jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CrashBreadcrumbs.kt create mode 100644 jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/EngineLifecycle.kt create mode 100644 jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/RestartCircuitBreaker.kt create mode 100644 jetbrains/src/main/kotlin/ai/codegraph/jetbrains/settings/CodeGraphConfigurable.kt create mode 100644 jetbrains/src/main/kotlin/ai/codegraph/jetbrains/settings/CodeGraphSettings.kt create mode 100644 jetbrains/src/main/kotlin/ai/codegraph/jetbrains/ui/CodeGraphStatusBarWidget.kt create mode 100644 jetbrains/src/main/kotlin/ai/codegraph/jetbrains/ui/SymbolsToolWindow.kt create mode 100644 jetbrains/src/main/kotlin/ai/codegraph/jetbrains/vision/CodeGraphCodeVisionProvider.kt create mode 100644 jetbrains/src/main/kotlin/ai/codegraph/jetbrains/vision/DocumentStatsCache.kt create mode 100644 jetbrains/src/main/resources/META-INF/plugin.xml create mode 100644 jetbrains/src/main/resources/messages/CodeGraphBundle.properties create mode 100644 jetbrains/src/test/kotlin/ai/codegraph/jetbrains/graph/GraphDataTest.kt create mode 100644 jetbrains/src/test/kotlin/ai/codegraph/jetbrains/indexing/IndexingServiceTest.kt create mode 100644 jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/CodeGraphServerResolverTest.kt create mode 100644 jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/CrashBreadcrumbsTest.kt create mode 100644 jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/RestartCircuitBreakerTest.kt 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..12965d7 --- /dev/null +++ b/jetbrains/README.md @@ -0,0 +1,187 @@ + + +# 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. +The VSIX can, because VS Code ships per-platform artifacts; the JetBrains +Marketplace has no equivalent, so bundling all four platforms would mean a +~120 MB download for every user regardless of platform. + +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 + +Until per-platform binaries are published as release assets, there is no +one-click install. +The engine ships bundled inside the npm package and the VSIX, so users install +it with: + +```sh +npm i -g @astudioplus/codegraph-mcp +``` + +That puts `codegraph-server` where step 3 finds it. +A checksum-verifying downloader for step 4 was written and then removed: the +release assets it would fetch do not exist yet, and shipping code that cannot +run is worse than not shipping it. +Publishing those assets is tracked separately; the `MANAGED_INSTALL` slot in the +resolver is reserved for it. + +## 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. + +## 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 known engine deviations are recorded in the probe rather than hidden by it: +`codegraph.getDocumentCodeLens` is dispatched but not advertised, and the engine +ignores the LSP `exit` notification, terminating only when stdin closes. +Both are masked by the clients today and are tracked as engine fixes. diff --git a/jetbrains/build.gradle.kts b/jetbrains/build.gradle.kts new file mode 100644 index 0000000..1927684 --- /dev/null +++ b/jetbrains/build.gradle.kts @@ -0,0 +1,103 @@ +// 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 the target IDE actually bundles (2.1 for + // 243) so nothing links against stdlib symbols the IDE lacks. + apiVersion = KotlinVersion.KOTLIN_2_1 + languageVersion = KotlinVersion.KOTLIN_2_1 + 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 { + recommended() + } + } +} + +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..89f148f --- /dev/null +++ b/jetbrains/gradle.properties @@ -0,0 +1,23 @@ +# Copyright 2026 Andrey Vasilevsky +# SPDX-License-Identifier: Apache-2.0 + +# Keep in sync with vscode/package.json `version` - both clients ship against +# the same codegraph-server protocol surface. +pluginVersion=0.19.1 + +# 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 0000000000000000000000000000000000000000..b1b8ef56b44f16b14dc800fa8103a6d89abb526f GIT binary patch literal 48462 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 R2np~f1o-$>Q}y+){|A@R9n$~+ literal 0 HcmV?d00001 diff --git a/jetbrains/gradle/wrapper/gradle-wrapper.properties b/jetbrains/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a9db115 --- /dev/null +++ b/jetbrains/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +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..26622bc --- /dev/null +++ b/jetbrains/scripts/engine_probe.py @@ -0,0 +1,251 @@ +#!/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 = { + # Dispatched at backend.rs, absent from executeCommandProvider.commands. + # VS Code reaches it through the custom-request form so it never noticed. + "codegraph.getDocumentCodeLens": "not yet advertised; tracked as a server fix", +} + +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, "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. Call it directly rather +# than trusting the advertised list, because that list is currently incomplete. +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", {}) +await_response(rid, timeout=30) +send("exit", {}, notify=True) + +# The engine currently ignores `exit` and terminates only when stdin closes. +# Both real clients force-kill the process, so this costs correctness rather +# than leaked processes - but the probe must not hang on it, and should say so +# out loud if it ever starts behaving. +EXIT_GRACE_SECONDS = 5 +try: + proc.wait(timeout=EXIT_GRACE_SECONDS) + print(f"NOTE engine honoured `exit` within {EXIT_GRACE_SECONDS}s") +except subprocess.TimeoutExpired: + print(f"NOTE engine ignored `exit` (known deviation); closing stdin instead") + 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/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..b43b6c2 --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/actions/ShowGraphAction.kt @@ -0,0 +1,67 @@ +// 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 panel = GraphPanel(project, kind) + val label = "${kind.title}: ${file.name}" + + val content = ContentFactory.getInstance().createContent(panel, label, true).apply { + isCloseable = true + setDisposer(panel) + } + Disposer.register(toolWindow.disposable, panel) + + toolWindow.contentManager.addContent(content) + toolWindow.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..e1e99da --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/diagnostics/SelfCheckActivity.kt @@ -0,0 +1,146 @@ +// 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.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) + } + } + + // 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..ab23e38 --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/graph/GraphHtml.kt @@ -0,0 +1,189 @@ +// 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" + + fun render(graph: GraphData, title: String): String { + val payload = gson.toJson( + mapOf( + "nodes" to graph.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 graph.edges.map { mapOf("from" to it.from, "to" to it.to) }, + ), + ) + + // 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 + + + + + + + + + """.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..6f9bcea --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/graph/GraphPanel.kt @@ -0,0 +1,152 @@ +// 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, private val kind: GraphKind) : + JPanel(BorderLayout()), + Disposable { + + 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) { + 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..3dde6a7 --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/indexing/IndexingService.kt @@ -0,0 +1,130 @@ +// 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 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 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. + */ + fun isIndexed(timeoutSeconds: Long = QUERY_TIMEOUT_SECONDS): Boolean = + runCatching { + val response = CodeGraphClient.getInstance(project) + .execute(CodeGraphCommand.SYMBOL_SEARCH, mapOf("query" to "*", "limit" to 1)) + .get(timeoutSeconds, TimeUnit.SECONDS) + 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 outcome = runCatching { + CodeGraphClient.getInstance(project) + .execute(CodeGraphCommand.REINDEX_WORKSPACE, emptyMap()) + .get(REINDEX_TIMEOUT_MINUTES, TimeUnit.MINUTES) + } + outcome.fold( + onSuccess = { response -> reportSuccess(filesIndexed(response)) }, + onFailure = { error -> + LOG.warn("CodeGraph reindex failed", error) + 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) { + 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..8bc8dab --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/indexing/IndexingStartupActivity.kt @@ -0,0 +1,93 @@ +// 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.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 + + if (CodeGraphServerResolver.resolve(project.basePath, settings.serverPath) == null) { + // No one-click install yet: the engine is only distributed bundled + // inside the npm package and the VSIX, and the JetBrains + // Marketplace ships a single artifact for every platform so the + // plugin cannot carry a ~120 MB binary set of its own. + CodeGraphNotifications.warn( + project, + "The CodeGraph engine is not installed. Install it with " + + "npm i -g @astudioplus/codegraph-mcp, then reopen this project, " + + "or point CodeGraph at an existing engine in Settings | Tools | CodeGraph.", + ) + return + } + + 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() + }, + ) + } + + 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..a8cf1e2 --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/lsp/CodeGraphClient.kt @@ -0,0 +1,120 @@ +// 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) + } + + /** + * 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 LanguageServerManager.getInstance(project) + .getLanguageServer(CODEGRAPH_SERVER_ID) + .thenCompose { server -> + if (server == null) { + CompletableFuture.failedFuture(EngineUnavailableException(command)) + } else { + server.workspaceService.executeCommand(params) + } + } + .thenApply { raw -> raw?.let { toJson(it) } } + .whenComplete { _, error -> + if (error != null) LOG.warn("CodeGraph command ${command.id} failed", error) + } + } + + /** 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() + + 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/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..aa7bee9 --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphConnectionProvider.kt @@ -0,0 +1,109 @@ +// 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 @codegraph-ai/codegraph`, " + + "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) + if (settings.embeddingModel == "static") { + withEnvironment("CODEGRAPH_STATIC_MODEL", staticModelDir(settings.staticModelPath).toString()) + } + } + 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". The engine currently only uses it as the gate that + * enables `embeddingModel` and `fullBodyEmbedding`, so it must be present or + * full-body embeddings silently turn off. We pass a stable per-client + * directory; see the follow-up to un-gate those settings server-side. + */ + 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 fun staticModelDir(override: String): Path = + if (override.isNotBlank()) { + Paths.get(override) + } else { + Paths.get(System.getProperty("user.home"), ".codegraph", "static_models", "jina-code-static-256") + } + + 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..0e007ec --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphLanguageServerFactory.kt @@ -0,0 +1,31 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +package ai.codegraph.jetbrains.server + +import com.intellij.openapi.project.Project +import com.redhat.devtools.lsp4ij.LanguageServerFactory +import com.redhat.devtools.lsp4ij.client.LanguageClientImpl +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) +} + +/** + * 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..6f0a8ac --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphServerResolver.kt @@ -0,0 +1,211 @@ +// 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, matching the names published in releases. */ + fun platformBinaryName(env: ResolverEnvironment = ResolverEnvironment.fromSystem()): String { + val os = env.osName.lowercase() + val arch = env.osArch.lowercase() + return when { + os.contains("win") -> "codegraph-server-win32-x64.exe" + os.contains("mac") || os.contains("darwin") -> + if (arch == "aarch64" || arch == "arm64") { + "codegraph-server-darwin-arm64" + } else { + "codegraph-server-darwin-x64" + } + os.contains("linux") -> "codegraph-server-linux-x64" + else -> 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 = + Files.isRegularFile(managedInstallDir(env).resolve(platformBinaryName(env))) + + /** + * 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) + } + + managedInstallDir(env).resolve(platformBinaryName(env)) + .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 + } +} 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..dd6bf1e --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CrashBreadcrumbs.kt @@ -0,0 +1,115 @@ +// 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, +) { + + /** + * Classify the most recent crash and delete every breadcrumb, so a stale + * file can never be read as a diagnosis of some later crash. + */ + fun readAndClear(): CrashDiagnosis { + val files = runCatching { Files.list(directory).use { it.toList() } }.getOrNull() + ?: return CrashDiagnosis(CrashDiagnosis.HARD_CRASH) + + val cause = pickFresh(files, CRASH_PATTERN)?.let { crumb -> + when { + crumb["kind"] == "signal" -> CrashDiagnosis.SIGNAL + crumb["kind"] == "panic" -> crumb["class"] + else -> null + } + } ?: CrashDiagnosis.HARD_CRASH + + val phase = pickFresh(files, PHASE_PATTERN)?.get("phase") + + files.filter { CRASH_PATTERN.matches(it.fileName.toString()) || PHASE_PATTERN.matches(it.fileName.toString()) } + .forEach { runCatching { Files.deleteIfExists(it) } } + + return CrashDiagnosis(cause, phase) + } + + /** + * 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. + */ + private fun pickFresh(files: List, pattern: Regex): Map? { + 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 + + return 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) }.getOrNull() + } + + 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/EngineLifecycle.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/EngineLifecycle.kt new file mode 100644 index 0000000..f6c1790 --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/EngineLifecycle.kt @@ -0,0 +1,127 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +package ai.codegraph.jetbrains.server + +import ai.codegraph.jetbrains.notify.CodeGraphNotifications +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 + } + + /** 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() { + 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})") + + 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..7833067 --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/settings/CodeGraphSettings.kt @@ -0,0 +1,93 @@ +// 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 + + @JvmField var telemetryEnabled: Boolean = true + @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/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/SymbolsToolWindow.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/ui/SymbolsToolWindow.kt new file mode 100644 index 0000000..ed7a5c3 --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/ui/SymbolsToolWindow.kt @@ -0,0 +1,215 @@ +// 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 panel = SymbolsPanel(project) + val content = ContentFactory.getInstance().createContent(panel, "Symbols", false) + Disposer.register(toolWindow.disposable, panel) + toolWindow.contentManager.addContent(content) + panel.refresh() + } +} + +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 + val file = VirtualFileManager.getInstance().findFileByUrl(symbol.uri) + ?: VirtualFileManager.getInstance().findFileByNioPath( + java.nio.file.Paths.get(java.net.URI.create(symbol.uri)), + ) + ?: 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..23a1e9d --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/vision/CodeGraphCodeVisionProvider.kt @@ -0,0 +1,105 @@ +// 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.editor.Editor +import com.intellij.openapi.util.TextRange +import com.intellij.psi.PsiFile + +/** + * 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. + */ + 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, + { _, _ -> }, + null, + parts.joinToString(", "), + tooltipFor(symbol), + emptyList(), + ) + } + + 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" + + /** + * 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..19b9a7b --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/vision/DocumentStatsCache.kt @@ -0,0 +1,107 @@ +// 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.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() + } + + 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?.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/resources/META-INF/plugin.xml b/jetbrains/src/main/resources/META-INF/plugin.xml new file mode 100644 index 0000000..4dc2d12 --- /dev/null +++ b/jetbrains/src/main/resources/META-INF/plugin.xml @@ -0,0 +1,102 @@ + + + + + + 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. +

+ ]]>
+ + com.intellij.modules.platform + + com.redhat.devtools.lsp4ij + + + + + + + + + + + + + + + + + + + codegraph-server), resolved from an + existing install or downloaded on first use. + ]]> + + + + + + + + + + + + + + + + + + + + + + + +
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..e3736ed --- /dev/null +++ b/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/graph/GraphDataTest.kt @@ -0,0 +1,117 @@ +// 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/server/CodeGraphServerResolverTest.kt b/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/CodeGraphServerResolverTest.kt new file mode 100644 index 0000000..6c95bd2 --- /dev/null +++ b/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/CodeGraphServerResolverTest.kt @@ -0,0 +1,172 @@ +// 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"), + ) + } + } + + 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..d4f8970 --- /dev/null +++ b/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/CrashBreadcrumbsTest.kt @@ -0,0 +1,125 @@ +// 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 + + @Before + fun setUp() { + dir = Files.createTempDirectory("codegraph-breadcrumbs-test") + } + + @After + fun tearDown() { + dir.toFile().deleteRecursively() + } + + private fun breadcrumbs() = CrashBreadcrumbs(directory = dir, clock = { now }) + + 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 `every breadcrumb is 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 `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) + } +} 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()) + } +} From d4ec3d65fdc8cc9f2cabb1310758e3aedd26cb3b Mon Sep 17 00:00:00 2001 From: Andrey Vasilevsky Date: Sat, 25 Jul 2026 22:16:21 -0700 Subject: [PATCH 07/31] feat(jetbrains): add Memories tool window Second tab on the existing CodeGraph tool window rather than its own sidebar slot: memories are the same graph seen from a different angle, and two CodeGraph icons would be two things to learn. Lists via codegraph.memoryList and switches to codegraph.memorySearch once a query is typed, since those are different commands with different response shapes. Both take currentOnly, so invalidated entries are filtered by the engine rather than re-filtered client-side. Invalidated memories render greyed rather than hidden when shown - silently drawing them as current would be worse than showing they exist. Verified against a seeded store: memoryList returns the entries, the tool window reports two tabs, and no exceptions reach the log. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013iTPP3eSpGSgWN6c5w4Fm1 --- .../diagnostics/SelfCheckActivity.kt | 7 + .../jetbrains/ui/MemoriesToolWindow.kt | 243 ++++++++++++++++++ .../jetbrains/ui/SymbolsToolWindow.kt | 20 +- 3 files changed, 265 insertions(+), 5 deletions(-) create mode 100644 jetbrains/src/main/kotlin/ai/codegraph/jetbrains/ui/MemoriesToolWindow.kt diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/diagnostics/SelfCheckActivity.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/diagnostics/SelfCheckActivity.kt index e1e99da..85e0f61 100644 --- a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/diagnostics/SelfCheckActivity.kt +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/diagnostics/SelfCheckActivity.kt @@ -68,6 +68,13 @@ class SelfCheckActivity : ProjectActivity { } } + runCheck("memoryList") { + client.execute( + CodeGraphCommand.MEMORY_LIST, + mapOf("currentOnly" to true, "limit" to 5), + ).get(COMMAND_TIMEOUT_SECONDS, TimeUnit.SECONDS) + } + // JCEF availability is a property of the running JBR, not of the build, // so it can only be answered here. runCheck("graphPanel") { 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..ae343d1 --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/ui/MemoriesToolWindow.kt @@ -0,0 +1,243 @@ +// 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.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DefaultActionGroup +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.ToggleActionButton +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() + }, + object : ToggleActionButton("Show Invalidated", AllIcons.Actions.Show) { + 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 index ed7a5c3..9b5b330 100644 --- a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/ui/SymbolsToolWindow.kt +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/ui/SymbolsToolWindow.kt @@ -63,11 +63,21 @@ private data class WorkspaceSymbolsResponse(val symbols: List = empt class SymbolsToolWindowFactory : ToolWindowFactory, DumbAware { override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { - val panel = SymbolsPanel(project) - val content = ContentFactory.getInstance().createContent(panel, "Symbols", false) - Disposer.register(toolWindow.disposable, panel) - toolWindow.contentManager.addContent(content) - panel.refresh() + 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() } } From 4a232bf1e50ba4f085b6808c2d41d8958fc3db9e Mon Sep 17 00:00:00 2001 From: Andrey Vasilevsky Date: Sat, 25 Jul 2026 22:26:37 -0700 Subject: [PATCH 08/31] feat(jetbrains): register the engine with AI tooling over MCP The VS Code client declares 28 languageModelTools. Those are Copilot-specific, and porting them would mean a second hand-written tool list drifting against the engine. Pointing the AI tooling at the engine's own MCP mode instead keeps the tool surface correct for free - verified end to end, the registered command answers an MCP initialize and lists 42 tools. Writes /.mcp.json in the mcpServers shape that Junie, Claude Code, Cursor and the AI Assistant MCP settings all read, and offers the same config on the clipboard, since every AI client stores MCP configuration somewhere different and pasting always works. Merges rather than overwrites: a project may already point at other MCP servers, and silently dropping them to add ourselves would be a hostile way to install a feature. Tests cover that case specifically - it is the failure that is invisible until some unrelated AI tool stops working. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013iTPP3eSpGSgWN6c5w4Fm1 --- jetbrains/README.md | 31 ++++ .../jetbrains/actions/RegisterMcpAction.kt | 69 +++++++++ .../diagnostics/SelfCheckActivity.kt | 5 + .../jetbrains/mcp/McpRegistration.kt | 127 ++++++++++++++++ .../src/main/resources/META-INF/plugin.xml | 5 + .../jetbrains/mcp/McpRegistrationTest.kt | 135 ++++++++++++++++++ 6 files changed, 372 insertions(+) create mode 100644 jetbrains/src/main/kotlin/ai/codegraph/jetbrains/actions/RegisterMcpAction.kt create mode 100644 jetbrains/src/main/kotlin/ai/codegraph/jetbrains/mcp/McpRegistration.kt create mode 100644 jetbrains/src/test/kotlin/ai/codegraph/jetbrains/mcp/McpRegistrationTest.kt diff --git a/jetbrains/README.md b/jetbrains/README.md index 12965d7..7862fc3 100644 --- a/jetbrains/README.md +++ b/jetbrains/README.md @@ -93,6 +93,37 @@ One caveat worth knowing when calling the engine directly: 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: 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..63180d1 --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/actions/RegisterMcpAction.kt @@ -0,0 +1,69 @@ +// 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 "" + CodeGraphNotifications.infoWithActions( + project, + "CodeGraph is registered as an MCP server in ${result.path.fileName}$note. " + + "Restart your AI client to pick it up.", + "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/diagnostics/SelfCheckActivity.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/diagnostics/SelfCheckActivity.kt index 85e0f61..bb67292 100644 --- a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/diagnostics/SelfCheckActivity.kt +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/diagnostics/SelfCheckActivity.kt @@ -6,6 +6,7 @@ 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 @@ -75,6 +76,10 @@ class SelfCheckActivity : ProjectActivity { ).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") { 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..eaadd8f --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/mcp/McpRegistration.kt @@ -0,0 +1,127 @@ +// 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 + +/** + * 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() + + sealed interface Result { + data class Written(val path: Path, val merged: Boolean) : 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 existing = readConfig(configPath) + 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) + } 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) + } + + /** + * A malformed or absent file both yield an empty object: refusing to write + * because the existing JSON is broken would leave the user stuck with no way + * forward from inside the IDE. + */ + private fun readConfig(path: Path): JsonObject { + if (!Files.exists(path)) return JsonObject() + return runCatching { + JsonParser.parseString(Files.readString(path)).asJsonObject + }.getOrElse { JsonObject() } + } + + 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/resources/META-INF/plugin.xml b/jetbrains/src/main/resources/META-INF/plugin.xml index 4dc2d12..4357a6a 100644 --- a/jetbrains/src/main/resources/META-INF/plugin.xml +++ b/jetbrains/src/main/resources/META-INF/plugin.xml @@ -91,6 +91,11 @@ text="Reindex Workspace" description="Rebuild the CodeGraph graph for this workspace from scratch."/> + + +// 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!!)) + 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 = "" + projectDir.toFile().deleteRecursively() + } finally { + super.tearDown() + } + } + + private fun configFile(): Path = Path.of(project.basePath!!, McpRegistration.CONFIG_FILE) + + 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 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) + } + } +} From bd9eb26efe46e7c46b64e733b041d94ba13665bf Mon Sep 17 00:00:00 2001 From: Andrey Vasilevsky Date: Sat, 25 Jul 2026 23:44:15 -0700 Subject: [PATCH 09/31] feat(jetbrains): telemetry parity, marketplace metadata, startup race fix Telemetry sends the same event and property names as the VS Code client so both editors land in one funnel, adding only ide/ideProduct/ideBuild so a dashboard can split by editor without a second schema. The gate is a pure function with its own tests. A mistake there means measuring someone who declined, which nothing downstream can detect or undo, so every refusal path is asserted individually rather than trusting one happy path. The IDE's own statistics consent is a hard gate the plugin setting can narrow but never widen, and a build with no compiled-in key cannot send at all - so builds from source and forks are silent with no setting to remember. Unknown values are dropped rather than sent as "unknown": a placeholder string looks like a real value in a dashboard and inflates whatever bucket it lands in. Also fixes a pre-existing startup race, found by a self-check run that failed where earlier runs had passed. start() is asynchronous, so a command issued straight after it could get a null server back and fail fast. That is "not up yet", not "cannot run" - and it fails the first command of a session, which is the one a user is most likely to notice, such as a reindex they just asked for. The plugin configuration verifier caught Kotlin apiVersion set to 2.1 while since-build 243 only guarantees 2.0; the mismatch would surface as a NoSuchMethodError on a 2024.3 user's machine rather than at build time. Verifier IDE selection narrowed from recommended() to the development platform: each recommended release is a ~3 GB download, which is a lot of someone else's disk to consume by default. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013iTPP3eSpGSgWN6c5w4Fm1 --- jetbrains/build.gradle.kts | 49 ++++- .../jetbrains/indexing/IndexingService.kt | 16 +- .../jetbrains/lsp/CodeGraphClient.kt | 42 +++- .../jetbrains/server/EngineLifecycle.kt | 11 ++ .../jetbrains/telemetry/TelemetryConfig.kt | 32 ++++ .../jetbrains/telemetry/TelemetryGate.kt | 57 ++++++ .../jetbrains/telemetry/TelemetryReporter.kt | 179 ++++++++++++++++++ .../src/main/resources/META-INF/plugin.xml | 20 ++ .../jetbrains/telemetry/TelemetryGateTest.kt | 94 +++++++++ 9 files changed, 485 insertions(+), 15 deletions(-) create mode 100644 jetbrains/src/main/kotlin/ai/codegraph/jetbrains/telemetry/TelemetryConfig.kt create mode 100644 jetbrains/src/main/kotlin/ai/codegraph/jetbrains/telemetry/TelemetryGate.kt create mode 100644 jetbrains/src/main/kotlin/ai/codegraph/jetbrains/telemetry/TelemetryReporter.kt create mode 100644 jetbrains/src/test/kotlin/ai/codegraph/jetbrains/telemetry/TelemetryGateTest.kt diff --git a/jetbrains/build.gradle.kts b/jetbrains/build.gradle.kts index 1927684..8094ece 100644 --- a/jetbrains/build.gradle.kts +++ b/jetbrains/build.gradle.kts @@ -1,6 +1,7 @@ // Copyright 2026 Andrey Vasilevsky // SPDX-License-Identifier: Apache-2.0 +import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType import org.jetbrains.intellij.platform.gradle.TestFrameworkType import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.KotlinVersion @@ -45,10 +46,14 @@ kotlin { jvmToolchain(21) compilerOptions { jvmTarget = JvmTarget.JVM_21 - // Compile against the Kotlin the target IDE actually bundles (2.1 for - // 243) so nothing links against stdlib symbols the IDE lacks. - apiVersion = KotlinVersion.KOTLIN_2_1 - languageVersion = KotlinVersion.KOTLIN_2_1 + // 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") } } @@ -72,11 +77,45 @@ intellijPlatform { pluginVerification { ides { - recommended() + // Only the development platform by default. `recommended()` pulls a + // full IDE distribution per recommended release - roughly 3 GB each + // - which is a surprising amount of disk to consume on someone + // else's machine. Widen this deliberately before a release, on a + // machine with room for it. + select { + types = listOf(IntelliJPlatformType.IntellijIdeaCommunity) + sinceBuild = providers.gradleProperty("pluginSinceBuild") + untilBuild = providers.gradleProperty("pluginSinceBuild") + } } } } +/** + * 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 by tasks.registering { + 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 diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/indexing/IndexingService.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/indexing/IndexingService.kt index 3dde6a7..ae278f3 100644 --- a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/indexing/IndexingService.kt +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/indexing/IndexingService.kt @@ -6,6 +6,7 @@ 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 com.google.gson.JsonElement import com.intellij.openapi.components.Service import com.intellij.openapi.components.service @@ -56,15 +57,28 @@ class IndexingService(private val project: Project) { 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 -> reportSuccess(filesIndexed(response)) }, + 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}", diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/lsp/CodeGraphClient.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/lsp/CodeGraphClient.kt index a8cf1e2..f279c81 100644 --- a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/lsp/CodeGraphClient.kt +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/lsp/CodeGraphClient.kt @@ -82,21 +82,45 @@ class CodeGraphClient(private val project: Project) { command.id, if (arguments == null) emptyList() else listOf(arguments), ) - return LanguageServerManager.getInstance(project) - .getLanguageServer(CODEGRAPH_SERVER_ID) - .thenCompose { server -> - if (server == null) { - CompletableFuture.failedFuture(EngineUnavailableException(command)) - } else { - server.workspaceService.executeCommand(params) - } - } + 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) } } diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/EngineLifecycle.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/EngineLifecycle.kt index f6c1790..c74f4c6 100644 --- a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/EngineLifecycle.kt +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/EngineLifecycle.kt @@ -4,6 +4,7 @@ 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 @@ -48,6 +49,7 @@ class EngineLifecycle(private val project: Project) { 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. */ @@ -93,6 +95,15 @@ class EngineLifecycle(private val project: Project) { 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() 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..e0b7a00 --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/telemetry/TelemetryGate.kt @@ -0,0 +1,57 @@ +// 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 ideConsent the IDE-level "send usage statistics" consent. The + * plugin's own switch can only ever narrow this, never widen it. + * @param pluginEnabled the plugin's `telemetry.enabled` setting. + * @param errorReportsOnly the plugin's `telemetry.errorReportsOnly` setting. + * @param isErrorEvent whether the event being considered reports a failure. + */ + fun allows( + hasKey: Boolean, + ideConsent: Boolean, + pluginEnabled: Boolean, + errorReportsOnly: Boolean, + isErrorEvent: Boolean, + ): Boolean = when { + !hasKey -> false + !ideConsent -> 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..6675b53 --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/telemetry/TelemetryReporter.kt @@ -0,0 +1,179 @@ +// 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.internal.statistic.utils.StatisticsUploadAssistant +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.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 +import java.util.concurrent.TimeUnit + +/** + * 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) { + + 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, + ideConsent = ideConsent(), + 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) } + } + + /** + * The IDE-level statistics consent. A user who turned JetBrains' own usage + * reporting off has already answered this question, and the plugin has no + * business asking again with a different default. + */ + private fun ideConsent(): Boolean = + runCatching { StatisticsUploadAssistant.isSendAllowed() }.getOrDefault(false) + + 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) + } + } + + fun shutdown() { + sender.shutdown() + runCatching { sender.awaitTermination(SHUTDOWN_WAIT_SECONDS, TimeUnit.SECONDS) } + } + + companion object { + private val LOG = logger() + private const val PLUGIN_ID = "ai.codegraph.jetbrains" + private const val TIMEOUT_MS = 5_000 + private const val SHUTDOWN_WAIT_SECONDS = 2L + + fun getInstance(project: Project): TelemetryReporter = project.service() + } +} diff --git a/jetbrains/src/main/resources/META-INF/plugin.xml b/jetbrains/src/main/resources/META-INF/plugin.xml index 4357a6a..e7382b6 100644 --- a/jetbrains/src/main/resources/META-INF/plugin.xml +++ b/jetbrains/src/main/resources/META-INF/plugin.xml @@ -13,6 +13,26 @@ across 40+ languages, then exposes it as call graphs, dependency graphs, impact analysis, related-test discovery and semantic symbol search.

+

Requirements

+

+ This plugin needs the CodeGraph engine installed separately: + npm i -g @astudioplus/codegraph-mcp. 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 unless the IDE's own "Send usage statistics" consent is + enabled, and can be turned off independently in + Settings | Tools | CodeGraph, where you can also limit it to error + reports only. +

]]> com.intellij.modules.platform 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..e924e34 --- /dev/null +++ b/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/telemetry/TelemetryGateTest.kt @@ -0,0 +1,94 @@ +// 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, + ideConsent: Boolean = true, + pluginEnabled: Boolean = true, + errorReportsOnly: Boolean = false, + isErrorEvent: Boolean = false, + ) = TelemetryGate.allows(hasKey, ideConsent, 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, ideConsent = true, pluginEnabled = true)) + } + + @Test + fun `IDE-level refusal cannot be overridden by the plugin setting`() { + // The user already answered this question for the whole IDE. The plugin + // switch may narrow that answer, never widen it. + assertFalse(allows(ideConsent = false)) + assertFalse(allows(ideConsent = false, pluginEnabled = true)) + assertFalse(allows(ideConsent = false, isErrorEvent = 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, ideConsent = 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()) + } +} From f99788c3d207b76b1d3dbb4ed96aff781438f237 Mon Sep 17 00:00:00 2001 From: Andrey Vasilevsky Date: Sun, 26 Jul 2026 12:46:46 -0700 Subject: [PATCH 10/31] fix(jetbrains): drop internal API and deprecated class flagged by the verifier The plugin verifier now runs against both ends of the supported range and reports Compatible for IC-243 and IC-252. Getting there required two real fixes. Telemetry no longer consults StatisticsUploadAssistant.isSendAllowed(). That is @ApiStatus.Internal - not an API plugins may call, and one that can change without notice. There is no public equivalent, because the platform does not offer third-party plugins a statistics-consent signal to honour at all. So the setting is now opt-in rather than default-on. The VS Code client can default to on because VS Code exposes env.isTelemetryEnabled, a platform consent it honours; with no such signal here, defaulting to on would mean collecting from people who never agreed to anything. The marketplace disclosure and the gate's own documentation say so plainly. ToggleActionButton is deprecated and scheduled for removal, which matters more than usual because until-build is unbounded - a removal would break the Memories toolbar on IDEs this plugin claims to support. Replaced with ToggleAction. Verifier IDE selection is current() + latest{} rather than pinned versions: a pinned "newest" stops being newest without anyone noticing, which is exactly the break it exists to catch. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013iTPP3eSpGSgWN6c5w4Fm1 --- jetbrains/build.gradle.kts | 22 +++++++++---------- .../jetbrains/settings/CodeGraphSettings.kt | 7 +++++- .../jetbrains/telemetry/TelemetryGate.kt | 13 ++++++----- .../jetbrains/telemetry/TelemetryReporter.kt | 10 --------- .../jetbrains/ui/MemoriesToolWindow.kt | 13 +++++++---- .../src/main/resources/META-INF/plugin.xml | 9 ++++---- .../jetbrains/telemetry/TelemetryGateTest.kt | 15 ++----------- 7 files changed, 39 insertions(+), 50 deletions(-) diff --git a/jetbrains/build.gradle.kts b/jetbrains/build.gradle.kts index 8094ece..0a1bb79 100644 --- a/jetbrains/build.gradle.kts +++ b/jetbrains/build.gradle.kts @@ -1,7 +1,6 @@ // Copyright 2026 Andrey Vasilevsky // SPDX-License-Identifier: Apache-2.0 -import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType import org.jetbrains.intellij.platform.gradle.TestFrameworkType import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.KotlinVersion @@ -77,16 +76,15 @@ intellijPlatform { pluginVerification { ides { - // Only the development platform by default. `recommended()` pulls a - // full IDE distribution per recommended release - roughly 3 GB each - // - which is a surprising amount of disk to consume on someone - // else's machine. Widen this deliberately before a release, on a - // machine with room for it. - select { - types = listOf(IntelliJPlatformType.IntellijIdeaCommunity) - sinceBuild = providers.gradleProperty("pluginSinceBuild") - untilBuild = providers.gradleProperty("pluginSinceBuild") - } + // 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 {} } } } @@ -96,7 +94,7 @@ intellijPlatform { * Absent by default, so builds from source report nothing - matching how the * VS Code client injects `__POSTHOG_KEY__` at bundle time. */ -val generateTelemetryConfig by tasks.registering { +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("") diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/settings/CodeGraphSettings.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/settings/CodeGraphSettings.kt index 7833067..2ce5e26 100644 --- a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/settings/CodeGraphSettings.kt +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/settings/CodeGraphSettings.kt @@ -73,7 +73,12 @@ class CodeGraphSettings : PersistentStateComponent { @JvmField var codeLensEnabled: Boolean = true @JvmField var hoverEnabled: Boolean = true - @JvmField var telemetryEnabled: 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 diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/telemetry/TelemetryGate.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/telemetry/TelemetryGate.kt index e0b7a00..947997e 100644 --- a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/telemetry/TelemetryGate.kt +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/telemetry/TelemetryGate.kt @@ -19,21 +19,24 @@ 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 ideConsent the IDE-level "send usage statistics" consent. The - * plugin's own switch can only ever narrow this, never widen it. - * @param pluginEnabled the plugin's `telemetry.enabled` setting. + * @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, - ideConsent: Boolean, pluginEnabled: Boolean, errorReportsOnly: Boolean, isErrorEvent: Boolean, ): Boolean = when { !hasKey -> false - !ideConsent -> false !pluginEnabled -> false errorReportsOnly && !isErrorEvent -> false else -> true diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/telemetry/TelemetryReporter.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/telemetry/TelemetryReporter.kt index 6675b53..c868f21 100644 --- a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/telemetry/TelemetryReporter.kt +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/telemetry/TelemetryReporter.kt @@ -6,7 +6,6 @@ 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.internal.statistic.utils.StatisticsUploadAssistant import com.intellij.openapi.application.ApplicationInfo import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.Service @@ -108,7 +107,6 @@ class TelemetryReporter(private val project: Project) { val allowed = TelemetryGate.allows( hasKey = TelemetryConfig.hasKey, - ideConsent = ideConsent(), pluginEnabled = settings.telemetryEnabled, errorReportsOnly = settings.telemetryErrorReportsOnly, isErrorEvent = isError, @@ -121,14 +119,6 @@ class TelemetryReporter(private val project: Project) { sender.execute { post(event, payload) } } - /** - * The IDE-level statistics consent. A user who turned JetBrains' own usage - * reporting off has already answered this question, and the plugin has no - * business asking again with a different default. - */ - private fun ideConsent(): Boolean = - runCatching { StatisticsUploadAssistant.isSendAllowed() }.getOrDefault(false) - private fun pluginVersion(): String = runCatching { com.intellij.ide.plugins.PluginManagerCore diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/ui/MemoriesToolWindow.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/ui/MemoriesToolWindow.kt index ae343d1..7062fc0 100644 --- a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/ui/MemoriesToolWindow.kt +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/ui/MemoriesToolWindow.kt @@ -9,16 +9,17 @@ 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.ToggleActionButton import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBList import com.intellij.ui.components.JBScrollPane @@ -110,9 +111,13 @@ class MemoriesPanel(private val project: Project) : JPanel(BorderLayout()), com. object : AnAction("Refresh", "Reload memories", AllIcons.Actions.Refresh), DumbAware { override fun actionPerformed(e: AnActionEvent) = reload() }, - object : ToggleActionButton("Show Invalidated", AllIcons.Actions.Show) { - override fun isSelected(e: AnActionEvent?) = showInvalidated - override fun setSelected(e: AnActionEvent?, state: Boolean) { + // 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() } diff --git a/jetbrains/src/main/resources/META-INF/plugin.xml b/jetbrains/src/main/resources/META-INF/plugin.xml index e7382b6..92710d8 100644 --- a/jetbrains/src/main/resources/META-INF/plugin.xml +++ b/jetbrains/src/main/resources/META-INF/plugin.xml @@ -28,10 +28,9 @@ code, file names, file paths, symbol names, or search queries.

- Reporting is off unless the IDE's own "Send usage statistics" consent is - enabled, and can be turned off independently in - Settings | Tools | CodeGraph, where you can also limit it to error - reports only. + 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.

]]> @@ -74,7 +73,7 @@ factoryClass="ai.codegraph.jetbrains.server.CodeGraphLanguageServerFactory"> codegraph-server), resolved from an - existing install or downloaded on first use. + existing install on PATH or from the path set in Settings. ]]> diff --git a/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/telemetry/TelemetryGateTest.kt b/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/telemetry/TelemetryGateTest.kt index e924e34..28fee8c 100644 --- a/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/telemetry/TelemetryGateTest.kt +++ b/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/telemetry/TelemetryGateTest.kt @@ -19,11 +19,10 @@ class TelemetryGateTest { private fun allows( hasKey: Boolean = true, - ideConsent: Boolean = true, pluginEnabled: Boolean = true, errorReportsOnly: Boolean = false, isErrorEvent: Boolean = false, - ) = TelemetryGate.allows(hasKey, ideConsent, pluginEnabled, errorReportsOnly, isErrorEvent) + ) = TelemetryGate.allows(hasKey, pluginEnabled, errorReportsOnly, isErrorEvent) @Test fun `sends when every gate is open`() { @@ -36,16 +35,7 @@ class TelemetryGateTest { // remember a setting. assertFalse(allows(hasKey = false)) assertFalse(allows(hasKey = false, isErrorEvent = true)) - assertFalse(allows(hasKey = false, ideConsent = true, pluginEnabled = true)) - } - - @Test - fun `IDE-level refusal cannot be overridden by the plugin setting`() { - // The user already answered this question for the whole IDE. The plugin - // switch may narrow that answer, never widen it. - assertFalse(allows(ideConsent = false)) - assertFalse(allows(ideConsent = false, pluginEnabled = true)) - assertFalse(allows(ideConsent = false, isErrorEvent = true)) + assertFalse(allows(hasKey = false, pluginEnabled = true)) } @Test @@ -64,7 +54,6 @@ class TelemetryGateTest { 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, ideConsent = false)) assertFalse(allows(isErrorEvent = true, pluginEnabled = false)) } From 876b0e9fa4253b530a3b0fedbd26eebbab81be08 Mon Sep 17 00:00:00 2001 From: Andrey Vasilevsky Date: Sun, 26 Jul 2026 13:17:27 -0700 Subject: [PATCH 11/31] fix(server): unblock non-VS-Code clients and stop discarding store errors Three fixes found by building a second editor client against this engine. Embedding settings are no longer gated on `extensionPath`. They sat inside `if let Some(extension_path)`, which made a VS Code-specific option 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 log to say why. The path itself is optional and only ever meant "a directory the client owns", so it is now logged rather than load-bearing. Verified: initialize without extensionPath now honours granite-97m and fullBodyEmbedding, where before it logged CRITICAL and used the defaults. `codegraph.getDocumentCodeLens` is now advertised in executeCommandProvider. It was dispatched but unadvertised; VS Code never noticed because it uses the custom-request form, but a client that gates on ServerCapabilities - LSP4IJ's supportsCommand does - would treat the whole inline-CodeLens surface as unsupported. Checked for the collision the neighbouring comment warns about: no VS Code command is registered under that id. Advertised count 35 -> 36. memoryStore no longer maps its failure through `.map_err(|_| internal_error())`. That discarded the cause and logged nothing, so a store failure was unactionable from a user report - and it led me to a wrong diagnosis that took five probes to characterise and was still wrong. It now logs and returns the reason. 361 server tests pass; the JetBrains contract probe passes against the rebuilt engine. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013iTPP3eSpGSgWN6c5w4Fm1 --- crates/codegraph-server/src/backend.rs | 113 +++++++++++++------------ jetbrains/scripts/engine_probe.py | 7 +- 2 files changed, 64 insertions(+), 56 deletions(-) diff --git a/crates/codegraph-server/src/backend.rs b/crates/codegraph-server/src/backend.rs index b0b2e01..b44ca1c 100644 --- a/crates/codegraph-server/src/backend.rs +++ b/crates/codegraph-server/src/backend.rs @@ -902,60 +902,56 @@ 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/"); } + // Safety: We're replacing the Arc contents during initialization before any use + let new_manager = Arc::new(MemoryManager::with_model( + extension_path.clone(), + embedding_model, + )); + let self_mut = self as *const Self as *mut Self; + unsafe { + (*self_mut).memory_manager = new_manager; + } + + 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 if let Some(folders) = params.workspace_folders { let mut workspace_folders = self.workspace_folders.write().await; @@ -1012,6 +1008,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"), @@ -2600,12 +2603,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 }) } diff --git a/jetbrains/scripts/engine_probe.py b/jetbrains/scripts/engine_probe.py index 26622bc..8b44126 100644 --- a/jetbrains/scripts/engine_probe.py +++ b/jetbrains/scripts/engine_probe.py @@ -34,9 +34,10 @@ # needs a reason: an unadvertised command is invisible to clients that gate on # ServerCapabilities, which is how LSP4IJ behaves. UNADVERTISED_BY_DESIGN = { - # Dispatched at backend.rs, absent from executeCommandProvider.commands. - # VS Code reaches it through the custom-request form so it never noticed. - "codegraph.getDocumentCodeLens": "not yet advertised; tracked as a server fix", + # 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 = [] From b2fc07dff79d3383675f176aac9f5ed1685396b2 Mon Sep 17 00:00:00 2001 From: Andrey Vasilevsky Date: Sun, 26 Jul 2026 13:17:59 -0700 Subject: [PATCH 12/31] style(server): apply rustfmt to two files that had drifted Pre-existing `cargo fmt --check` violations in navigation.rs and parser_registry.rs, neither touched by the preceding fix. Separated from that commit so the functional change stays readable; this one is pure reflow and changes no behaviour. 361 tests still pass. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013iTPP3eSpGSgWN6c5w4Fm1 --- .../src/handlers/navigation.rs | 10 ++++- .../codegraph-server/src/parser_registry.rs | 37 ++++++++++++++++--- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/crates/codegraph-server/src/handlers/navigation.rs b/crates/codegraph-server/src/handlers/navigation.rs index bb95ede..6cb1b93 100644 --- a/crates/codegraph-server/src/handlers/navigation.rs +++ b/crates/codegraph-server/src/handlers/navigation.rs @@ -613,7 +613,15 @@ mod tests { 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); + 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 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")] From 6cebc7a98e69557a4f7965f2516de8844c195f98 Mon Sep 17 00:00:00 2001 From: Andrey Vasilevsky Date: Sun, 26 Jul 2026 16:34:41 -0700 Subject: [PATCH 13/31] fix(server): sweep crash breadcrumbs left by killed processes Measured before the fix: 312 last-phase..json files in ~/.codegraph, the oldest two months old. After: 4, all belonging to live processes. clear() only removes the current process's marker, and main.rs calls it after the LSP serve loop returns - which does not happen when a client force-kills the engine, as both clients do. Every killed process therefore leaked its marker permanently. The disk cost is trivial. The real cost is that stale markers make the clients' 15-second freshness window the only thing standing between a two-month-old marker and a wrong crash diagnosis today. The sweep requires two conditions, so it can never destroy a live diagnosis: the marker is older than an hour, AND its process is gone. Age alone would delete the marker of a long-running engine that later crashes hard; liveness alone would race a client that has not yet read a fresh crash. Verified in practice - one marker survived the first pass because its pid was still alive, and was swept on the next once it had exited. last-recovery markers are deliberately excluded: they are reported once with no freshness window, so an old one still matters to a client that has not read it. sweep_orphans_in() takes the directory so the policy is testable without setting HOME, which is process-global and would race other tests in this binary. Six tests cover both refusal paths, the exclusion, and non-marker files. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013iTPP3eSpGSgWN6c5w4Fm1 --- crates/codegraph-server/src/crash_phase.rs | 219 +++++++++++++++++++++ crates/codegraph-server/src/main.rs | 4 + 2 files changed, 223 insertions(+) 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/main.rs b/crates/codegraph-server/src/main.rs index 6ac0250..1b67e19 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(); From ccda86094c8cad8a286fbc61d220f560d71598bc Mon Sep 17 00:00:00 2001 From: Andrey Vasilevsky Date: Mon, 27 Jul 2026 18:14:44 -0700 Subject: [PATCH 14/31] fix(server): terminate on the LSP `exit` notification Measured before: shutdown answered in 0.00s, process still alive 120s after exit, terminated only when stdin closed. After: exits 2.05s after exit, rc=0. I had originally written this off as an upstream limitation not worth working around. That was wrong on the risk analysis: both clients already SIGKILL the engine at exactly this point, so returning from main a moment after `shutdown` is *gentler* than the status quo, not riskier. It also covers the case no client handles - anything holding the pipe open after `exit`, where the engine would linger holding an entire graph in memory. Two causes, one behind the other. tower-lsp 0.20 dispatches `exit` through service.call(), which flips the state to Exited but does not break the read loop; the loop only notices via poll_ready when the *next* message arrives. So `shutdown` now signals a waiter that main races against serve(). That alone was not enough: with the select resolving, the process still hung. tokio::io::stdin() reads on a blocking-pool thread that cannot be cancelled, and dropping the runtime waits for blocking tasks - the very read we are trying not to wait for. The request path now exits explicitly after clearing its crash breadcrumb. Also fixes the probe that produced the original diagnosis. It sent `"params": {}` on shutdown, which tower-lsp rejects with -32602 "Unexpected params" - a reply that looks like success to anything checking only for a response, while the server's shutdown handler never runs. The probe now omits params and asserts both the shutdown result and prompt termination, instead of printing a note about a known deviation. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013iTPP3eSpGSgWN6c5w4Fm1 --- crates/codegraph-server/src/backend.rs | 4 + crates/codegraph-server/src/lib.rs | 1 + crates/codegraph-server/src/lsp_exit.rs | 119 ++++++++++++++++++++++++ crates/codegraph-server/src/main.rs | 28 +++++- jetbrains/scripts/engine_probe.py | 29 +++--- 5 files changed, 169 insertions(+), 12 deletions(-) create mode 100644 crates/codegraph-server/src/lsp_exit.rs diff --git a/crates/codegraph-server/src/backend.rs b/crates/codegraph-server/src/backend.rs index b44ca1c..5b39ee0 100644 --- a/crates/codegraph-server/src/backend.rs +++ b/crates/codegraph-server/src/backend.rs @@ -1377,6 +1377,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(()) } 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 1b67e19..e4930fa 100644 --- a/crates/codegraph-server/src/main.rs +++ b/crates/codegraph-server/src/main.rs @@ -485,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/jetbrains/scripts/engine_probe.py b/jetbrains/scripts/engine_probe.py index 8b44126..d643a52 100644 --- a/jetbrains/scripts/engine_probe.py +++ b/jetbrains/scripts/engine_probe.py @@ -61,7 +61,13 @@ def check(ok, message): def send(method, params, notify=False): - msg = {"jsonrpc": "2.0", "method": method, "params": params} + 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] @@ -227,20 +233,21 @@ def kotlin_defaults(path): else: print("SKIP settings-defaults parity (vscode/package.json not found)") -rid = send("shutdown", {}) -await_response(rid, timeout=30) -send("exit", {}, notify=True) +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 currently ignores `exit` and terminates only when stdin closes. -# Both real clients force-kill the process, so this costs correctness rather -# than leaked processes - but the probe must not hang on it, and should say so -# out loud if it ever starts behaving. -EXIT_GRACE_SECONDS = 5 +# 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) - print(f"NOTE engine honoured `exit` within {EXIT_GRACE_SECONDS}s") + check(True, f"engine honoured `exit` within {EXIT_GRACE_SECONDS}s") except subprocess.TimeoutExpired: - print(f"NOTE engine ignored `exit` (known deviation); closing stdin instead") + check(False, f"engine ignored `exit`; still running after {EXIT_GRACE_SECONDS}s") proc.stdin.close() try: proc.wait(timeout=30) From 4ef600ffd585eb5ce443f7264b8afb4ff4fc8558 Mon Sep 17 00:00:00 2001 From: Andrey Vasilevsky Date: Mon, 27 Jul 2026 22:49:07 -0700 Subject: [PATCH 15/31] fix(server): initialize the workspace from rootUri when workspaceFolders is absent `workspaceFolders` is optional in LSP - a client may legally send only `rootUri`, or the deprecated `rootPath`, and several do. The engine read only the first, so those clients got a server whose memory subsystem never initialised: every memory command failed for the entire session while indexing and search kept working normally. A half-broken server is worse than an obviously broken one, because nothing points at the cause. Measured against a cold ~/.codegraph, first-ever memoryStore: rootUri only, before: fails, and keeps failing - "Memory manager not initialized" on every subsequent call too rootUri only, after: succeeds in 13.4s (cold embedding model load) workspaceFolders: unchanged This is what the earlier "architectural_decision is broken" report actually was. That diagnosis was wrong twice over - not kind-specific, and not a cold embedding model either. The real cause only became visible once memoryStore stopped discarding its error, which is the second time that discarded error sent an investigation down the wrong path. An empty `workspaceFolders` list is treated as absent rather than as "no workspace", so a client sending [] alongside a usable rootUri still works. The selection is extracted into workspace_paths_from() so all six cases are tested without standing up a backend. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013iTPP3eSpGSgWN6c5w4Fm1 --- crates/codegraph-server/src/backend.rs | 170 ++++++++++++++++++++++++- 1 file changed, 163 insertions(+), 7 deletions(-) diff --git a/crates/codegraph-server/src/backend.rs b/crates/codegraph-server/src/backend.rs index 5b39ee0..1c487b7 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, @@ -865,6 +906,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| { @@ -952,14 +996,27 @@ impl LanguageServer for CodeGraphBackend { self.query_engine.set_full_body_embedding(full_body); tracing::info!("[LSP::initialize] Full-body embedding: {}", full_body); - // Store workspace folders - if let Some(folders) = params.workspace_folders { + // 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 @@ -4028,6 +4085,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( From 3c576a5ce021d5f675e9acc8cb8943a579ce5995 Mon Sep 17 00:00:00 2001 From: Andrey Vasilevsky Date: Sat, 1 Aug 2026 19:37:39 -0700 Subject: [PATCH 16/31] feat: publish engine binaries as release assets, and download them Adds scripts/publish-release-assets.sh and restores the JetBrains engine downloader it unblocks. The script uploads what the existing cross-platform build already produces in vscode/bin/, under tag v read from Cargo.toml, each asset beside a .sha256. It builds nothing and changes nothing about how binaries are made - it slots in after the manual per-host build. Staging is the default; --publish is required to upload, matching package-npm.sh. It refuses to publish a partial set. A client that resolves its own platform and finds nothing has no way to distinguish "not built yet" from "never supported", so half a release is worse than none. The downloader fetches only the platform it needs - roughly 30 MB against the 498 MB unpacked npm package that is the alternative for users without Node - and verifies every file against its published checksum before installing. An engine runs with the user's permissions; TLS says nothing about a mirror, a proxy or a truncated transfer. Two things the first attempt at this got wrong, both now covered by tests: Windows needs onnxruntime.dll alongside the executable. Fetching only the exe gives a download that succeeds and then fails at startup - package-npm.sh warns about precisely this. The sidecar is part of the install, and a checksum failure on it leaves nothing behind. The URL scheme now matches the convention already in use: the npm postinstall fetches its model from releases/download//, and the repository name casing is load-bearing. The download is offered, never automatic. Pulling a native binary unasked on project open is not a decision the plugin should make. Verified end to end against a real release tree served over loopback: the script's checksum format is exactly what the downloader parses, and a 116 MB binary verifies against it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013iTPP3eSpGSgWN6c5w4Fm1 --- jetbrains/README.md | 25 ++-- .../indexing/IndexingStartupActivity.kt | 21 ++- .../jetbrains/server/EngineDownloader.kt | 131 +++++++++++++++++ .../jetbrains/server/EngineInstaller.kt | 76 ++++++++++ .../jetbrains/server/EngineDownloaderTest.kt | 138 ++++++++++++++++++ scripts/publish-release-assets.sh | 128 ++++++++++++++++ 6 files changed, 501 insertions(+), 18 deletions(-) create mode 100644 jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/EngineDownloader.kt create mode 100644 jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/EngineInstaller.kt create mode 100644 jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/EngineDownloaderTest.kt create mode 100755 scripts/publish-release-assets.sh diff --git a/jetbrains/README.md b/jetbrains/README.md index 7862fc3..cd601f1 100644 --- a/jetbrains/README.md +++ b/jetbrains/README.md @@ -54,21 +54,26 @@ Resolution order, implemented in ### Installing the engine -Until per-platform binaries are published as release assets, there is no -one-click install. -The engine ships bundled inside the npm package and the VSIX, so users install -it with: +When no engine is found, the plugin offers to download the one built for this +platform from the GitHub release matching its own version, verifying it against +the published `.sha256` before installing it into `~/.codegraph/bin`. +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 ``` -That puts `codegraph-server` where step 3 finds it. -A checksum-verifying downloader for step 4 was written and then removed: the -release assets it would fetch do not exist yet, and shipping code that cannot -run is worse than not shipping it. -Publishing those assets is tracked separately; the `MANAGED_INSTALL` slot in the -resolver is reserved for it. +The release assets come from `scripts/publish-release-assets.sh` in the repo +root, run after the per-platform binaries are built. ## Surfaces diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/indexing/IndexingStartupActivity.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/indexing/IndexingStartupActivity.kt index 8bc8dab..49665f8 100644 --- a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/indexing/IndexingStartupActivity.kt +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/indexing/IndexingStartupActivity.kt @@ -6,6 +6,7 @@ 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.settings.CodeGraphSettings import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.diagnostic.logger @@ -35,15 +36,19 @@ class IndexingStartupActivity : ProjectActivity { if (!settings.enabled) return if (CodeGraphServerResolver.resolve(project.basePath, settings.serverPath) == null) { - // No one-click install yet: the engine is only distributed bundled - // inside the npm package and the VSIX, and the JetBrains - // Marketplace ships a single artifact for every platform so the - // plugin cannot carry a ~120 MB binary set of its own. - CodeGraphNotifications.warn( + // 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. Install it with " + - "npm i -g @astudioplus/codegraph-mcp, then reopen this project, " + - "or point CodeGraph at an existing engine in Settings | Tools | CodeGraph.", + "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 } 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..2c14db0 --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/EngineDownloader.kt @@ -0,0 +1,131 @@ +// 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.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.", + ) + + /** + * Download and install the engine for [version], returning its path. + * + * Each file is staged next to its destination and moved into place only + * after its checksum matches, so an interrupted or corrupted download can + * never leave something behind that later looks like a valid install. + */ + fun download(version: String, indicator: ProgressIndicator? = null): 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) + } + + assets.forEachIndexed { index, asset -> + indicator?.text = "Downloading the CodeGraph engine ($version): $asset" + indicator?.fraction = index.toDouble() / assets.size + fetchVerified(version, asset, targetDir, indicator) + } + + val engine = targetDir.resolve(binaryName) + engine.toFile().setExecutable(true, /* ownerOnly = */ true) + LOG.info("Installed CodeGraph engine $version at $engine") + return engine + } + + private fun fetchVerified(version: String, asset: String, targetDir: Path, indicator: ProgressIndicator?) { + 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) + } + Files.move(staged, targetDir.resolve(asset), StandardCopyOption.REPLACE_EXISTING) + } finally { + runCatching { Files.deleteIfExists(staged) } + } + } + + /** + * 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..ea8d35c --- /dev/null +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/EngineInstaller.kt @@ -0,0 +1,76 @@ +// 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.ide.plugins.PluginManagerCore +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.extensions.PluginId +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) { + ProgressManager.getInstance().run( + object : Task.Backgroundable(project, "Downloading the CodeGraph engine", true) { + override fun run(indicator: ProgressIndicator) { + val version = engineVersion() + runCatching { EngineDownloader().download(version, indicator) }.fold( + onSuccess = { path -> + LOG.info("CodeGraph engine installed at $path") + CodeGraphNotifications.info( + project, + "CodeGraph engine $version installed. Starting it now.", + ) + CodeGraphClient.getInstance(project).start() + }, + onFailure = { error -> report(project, version, error) }, + ) + } + }, + ) + } + + /** + * 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 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) + } + + /** + * The plugin ships in lockstep with the engine it was built against, so the + * plugin's own version names the release to fetch. + */ + private fun engineVersion(): String = + PluginManagerCore.getPlugin(PluginId.getId(PLUGIN_ID))?.version + ?: error("CodeGraph plugin descriptor is unavailable") + + private val LOG = logger() + private const val PLUGIN_ID = "ai.codegraph.jetbrains" +} 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..4342ab3 --- /dev/null +++ b/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/EngineDownloaderTest.kt @@ -0,0 +1,138 @@ +// 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) + + private fun downloader(os: String) = EngineDownloader(env(os), 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").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 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: the install must not be + // reported as usable. + 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").download("0.19.1") }.exceptionOrNull() + + assertTrue(failure is EngineDownloader.ChecksumMismatchException) + assertFalse( + Files.exists( + CodeGraphServerResolver.managedInstallDir(env("Windows 11")) + .resolve(EngineDownloader.WINDOWS_SIDECAR), + ), + ) + } +} diff --git a/scripts/publish-release-assets.sh b/scripts/publish-release-assets.sh new file mode 100755 index 0000000..0a40967 --- /dev/null +++ b/scripts/publish-release-assets.sh @@ -0,0 +1,128 @@ +#!/bin/bash +# Copyright 2026 Andrey Vasilevsky +# SPDX-License-Identifier: Apache-2.0 +# +# Publish the per-platform engine binaries as GitHub release assets. +# +# The VSIX and the npm package both bundle all four platform binaries, because +# both ecosystems allow it. The JetBrains Marketplace does not: it serves one +# artifact to every platform, so a bundled plugin would be a ~120 MB download +# for every user to obtain the ~30 MB they can actually run. Publishing the +# binaries individually lets that client - and anyone scripting an install - +# fetch only what they need. +# +# 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 the JetBrains client asks for them by engine version. +VERSION="$(grep -m1 '^version' "$REPO_ROOT/Cargo.toml" | sed 's/.*"\(.*\)".*/\1/')" +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 + +# ---------------------------------------------------------------- verify +missing=0 +for bin in "${BINARIES[@]}" "$WINDOWS_SIDECAR"; do + if [ -f "$VSCODE_BIN/$bin" ]; then + printf ' ✓ %-36s %s\n' "$bin" "$(du -h "$VSCODE_BIN/$bin" | cut -f1)" + else + printf ' ✗ %-36s MISSING\n' "$bin" + missing=1 + fi +done + +if [ "$missing" -ne 0 ]; then + cat >&2 < " 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"' From 7a3d25ded0496a7989eecd445bc0d141ae43a106 Mon Sep 17 00:00:00 2001 From: Andrey Vasilevsky Date: Sat, 1 Aug 2026 23:00:30 -0700 Subject: [PATCH 17/31] feat: fetch the engine per platform instead of bundling it everywhere, 0.20.0 Every channel used to carry all four platform binaries so that each user could run exactly one of them. The binaries are now published once as release assets and each channel fetches only what it needs. npm package 88 MB compressed / 498 MB unpacked -> 16 kB / 50.7 kB VSIX 118 MB (plus four targeted variants) -> 554 kB, one artifact JetBrains already fetched; unchanged mcp-package/bin/fetch-engine.js is the single implementation of the download contract - URL layout, checksum format, the Windows sidecar rule - for both JavaScript channels. The VS Code extension does not reimplement it: esbuild follows the relative path and inlines the same file into out/extension.js, verified by checking the bundle rather than assuming. The JetBrains plugin implements the same contract in Kotlin against the same assets. Where each channel fetches: npm postinstall, into /bin/ - the path is unchanged because consumers resolve it directly, codegraph-pr.yml among them VSIX first activation, into ~/.codegraph/bin, since a VSIX has no install hook. server.ts now looks there too, so an engine installed through any channel is found by all of them. Both offer rather than assume. Pulling a native binary that runs with the user's permissions, unasked, is not a decision these clients should make. Escape hatches for air-gapped installs: CODEGRAPH_SKIP_BINARY_FETCH, a pre-placed binary, codegraph.serverPath, or npx codegraph-mcp-fetch-engine to retry. A failed fetch never fails `npm install` - rolling back a package whose CLI, hooks and docs all work would be the wrong trade. Version 0.20.0 rather than a patch: 0.19.1 is published, this adds a distribution channel and changes how every install obtains its engine. Note on coverage: vscode/src/server.ts and extension.ts changes are compile-checked only. Those suites are quarantined because the vsforge test harness they import is permanently lost - a pre-existing condition, not introduced here. The download logic itself is covered by 12 checks in mcp-package/test and 6 in the JetBrains suite. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013iTPP3eSpGSgWN6c5w4Fm1 --- Cargo.lock | 6 +- Cargo.toml | 2 +- jetbrains/gradle.properties | 2 +- mcp-package/bin/fetch-engine-cli.js | 49 +++++++ mcp-package/bin/fetch-engine.js | 196 ++++++++++++++++++++++++++ mcp-package/bin/postinstall.js | 145 +++++++++++-------- mcp-package/package.json | 10 +- mcp-package/server.json | 4 +- mcp-package/test/fetch-engine.test.js | 160 +++++++++++++++++++++ scripts/package-npm.sh | 78 ++++------ scripts/package-vsix.sh | 34 ++--- vscode/.vscodeignore | 5 +- vscode/package.json | 2 +- vscode/src/engineDownload.ts | 107 ++++++++++++++ vscode/src/extension.ts | 24 +++- vscode/src/server.ts | 10 +- 16 files changed, 684 insertions(+), 150 deletions(-) create mode 100755 mcp-package/bin/fetch-engine-cli.js create mode 100644 mcp-package/bin/fetch-engine.js create mode 100644 mcp-package/test/fetch-engine.test.js create mode 100644 vscode/src/engineDownload.ts 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/jetbrains/gradle.properties b/jetbrains/gradle.properties index 89f148f..b10f7bb 100644 --- a/jetbrains/gradle.properties +++ b/jetbrains/gradle.properties @@ -3,7 +3,7 @@ # Keep in sync with vscode/package.json `version` - both clients ship against # the same codegraph-server protocol surface. -pluginVersion=0.19.1 +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, diff --git a/mcp-package/bin/fetch-engine-cli.js b/mcp-package/bin/fetch-engine-cli.js new file mode 100755 index 0000000..b79015d --- /dev/null +++ b/mcp-package/bin/fetch-engine-cli.js @@ -0,0 +1,49 @@ +#!/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 } = require("./fetch-engine"); + +const force = process.argv.includes("--force"); +const version = require("../package.json").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)}, or`); + console.error(" - set CODEGRAPH_SERVER_PATH to an engine you already have"); + 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..9d7e503 --- /dev/null +++ b/mcp-package/bin/fetch-engine.js @@ -0,0 +1,196 @@ +// 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; +} + +const RELEASE_BASE = "https://github.com/codegraph-ai/CodeGraph/releases/download"; + +/** 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" }; + +/** + * 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; + // Only x64 is published for Windows and Linux today. + if (p === "win32") return "codegraph-server-win32-x64.exe"; + if (p === "linux") return "codegraph-server-linux-x64"; + return `codegraph-server-darwin-${a}`; +} + +/** 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}`)); + 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(); + return resolve(download(res.headers.location, destination, { redirects: redirects - 1 })); + } + if (res.statusCode !== 200) { + res.resume(); + return reject(new Error(`HTTP ${res.statusCode} for ${url}`)); + } + const file = fs.createWriteStream(destination); + res.pipe(file); + file.on("finish", () => file.close(resolve)); + file.on("error", reject); + }) + .on("error", reject); + }); +} + +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(); + return resolve(readText(res.headers.location, { 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); + }); +} + +/** + * Fetch one asset into targetDir, verified. + * + * Staged first and moved into place only once the checksum matches, so an + * interrupted or corrupted download can never leave behind something that + * later looks like a valid install. + */ +async function fetchVerified(asset, version, targetDir, { baseUrl = RELEASE_BASE } = {}) { + const assetUrl = `${baseUrl}/v${version}/${asset}`; + const expected = (await readText(`${assetUrl}.sha256`)).trim().split(/\s+/)[0].toLowerCase(); + + const staged = path.join(targetDir, `.${asset}.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})` + ); + } + fs.renameSync(staged, path.join(targetDir, asset)); + } finally { + if (fs.existsSync(staged)) fs.unlinkSync(staged); + } +} + +/** + * Ensure the engine for this platform is present in targetDir. + * + * @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 fetched = []; + for (const asset of assets) { + const destination = path.join(targetDir, asset); + if (fs.existsSync(destination) && !options.force) continue; + if (options.onProgress) options.onProgress(asset); + await fetchVerified(asset, version, targetDir, options); + fetched.push(asset); + } + + 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, + WINDOWS_SIDECAR, + platformBinaryName, + requiredAssets, + ensureEngine, + fetchVerified, + sha256, +}; diff --git a/mcp-package/bin/postinstall.js b/mcp-package/bin/postinstall.js index 4d2d8d8..d0f3afa 100644 --- a/mcp-package/bin/postinstall.js +++ b/mcp-package/bin/postinstall.js @@ -28,72 +28,101 @@ if (!platform || !arch) { process.exit(0); } -const ext = platform === "win32" ? ".exe" : ""; -const binaryName = `codegraph-server-${platform}-${arch}${ext}`; -const binaryPath = path.join(__dirname, binaryName); +const { ensureEngine, platformBinaryName } = require("./fetch-engine"); -if (!fs.existsSync(binaryPath)) { - console.warn(`⚠ codegraph-mcp: binary not found for ${platform}-${arch}`); - console.warn(` Expected: ${binaryPath}`); - process.exit(0); -} +const binaryName = platformBinaryName(); +const binaryPath = path.join(__dirname, binaryName); +const version = require("../package.json").version; -if (platform !== "win32") { - try { - fs.chmodSync(binaryPath, 0o755); - } catch { - // Ignore permission errors +// 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; the file already being present skips the +// fetch anyway. +(async () => { + if (!fs.existsSync(binaryPath) && !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`); + console.warn(` Or set CODEGRAPH_SERVER_PATH to an engine you already have.`); + } } -} -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..4ec48fd 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" } } 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..2b8135f --- /dev/null +++ b/mcp-package/test/fetch-engine.test.js @@ -0,0 +1,160 @@ +#!/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, WINDOWS_SIDECAR } = + 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. */ +function startRelease(assets) { + 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 body = routes[req.url]; + if (!body) { + res.writeHead(404); + 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 is not re-downloaded ------------------ + { + const dir = scratch(); + const name = platformBinaryName(); + for (const asset of requiredAssets()) fs.writeFileSync(path.join(dir, asset), "existing"); + // 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 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 }); + } + } + + // --- 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 }); + } + } + + // --- 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" + ); + + console.log(""); + console.log(`${failures} failure(s)`); + process.exit(failures ? 1 : 0); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/package-npm.sh b/scripts/package-npm.sh index 686e6df..5e050cf 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,32 @@ 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)" - 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/ -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" +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 -# 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" -fi - -# Ensure launcher scripts are executable -chmod +x "$BIN_DIR/codegraph-mcp.js" - +# The fetch path is what every install now depends on, so it is checked here +# rather than discovered by the first user to install the package. echo "" -echo "Package contents:" -ls -lh "$BIN_DIR/" +echo "Checking the engine fetch..." +( cd "$PKG_DIR" && node test/fetch-engine.test.js >/dev/null ) \ + && echo " ✓ fetch-engine tests pass" \ + || { echo " ✗ fetch-engine tests FAILED — not packaging"; exit 1; } # 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..1e9a9a3 100755 --- a/scripts/package-vsix.sh +++ b/scripts/package-vsix.sh @@ -46,30 +46,16 @@ 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" -fi +# 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)..." +npx @vscode/vsce package 2>&1 | grep -E "DONE|ERROR" echo "" echo "VSIX packages:" 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/package.json b/vscode/package.json index 67a2074..3b835f0 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", diff --git a/vscode/src/engineDownload.ts b/vscode/src/engineDownload.ts new file mode 100644 index 0000000..12050ea --- /dev/null +++ b/vscode/src/engineDownload.ts @@ -0,0 +1,107 @@ +// 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 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 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): 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 }), + }); + return binary as string; + }, + ); +} + +/** + * 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 58f5420..98efdf5 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -17,6 +17,7 @@ import { registerCodeLens } from './views/codeLensProvider'; import { CodeGraphAIProvider } from './ai/contextProvider'; import { CodeGraphToolManager } from './ai/toolManager'; import { getServerPath } from './server'; +import { offerEngineDownload } from './engineDownload'; import { createReporter, setServerEdition, type Reporter } from './telemetry/reporter'; import { detectMachineProfile } from './telemetry/machineProfile'; import { handleIndexOutcome, filesIndexed, reportIndexTelemetry } from './funnel'; @@ -269,7 +270,28 @@ export async function activate(context: vscode.ExtensionContext): Promise // 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(context.extension.packageJSON.version); + if (!downloaded) { + reporter.activationServerStartResult({ + outcome: 'spawn_fail', + durationMs: 0, + serverBinaryFound: false, + errorHint: 'engine_not_installed', + }); + return; + } + serverInfo = getServerPath(context); + } setServerEdition(serverInfo.edition === 'pro' ? 'pro' : 'community'); // Log server path for debugging diff --git a/vscode/src/server.ts b/vscode/src/server.ts index ad19f20..aa802a2 100644 --- a/vscode/src/server.ts +++ b/vscode/src/server.ts @@ -86,12 +86,20 @@ function findCommunityBinary(context: vscode.ExtensionContext): string { throw new Error(`Unsupported platform: ${platform}`); } - // Packaged binary (production) + // Packaged binary — only present in a VSIX built with binaries bundled. + // The published VSIX no longer carries one; see engineDownload.ts. const packagedPath = context.asAbsolutePath(path.join('bin', binaryName)); if (fs.existsSync(packagedPath)) { return packagedPath; } + // 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. + const managedPath = path.join(os.homedir(), '.codegraph', 'bin', binaryName); + if (fs.existsSync(managedPath)) { + return managedPath; + } + // Cargo release build (development) const releasePath = context.asAbsolutePath( path.join('..', 'crates', 'codegraph-server', 'target', 'release', 'codegraph-server') From cdac98eae44aaf5c2ed68136894891661b5fc575 Mon Sep 17 00:00:00 2001 From: Andrey Vasilevsky Date: Sun, 2 Aug 2026 00:12:21 -0700 Subject: [PATCH 18/31] no-mistakes(review): fix engine platform/version handling and JetBrains plugin defects --- .../jetbrains/actions/RegisterMcpAction.kt | 7 +- .../jetbrains/indexing/IndexingService.kt | 20 ++++- .../indexing/IndexingStartupActivity.kt | 35 +++++++- .../jetbrains/mcp/McpRegistration.kt | 46 ++++++++-- .../server/CodeGraphConnectionProvider.kt | 2 +- .../server/CodeGraphLanguageServerFactory.kt | 18 ++++ .../server/CodeGraphServerResolver.kt | 60 ++++++++++--- .../jetbrains/server/CrashBreadcrumbs.kt | 34 +++++-- .../jetbrains/server/EngineDownloader.kt | 5 ++ .../jetbrains/server/EngineInstaller.kt | 37 ++++++-- .../jetbrains/telemetry/TelemetryReporter.kt | 10 ++- .../jetbrains/ui/SymbolsToolWindow.kt | 12 ++- .../vision/CodeGraphCodeVisionProvider.kt | 25 +++++- .../src/main/resources/META-INF/plugin.xml | 9 +- .../jetbrains/mcp/McpRegistrationTest.kt | 39 ++++++++ .../server/CodeGraphServerResolverTest.kt | 34 +++++++ .../jetbrains/server/CrashBreadcrumbsTest.kt | 53 ++++++++++- .../jetbrains/server/EngineDownloaderTest.kt | 38 +++++++- mcp-package/bin/fetch-engine.js | 68 +++++++++++--- mcp-package/bin/postinstall.js | 28 ++---- mcp-package/test/fetch-engine.test.js | 90 ++++++++++++++++++- scripts/package-vsix.sh | 19 ++-- vscode/src/engineDownload.ts | 29 ++++++ vscode/src/extension.ts | 12 ++- 24 files changed, 632 insertions(+), 98 deletions(-) diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/actions/RegisterMcpAction.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/actions/RegisterMcpAction.kt index 63180d1..da799f6 100644 --- a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/actions/RegisterMcpAction.kt +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/actions/RegisterMcpAction.kt @@ -41,10 +41,15 @@ class RegisterMcpAction : AnAction() { 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.", + "Restart your AI client to pick it up.$rescued", "Copy Config" to { notification -> notification.expire() copyConfig(e) diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/indexing/IndexingService.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/indexing/IndexingService.kt index ae278f3..7534edf 100644 --- a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/indexing/IndexingService.kt +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/indexing/IndexingService.kt @@ -7,6 +7,7 @@ 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 @@ -15,6 +16,7 @@ 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 /** @@ -37,11 +39,17 @@ class IndexingService(private val project: Project) { * 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. */ - fun isIndexed(timeoutSeconds: Long = QUERY_TIMEOUT_SECONDS): Boolean = + 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)) - .get(timeoutSeconds, TimeUnit.SECONDS) + .orTimeout(timeoutSeconds, TimeUnit.SECONDS) + .await() resultCount(response) > 0 }.getOrElse { error -> LOG.info("Could not determine CodeGraph index state: ${error.message}") @@ -96,6 +104,14 @@ class IndexingService(private val project: Project) { * 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 { diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/indexing/IndexingStartupActivity.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/indexing/IndexingStartupActivity.kt index 49665f8..109d4a6 100644 --- a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/indexing/IndexingStartupActivity.kt +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/indexing/IndexingStartupActivity.kt @@ -7,6 +7,7 @@ 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 @@ -35,7 +36,8 @@ class IndexingStartupActivity : ProjectActivity { val settings = CodeGraphSettings.getInstance(project).state if (!settings.enabled) return - if (CodeGraphServerResolver.resolve(project.basePath, settings.serverPath) == null) { + 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 @@ -53,6 +55,8 @@ class IndexingStartupActivity : ProjectActivity { return } + offerEngineUpdateIfStale(project, resolved) + val client = CodeGraphClient.getInstance(project) client.start() @@ -89,6 +93,35 @@ class IndexingStartupActivity : ProjectActivity { ) } + /** + * 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. + */ + private fun offerEngineUpdateIfStale(project: Project, resolved: ResolvedServer) { + if (resolved.origin != ResolvedServer.Origin.MANAGED_INSTALL) return + val expected = EngineInstaller.pluginVersion() ?: return + val installed = CodeGraphServerResolver.managedEngineVersion() + if (installed == expected) return + + LOG.info("Managed CodeGraph engine reports version ${installed ?: "unknown"}, plugin is $expected") + CodeGraphNotifications.infoWithActions( + project, + "The installed CodeGraph engine (${installed ?: "unknown version"}) does not match this " + + "plugin ($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() diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/mcp/McpRegistration.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/mcp/McpRegistration.kt index eaadd8f..7e5d7b9 100644 --- a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/mcp/McpRegistration.kt +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/mcp/McpRegistration.kt @@ -12,6 +12,7 @@ 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. @@ -32,8 +33,11 @@ object McpRegistration { 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) : 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 } @@ -61,14 +65,20 @@ object McpRegistration { val configPath = Paths.get(basePath, CONFIG_FILE) return try { - val existing = readConfig(configPath) + 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) + 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. @@ -81,23 +91,41 @@ object McpRegistration { val basePath = project.basePath ?: return false return runCatching { readConfig(Paths.get(basePath, CONFIG_FILE)) - .getAsJsonObject("mcpServers") + ?.getAsJsonObject("mcpServers") ?.has(SERVER_NAME) == true }.getOrDefault(false) } /** - * A malformed or absent file both yield an empty object: refusing to write - * because the existing JSON is broken would leave the user stuck with no way - * forward from inside the IDE. + * 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 { + private fun readConfig(path: Path): JsonObject? { if (!Files.exists(path)) return JsonObject() return runCatching { JsonParser.parseString(Files.readString(path)).asJsonObject - }.getOrElse { JsonObject() } + }.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 diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphConnectionProvider.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphConnectionProvider.kt index aa7bee9..7b59af7 100644 --- a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphConnectionProvider.kt +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphConnectionProvider.kt @@ -32,7 +32,7 @@ class CodeGraphConnectionProvider(private val project: Project) : OSProcessStrea 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 @codegraph-ai/codegraph`, " + + "CodeGraph engine not found. Install it with `npm i -g @astudioplus/codegraph-mcp`, " + "or set the engine path in Settings | Tools | CodeGraph.", ) resolved = server diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphLanguageServerFactory.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphLanguageServerFactory.kt index 0e007ec..f12f2c0 100644 --- a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphLanguageServerFactory.kt +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphLanguageServerFactory.kt @@ -3,9 +3,13 @@ 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. */ @@ -19,6 +23,20 @@ class CodeGraphLanguageServerFactory : LanguageServerFactory { 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) } /** diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphServerResolver.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphServerResolver.kt index 6f0a8ac..363c6f2 100644 --- a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphServerResolver.kt +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphServerResolver.kt @@ -80,30 +80,59 @@ object CodeGraphServerResolver { class UnsupportedPlatformException(os: String, arch: String) : RuntimeException("CodeGraph does not ship an engine for $os/$arch") - /** Binary name for this platform, matching the names published in releases. */ - fun platformBinaryName(env: ResolverEnvironment = ResolverEnvironment.fromSystem()): String { + /** + * Binary name for this platform, or null when no engine is published for it. + * + * Only macOS is built for both architectures. Falling back to the x64 asset + * on an arm64 Linux or Windows machine installs ~30 MB that cannot execute, + * which surfaces as an exec-format error at first use instead of as the + * unsupported platform it is. + */ + 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 + } + !isX64 -> null os.contains("win") -> "codegraph-server-win32-x64.exe" - os.contains("mac") || os.contains("darwin") -> - if (arch == "aarch64" || arch == "arm64") { - "codegraph-server-darwin-arm64" - } else { - "codegraph-server-darwin-x64" - } os.contains("linux") -> "codegraph-server-linux-x64" - else -> throw UnsupportedPlatformException(env.osName, env.osArch) + 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 = - Files.isRegularFile(managedInstallDir(env).resolve(platformBinaryName(env))) + 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() } /** * Resolve the engine, or return null when nothing is installed yet. A null @@ -135,8 +164,9 @@ object CodeGraphServerResolver { return ResolvedServer(it, ServerEdition.COMMUNITY, ResolvedServer.Origin.SYSTEM_PATH) } - managedInstallDir(env).resolve(platformBinaryName(env)) - .takeIf { it.isExecutableFile(env) } + 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 { @@ -208,4 +238,10 @@ object CodeGraphServerResolver { } catch (_: SecurityException) { false } + + /** File name shared with the JavaScript installer, so all channels agree. */ + const val VERSION_MARKER = ".engine-version" + + 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 index dd6bf1e..c06f957 100644 --- a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CrashBreadcrumbs.kt +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CrashBreadcrumbs.kt @@ -52,16 +52,26 @@ data class CrashDiagnosis( 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 every breadcrumb, so a stale - * file can never be read as a diagnosis of some later crash. + * 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. */ fun readAndClear(): CrashDiagnosis { - val files = runCatching { Files.list(directory).use { it.toList() } }.getOrNull() + val all = runCatching { Files.list(directory).use { it.toList() } }.getOrNull() ?: return CrashDiagnosis(CrashDiagnosis.HARD_CRASH) + val files = all.filter { isBreadcrumb(it) && !belongsToLiveProcess(it) } + val cause = pickFresh(files, CRASH_PATTERN)?.let { crumb -> when { crumb["kind"] == "signal" -> CrashDiagnosis.SIGNAL @@ -72,12 +82,26 @@ class CrashBreadcrumbs( val phase = pickFresh(files, PHASE_PATTERN)?.get("phase") - files.filter { CRASH_PATTERN.matches(it.fileName.toString()) || PHASE_PATTERN.matches(it.fileName.toString()) } - .forEach { runCatching { Files.deleteIfExists(it) } } + files.forEach { runCatching { Files.deleteIfExists(it) } } return CrashDiagnosis(cause, phase) } + 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. diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/EngineDownloader.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/EngineDownloader.kt index 2c14db0..640f11a 100644 --- a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/EngineDownloader.kt +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/EngineDownloader.kt @@ -64,6 +64,11 @@ class EngineDownloader( 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 } diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/EngineInstaller.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/EngineInstaller.kt index ea8d35c..87134c7 100644 --- a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/EngineInstaller.kt +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/EngineInstaller.kt @@ -12,23 +12,41 @@ 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 com.redhat.devtools.lsp4ij.ServerStatus /** Runs the engine download behind a progress bar and reports the outcome. */ object EngineInstaller { fun downloadInBackground(project: Project) { + val version = pluginVersion() + if (version == null) { + CodeGraphNotifications.error( + project, + "The CodeGraph plugin descriptor is unavailable, so there is no version to download.", + ) + return + } ProgressManager.getInstance().run( object : Task.Backgroundable(project, "Downloading the CodeGraph engine", true) { override fun run(indicator: ProgressIndicator) { - val version = engineVersion() + val client = CodeGraphClient.getInstance(project) runCatching { EngineDownloader().download(version, indicator) }.fold( onSuccess = { path -> LOG.info("CodeGraph engine installed at $path") + // Replacing the binary under a live process does not + // change the process. Saying so beats implying the + // new engine is already in use. + val running = client.status() in RUNNING_STATUSES CodeGraphNotifications.info( project, - "CodeGraph engine $version installed. Starting it now.", + if (running) { + "CodeGraph engine $version installed. It takes effect the next " + + "time the engine starts." + } else { + "CodeGraph engine $version installed. Starting it now." + }, ) - CodeGraphClient.getInstance(project).start() + if (!running) client.start() }, onFailure = { error -> report(project, version, error) }, ) @@ -65,11 +83,16 @@ object EngineInstaller { /** * The plugin ships in lockstep with the engine it was built against, so the - * plugin's own version names the release to fetch. + * plugin's own version names the release to fetch - and the version a + * managed install is expected to be. + * + * Null outside a real IDE (tests, headless tooling), where there is no + * plugin descriptor to read. */ - private fun engineVersion(): String = - PluginManagerCore.getPlugin(PluginId.getId(PLUGIN_ID))?.version - ?: error("CodeGraph plugin descriptor is unavailable") + fun pluginVersion(): String? = + runCatching { PluginManagerCore.getPlugin(PluginId.getId(PLUGIN_ID))?.version }.getOrNull() + + private val RUNNING_STATUSES = setOf(ServerStatus.started, ServerStatus.starting) private val LOG = logger() private const val PLUGIN_ID = "ai.codegraph.jetbrains" diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/telemetry/TelemetryReporter.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/telemetry/TelemetryReporter.kt index c868f21..01a1944 100644 --- a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/telemetry/TelemetryReporter.kt +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/telemetry/TelemetryReporter.kt @@ -10,6 +10,7 @@ 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 @@ -32,7 +33,7 @@ import java.util.concurrent.TimeUnit * without a compiled-in key can send at all. */ @Service(Service.Level.PROJECT) -class TelemetryReporter(private val project: Project) { +class TelemetryReporter(private val project: Project) : Disposable { private val gson = Gson() private val sessionId = UUID.randomUUID().toString() @@ -153,7 +154,12 @@ class TelemetryReporter(private val project: Project) { } } - fun shutdown() { + /** + * 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. + */ + override fun dispose() { sender.shutdown() runCatching { sender.awaitTermination(SHUTDOWN_WAIT_SECONDS, TimeUnit.SECONDS) } } diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/ui/SymbolsToolWindow.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/ui/SymbolsToolWindow.kt index 9b5b330..bfc2290 100644 --- a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/ui/SymbolsToolWindow.kt +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/ui/SymbolsToolWindow.kt @@ -171,10 +171,16 @@ private class SymbolsPanel(private val project: Project) : JPanel(BorderLayout() 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) - ?: VirtualFileManager.getInstance().findFileByNioPath( - java.nio.file.Paths.get(java.net.URI.create(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 diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/vision/CodeGraphCodeVisionProvider.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/vision/CodeGraphCodeVisionProvider.kt index 23a1e9d..75b6fd9 100644 --- a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/vision/CodeGraphCodeVisionProvider.kt +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/vision/CodeGraphCodeVisionProvider.kt @@ -9,9 +9,12 @@ 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 @@ -54,6 +57,10 @@ class CodeGraphCodeVisionProvider : DaemonBoundCodeVisionProvider { /** * 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 { @@ -66,7 +73,7 @@ class CodeGraphCodeVisionProvider : DaemonBoundCodeVisionProvider { return ClickableTextCodeVisionEntry( parts.joinToString(" · "), ID, - { _, _ -> }, + { event, clickedIn -> showCallGraph(event, clickedIn) }, null, parts.joinToString(", "), tooltipFor(symbol), @@ -74,6 +81,19 @@ class CodeGraphCodeVisionProvider : DaemonBoundCodeVisionProvider { ) } + /** + * 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}") @@ -96,6 +116,9 @@ class CodeGraphCodeVisionProvider : DaemonBoundCodeVisionProvider { 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. diff --git a/jetbrains/src/main/resources/META-INF/plugin.xml b/jetbrains/src/main/resources/META-INF/plugin.xml index 92710d8..07074ff 100644 --- a/jetbrains/src/main/resources/META-INF/plugin.xml +++ b/jetbrains/src/main/resources/META-INF/plugin.xml @@ -15,9 +15,12 @@

Requirements

- This plugin needs the CodeGraph engine installed separately: - npm i -g @astudioplus/codegraph-mcp. Your code is analysed - locally by that engine and is never uploaded. + 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

diff --git a/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/mcp/McpRegistrationTest.kt b/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/mcp/McpRegistrationTest.kt index 664b99f..a6ca961 100644 --- a/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/mcp/McpRegistrationTest.kt +++ b/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/mcp/McpRegistrationTest.kt @@ -28,6 +28,11 @@ class McpRegistrationTest : BasePlatformTestCase() { // 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) @@ -41,6 +46,7 @@ class McpRegistrationTest : BasePlatformTestCase() { override fun tearDown() { try { CodeGraphSettings.getInstance(project).state.serverPath = "" + clearConfig() projectDir.toFile().deleteRecursively() } finally { super.tearDown() @@ -49,6 +55,14 @@ class McpRegistrationTest : BasePlatformTestCase() { 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) } @@ -113,6 +127,31 @@ class McpRegistrationTest : BasePlatformTestCase() { 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)) } diff --git a/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/CodeGraphServerResolverTest.kt b/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/CodeGraphServerResolverTest.kt index 6c95bd2..1fa2203 100644 --- a/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/CodeGraphServerResolverTest.kt +++ b/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/CodeGraphServerResolverTest.kt @@ -160,6 +160,40 @@ class CodeGraphServerResolverTest : BasePlatformTestCase() { } } + fun `test arm64 linux and windows have no published engine`() { + // Only macOS is built for both architectures. Handing the x64 asset to + // an arm64 machine installs something that cannot execute, which shows + // up as an exec-format error rather than as the missing build it is. + fun nameFor(os: String, arch: String) = CodeGraphServerResolver.platformBinaryNameOrNull( + ResolverEnvironment(fakeHome, emptyList(), os, arch), + ) + + assertNull(nameFor("Linux", "aarch64")) + assertNull(nameFor("Windows 11", "aarch64")) + assertNull(nameFor("Linux", "arm64")) + assertEquals("codegraph-server-linux-x64", nameFor("Linux", "x86_64")) + assertEquals("codegraph-server-darwin-arm64", nameFor("Mac OS X", "aarch64")) + } + + 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() diff --git a/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/CrashBreadcrumbsTest.kt b/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/CrashBreadcrumbsTest.kt index d4f8970..83b05bc 100644 --- a/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/CrashBreadcrumbsTest.kt +++ b/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/CrashBreadcrumbsTest.kt @@ -23,6 +23,13 @@ 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") @@ -33,7 +40,8 @@ class CrashBreadcrumbsTest { dir.toFile().deleteRecursively() } - private fun breadcrumbs() = CrashBreadcrumbs(directory = dir, clock = { now }) + 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) @@ -122,4 +130,47 @@ class CrashBreadcrumbsTest { 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 index 4342ab3..14ef066 100644 --- a/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/EngineDownloaderTest.kt +++ b/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/EngineDownloaderTest.kt @@ -63,7 +63,13 @@ class EngineDownloaderTest : BasePlatformTestCase() { private fun env(os: String, arch: String = "aarch64") = ResolverEnvironment(homeDir = home, pathEntries = emptyList(), osName = os, osArch = arch) - private fun downloader(os: String) = EngineDownloader(env(os), baseUrl()) + /** + * 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() @@ -79,7 +85,7 @@ class EngineDownloaderTest : BasePlatformTestCase() { 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").download("0.19.1") + val path = downloader("Windows 11", "amd64").download("0.19.1") assertTrue(Files.exists(path)) assertTrue( @@ -88,6 +94,32 @@ class EngineDownloaderTest : BasePlatformTestCase() { ) } + 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)) @@ -125,7 +157,7 @@ class EngineDownloaderTest : BasePlatformTestCase() { 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").download("0.19.1") }.exceptionOrNull() + val failure = runCatching { downloader("Windows 11", "amd64").download("0.19.1") }.exceptionOrNull() assertTrue(failure is EngineDownloader.ChecksumMismatchException) assertFalse( diff --git a/mcp-package/bin/fetch-engine.js b/mcp-package/bin/fetch-engine.js index 9d7e503..04ebc79 100644 --- a/mcp-package/bin/fetch-engine.js +++ b/mcp-package/bin/fetch-engine.js @@ -44,6 +44,15 @@ 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 @@ -53,10 +62,13 @@ function platformBinaryName(platform = os.platform(), arch = os.arch()) { const p = PLATFORM_MAP[platform]; const a = ARCH_MAP[arch]; if (!p || !a) return null; - // Only x64 is published for Windows and Linux today. - if (p === "win32") return "codegraph-server-win32-x64.exe"; - if (p === "linux") return "codegraph-server-linux-x64"; - return `codegraph-server-darwin-${a}`; + // macOS is the only platform published for both architectures. + if (p === "darwin") return `codegraph-server-darwin-${a}`; + // Only x64 is published for Windows and Linux today. Handing the x64 build to + // an arm64 machine installs ~30 MB that cannot execute at all, which surfaces + // as an exec-format error at first use rather than as an unsupported platform. + if (a !== "x64") return null; + return p === "win32" ? "codegraph-server-win32-x64.exe" : "codegraph-server-linux-x64"; } /** Everything this platform needs on disk, in the order it should be fetched. */ @@ -71,6 +83,19 @@ function requiredAssets(platform = os.platform(), arch = os.arch()) { 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. @@ -82,12 +107,13 @@ function download(url, destination, { redirects = 5 } = {}) { res.resume(); return reject(new Error(`HTTP ${res.statusCode} for ${url}`)); } - const file = fs.createWriteStream(destination); - res.pipe(file); + file = fs.createWriteStream(destination); + res.on("error", fail); + file.on("error", fail); file.on("finish", () => file.close(resolve)); - file.on("error", reject); + res.pipe(file); }) - .on("error", reject); + .on("error", fail); }); } @@ -151,7 +177,23 @@ async function fetchVerified(asset, version, targetDir, { baseUrl = RELEASE_BASE } /** - * Ensure the engine for this platform is present in targetDir. + * 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; + } +} + +/** + * 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". * * @returns {Promise<{binary: string, fetched: string[]}>} path to the engine * and which assets were downloaded (empty when everything was already there). @@ -164,14 +206,18 @@ async function ensureEngine(version, targetDir, options = {}) { fs.mkdirSync(targetDir, { recursive: true }); + const stale = installedVersion(targetDir) !== version; const fetched = []; for (const asset of assets) { const destination = path.join(targetDir, asset); - if (fs.existsSync(destination) && !options.force) continue; + if (fs.existsSync(destination) && !options.force && !stale) continue; if (options.onProgress) options.onProgress(asset); await fetchVerified(asset, version, targetDir, options); fetched.push(asset); } + // Written last: a marker recorded before the assets are verified would claim + // an install that a later failure never completed. + fs.writeFileSync(path.join(targetDir, VERSION_MARKER), `${version}\n`); const binary = path.join(targetDir, assets[0]); if (os.platform() !== "win32") { @@ -188,8 +234,10 @@ async function ensureEngine(version, targetDir, options = {}) { module.exports = { RELEASE_BASE, WINDOWS_SIDECAR, + VERSION_MARKER, platformBinaryName, requiredAssets, + installedVersion, ensureEngine, fetchVerified, sha256, diff --git a/mcp-package/bin/postinstall.js b/mcp-package/bin/postinstall.js index d0f3afa..be8a6f8 100644 --- a/mcp-package/bin/postinstall.js +++ b/mcp-package/bin/postinstall.js @@ -6,31 +6,21 @@ 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 } = 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 { ensureEngine, platformBinaryName } = require("./fetch-engine"); - -const binaryName = platformBinaryName(); const binaryPath = path.join(__dirname, binaryName); const version = require("../package.json").version; diff --git a/mcp-package/test/fetch-engine.test.js b/mcp-package/test/fetch-engine.test.js index 2b8135f..760fd76 100644 --- a/mcp-package/test/fetch-engine.test.js +++ b/mcp-package/test/fetch-engine.test.js @@ -23,8 +23,14 @@ const http = require("http"); const os = require("os"); const path = require("path"); -const { ensureEngine, requiredAssets, platformBinaryName, WINDOWS_SIDECAR } = - require("../bin/fetch-engine"); +const { + ensureEngine, + requiredAssets, + platformBinaryName, + installedVersion, + WINDOWS_SIDECAR, + VERSION_MARKER, +} = require("../bin/fetch-engine"); const VERSION = "0.20.0"; let failures = 0; @@ -107,16 +113,17 @@ async function run() { } } - // --- an already-present engine is not re-downloaded ------------------ + // --- 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 is left alone"); + 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(); @@ -124,6 +131,36 @@ async function run() { } } + // --- 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 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"); + } + // --- a missing release fails loudly ---------------------------------- { const dir = scratch(); @@ -139,6 +176,39 @@ async function run() { } } + // --- 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 }); + } + } + // --- the windows sidecar rule ---------------------------------------- check( requiredAssets("win32", "x64").includes(WINDOWS_SIDECAR), @@ -149,6 +219,18 @@ async function run() { "other platforms do not" ); + // --- only published platform/arch pairs resolve to an asset ---------- + // An x64 asset handed to an arm64 machine downloads and chmods cleanly and + // then fails to exec, which is far harder to read than "not published". + check(platformBinaryName("linux", "arm64") === null, "linux-arm64 has no published engine"); + check(platformBinaryName("win32", "arm64") === null, "win32-arm64 has no published engine"); + 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); diff --git a/scripts/package-vsix.sh b/scripts/package-vsix.sh index 1e9a9a3..90924bc 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 "" diff --git a/vscode/src/engineDownload.ts b/vscode/src/engineDownload.ts index 12050ea..c04d504 100644 --- a/vscode/src/engineDownload.ts +++ b/vscode/src/engineDownload.ts @@ -14,6 +14,7 @@ //! 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'; @@ -64,6 +65,34 @@ export async function downloadEngine(version: string): Promise { ); } +/** + * Bring a managed engine installed by an earlier extension version up to + * [version]. + * + * 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. + * + * A failed update is not fatal: the engine already on disk still runs, and + * refusing to start over one version of drift is worse than the drift. + */ +export async function upgradeManagedEngine(version: string): Promise { + const engine = managedEnginePath(); + if (!engine || !fs.existsSync(engine)) { + return; + } + if (fetchEngine.installedVersion(managedInstallDir()) === version) { + return; + } + + try { + await downloadEngine(version); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[CodeGraph] Could not update the managed engine to ${version}: ${message}`); + } +} + /** * Ask whether to download, then do it. * diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index 98efdf5..c54309d 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -17,7 +17,7 @@ import { registerCodeLens } from './views/codeLensProvider'; import { CodeGraphAIProvider } from './ai/contextProvider'; import { CodeGraphToolManager } from './ai/toolManager'; import { getServerPath } from './server'; -import { offerEngineDownload } from './engineDownload'; +import { managedEnginePath, offerEngineDownload, upgradeManagedEngine } from './engineDownload'; import { createReporter, setServerEdition, type Reporter } from './telemetry/reporter'; import { detectMachineProfile } from './telemetry/machineProfile'; import { handleIndexOutcome, filesIndexed, reportIndexTelemetry } from './funnel'; @@ -292,6 +292,16 @@ export async function activate(context: vscode.ExtensionContext): Promise } 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 bring it up to this version before starting + // it - only when it is the binary we actually resolved, since a pro, + // bundled or locally built engine is the user's to manage. + if (serverInfo.path === managedEnginePath()) { + await upgradeManagedEngine(context.extension.packageJSON.version); + } + setServerEdition(serverInfo.edition === 'pro' ? 'pro' : 'community'); // Log server path for debugging From 8a213594b736662abe9a816cf7aa7172eed0c9c1 Mon Sep 17 00:00:00 2001 From: Andrey Vasilevsky Date: Sun, 2 Aug 2026 00:55:52 -0700 Subject: [PATCH 19/31] no-mistakes(review): fix engine update safety, platform mapping, and graph panel defects --- crates/codegraph-server/src/backend.rs | 93 ++++++++++++------- .../jetbrains/actions/ShowGraphAction.kt | 26 +++++- .../ai/codegraph/jetbrains/graph/GraphHtml.kt | 47 +++++++++- .../codegraph/jetbrains/graph/GraphPanel.kt | 7 +- .../indexing/IndexingStartupActivity.kt | 8 +- .../jetbrains/lsp/CodeGraphClient.kt | 43 +++++++++ .../server/CodeGraphServerResolver.kt | 57 ++++++++++-- .../jetbrains/server/EngineDownloader.kt | 77 ++++++++++++--- .../jetbrains/server/EngineInstaller.kt | 45 +++++---- .../jetbrains/graph/GraphDataTest.kt | 15 +++ .../server/CodeGraphServerResolverTest.kt | 28 +++++- .../jetbrains/server/EngineDownloaderTest.kt | 31 +++++-- mcp-package/bin/fetch-engine.js | 81 ++++++++++++++-- mcp-package/test/fetch-engine.test.js | 46 ++++++++- vscode/src/engineDownload.ts | 39 ++++++-- vscode/src/extension.ts | 15 ++- vscode/src/server.ts | 47 +++++----- 17 files changed, 565 insertions(+), 140 deletions(-) diff --git a/crates/codegraph-server/src/backend.rs b/crates/codegraph-server/src/backend.rs index 1c487b7..14a6acf 100644 --- a/crates/codegraph-server/src/backend.rs +++ b/crates/codegraph-server/src/backend.rs @@ -128,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>>, @@ -183,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)), @@ -236,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)), @@ -249,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 @@ -256,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(), @@ -324,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) => { @@ -373,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 { @@ -978,15 +1000,16 @@ impl LanguageServer for CodeGraphBackend { ); } - // Safety: We're replacing the Arc contents during initialization before any use - let new_manager = Arc::new(MemoryManager::with_model( - extension_path.clone(), - embedding_model, - )); - let self_mut = self as *const Self as *mut Self; - unsafe { - (*self_mut).memory_manager = new_manager; - } + // 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() @@ -1298,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 @@ -1306,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); @@ -2495,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) { @@ -2668,7 +2691,7 @@ impl CodeGraphBackend { // 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| { + 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(); @@ -2715,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())?; @@ -2759,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())?; @@ -2870,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())?; @@ -2885,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())?; @@ -2972,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())?; @@ -3026,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())?; @@ -3214,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())?; @@ -3256,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())?; @@ -3346,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); @@ -3383,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; } @@ -3435,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; } @@ -3503,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); @@ -3667,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; @@ -3903,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; @@ -3970,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 { diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/actions/ShowGraphAction.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/actions/ShowGraphAction.kt index b43b6c2..dd7bf44 100644 --- a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/actions/ShowGraphAction.kt +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/actions/ShowGraphAction.kt @@ -38,17 +38,37 @@ sealed class ShowGraphAction(private val kind: GraphKind) : AnAction() { CodeGraphClient.getInstance(project).start() val toolWindow = ToolWindowManager.getInstance(project).getToolWindow(TOOL_WINDOW_ID) ?: return - val panel = GraphPanel(project, kind) + 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) - toolWindow.contentManager.addContent(content) - toolWindow.contentManager.setSelectedContent(content) + contentManager.addContent(content) + contentManager.setSelectedContent(content) toolWindow.show() panel.load(file.url) diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/graph/GraphHtml.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/graph/GraphHtml.kt index ab23e38..14d8148 100644 --- a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/graph/GraphHtml.kt +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/graph/GraphHtml.kt @@ -41,10 +41,45 @@ object GraphHtml { 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 graph.nodes.map { node -> + "nodes" to drawn.nodes.map { node -> mapOf( "id" to node.id, "label" to node.label, @@ -52,9 +87,14 @@ object GraphHtml { "title" to "${node.label}\n${node.type}${if (node.language.isNotBlank()) " · ${node.language}" else ""}", ) }, - "edges" to graph.edges.map { mapOf("from" to it.from, "to" to it.to) }, + "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. @@ -71,6 +111,8 @@ object GraphHtml { html, body { margin: 0; height: 100%; background: $background; color: $foreground; font-family: -apple-system, "Segoe UI", sans-serif; overflow: hidden; } #empty { padding: 24px; font-size: 13px; opacity: 0.7; } + #truncated { position: absolute; top: 0; left: 0; right: 0; padding: 6px 10px; + font-size: 11px; opacity: 0.7; pointer-events: none; } svg { width: 100%; height: 100%; display: block; cursor: grab; } line { stroke: $foreground; stroke-opacity: 0.25; } circle { stroke: $background; stroke-width: 1.5px; cursor: pointer; } @@ -79,6 +121,7 @@ object GraphHtml {

+
$truncationNote