From 984316d260e2e247968f277044bcb7a7102d85b7 Mon Sep 17 00:00:00 2001 From: Dmytro Date: Wed, 25 Feb 2026 15:43:12 +0100 Subject: [PATCH 01/12] feat: add Codex MCP and skills support --- README.md | 13 ++++-- gitnexus/README.md | 9 +++- gitnexus/package.json | 1 + gitnexus/src/cli/ai-context.ts | 3 +- gitnexus/src/cli/index.ts | 2 +- gitnexus/src/cli/setup.ts | 84 +++++++++++++++++++++++++++++++++- 6 files changed, 104 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 2cf84826f..f24d3f4df 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ https://github.com/user-attachments/assets/172685ba-8e54-4ea7-9ad1-e31a3398da72 > *Like DeepWiki, but deeper.* DeepWiki helps you *understand* code. GitNexus lets you *analyze* it — because a knowledge graph tracks every relationship, not just descriptions. -**TL;DR:** The **Web UI** is a quick way to chat with any repo. The **CLI + MCP** is how you make your AI agent actually reliable — it gives Cursor, Claude Code, and friends a deep architectural view of your codebase so they stop missing dependencies, breaking call chains, and shipping blind edits. Even smaller models get full architectural clarity, making it compete with goliath models. +**TL;DR:** The **Web UI** is a quick way to chat with any repo. The **CLI + MCP** is how you make your AI agent actually reliable — it gives Cursor, Claude Code, Codex, and friends a deep architectural view of your codebase so they stop missing dependencies, breaking call chains, and shipping blind edits. Even smaller models get full architectural clarity, making it compete with goliath models. --- @@ -31,7 +31,7 @@ https://github.com/user-attachments/assets/172685ba-8e54-4ea7-9ad1-e31a3398da72 | | **CLI + MCP** | **Web UI** | | ----------------- | -------------------------------------------------------------- | ------------------------------------------------------------ | | **What** | Index repos locally, connect AI agents via MCP | Visual graph explorer + AI chat in browser | -| **For** | Daily development with Cursor, Claude Code, Windsurf, OpenCode | Quick exploration, demos, one-off analysis | +| **For** | Daily development with Cursor, Claude Code, Codex, Windsurf, OpenCode | Quick exploration, demos, one-off analysis | | **Scale** | Full repos, any size | Limited by browser memory (~5k files), or unlimited via backend mode | | **Install** | `npm install -g gitnexus` | No install —[gitnexus.vercel.app](https://gitnexus.vercel.app) | | **Storage** | KuzuDB native (fast, persistent) | KuzuDB WASM (in-memory, per session) | @@ -67,6 +67,7 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up | --------------------- | --- | ------ | -------------------- | -------------- | | **Claude Code** | Yes | Yes | Yes (PreToolUse) | **Full** | | **Cursor** | Yes | Yes | — | MCP + Skills | +| **Codex** | Yes | Yes | — | MCP + Skills | | **Windsurf** | Yes | — | — | MCP | | **OpenCode** | Yes | Yes | — | MCP + Skills | @@ -86,6 +87,12 @@ If you prefer manual configuration: claude mcp add gitnexus -- npx -y gitnexus@latest mcp ``` +**Codex** (full support — MCP + skills): + +```bash +codex mcp add gitnexus -- npx -y gitnexus@latest mcp +``` + **Cursor** (`~/.cursor/mcp.json` — global, works for all projects): ```json @@ -247,7 +254,7 @@ The web UI uses the same indexing pipeline as the CLI but runs entirely in WebAs ## The Problem GitNexus Solves -Tools like **Cursor**, **Claude Code**, **Cline**, **Roo Code**, and **Windsurf** are powerful — but they don't truly know your codebase structure. +Tools like **Cursor**, **Claude Code**, **Codex**, **Cline**, **Roo Code**, and **Windsurf** are powerful — but they don't truly know your codebase structure. **What happens:** diff --git a/gitnexus/README.md b/gitnexus/README.md index e6aa62940..16383afe6 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -2,7 +2,7 @@ **Graph-powered code intelligence for AI agents.** Index any codebase into a knowledge graph, then query it via MCP or CLI. -Works with **Cursor**, **Claude Code**, **Windsurf**, **Cline**, **OpenCode**, and any MCP-compatible tool. +Works with **Cursor**, **Claude Code**, **Codex**, **Windsurf**, **Cline**, **OpenCode**, and any MCP-compatible tool. [![npm version](https://img.shields.io/npm/v/gitnexus.svg)](https://www.npmjs.com/package/gitnexus) [![License: PolyForm Noncommercial](https://img.shields.io/badge/License-PolyForm%20Noncommercial-blue.svg)](https://polyformproject.org/licenses/noncommercial/1.0.0/) @@ -34,6 +34,7 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up |--------|-----|--------|---------------------|---------| | **Claude Code** | Yes | Yes | Yes (PreToolUse) | **Full** | | **Cursor** | Yes | Yes | — | MCP + Skills | +| **Codex** | Yes | Yes | — | MCP + Skills | | **Windsurf** | Yes | — | — | MCP | | **OpenCode** | Yes | Yes | — | MCP + Skills | @@ -55,6 +56,12 @@ If you prefer to configure manually instead of using `gitnexus setup`: claude mcp add gitnexus -- npx -y gitnexus@latest mcp ``` +### Codex (full support — MCP + skills) + +```bash +codex mcp add gitnexus -- npx -y gitnexus@latest mcp +``` + ### Cursor / Windsurf Add to `~/.cursor/mcp.json` (global — works for all projects): diff --git a/gitnexus/package.json b/gitnexus/package.json index 261be4bfc..6cbeacab5 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -20,6 +20,7 @@ "knowledge-graph", "cursor", "claude", + "codex", "ai-agent", "gitnexus", "static-analysis", diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index 6f8d1ede6..c3bf375af 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -2,7 +2,7 @@ * AI Context Generator * * Creates AGENTS.md and CLAUDE.md with full inline GitNexus context. - * AGENTS.md is the standard read by Cursor, Windsurf, OpenCode, Cline, etc. + * AGENTS.md is the standard read by Cursor, Windsurf, OpenCode, Codex, Cline, etc. * CLAUDE.md is for Claude Code which only reads that file. */ @@ -250,4 +250,3 @@ export async function generateAIContextFiles( return { files: createdFiles }; } - diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index d5e5b84e0..fdcbb1f16 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -20,7 +20,7 @@ program program .command('setup') - .description('One-time setup: configure MCP for Cursor, Claude Code, OpenCode') + .description('One-time setup: configure MCP for Cursor, Claude Code, OpenCode, Codex') .action(setupCommand); program diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index a68b3ac0a..312432971 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -9,11 +9,14 @@ import fs from 'fs/promises'; import path from 'path'; import os from 'os'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; import { fileURLToPath } from 'url'; import { getGlobalDir } from '../storage/repo-manager.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +const execFileAsync = promisify(execFile); interface SetupResult { configured: string[]; @@ -222,6 +225,65 @@ async function setupOpenCode(result: SetupResult): Promise { } } +/** + * Build a TOML section for Codex MCP config (~/.codex/config.toml). + */ +function getCodexMcpTomlSection(): string { + const entry = getMcpEntry(); + const command = JSON.stringify(entry.command); + const args = `[${entry.args.map(arg => JSON.stringify(arg)).join(', ')}]`; + return `[mcp_servers.gitnexus]\ncommand = ${command}\nargs = ${args}\n`; +} + +/** + * Append GitNexus MCP server config to Codex's config.toml if missing. + */ +async function upsertCodexConfigToml(configPath: string): Promise { + let existing = ''; + try { + existing = await fs.readFile(configPath, 'utf-8'); + } catch { + existing = ''; + } + + if (existing.includes('[mcp_servers.gitnexus]')) { + return; + } + + const section = getCodexMcpTomlSection(); + const nextContent = existing.trim().length > 0 + ? `${existing.trimEnd()}\n\n${section}` + : section; + + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, `${nextContent.trimEnd()}\n`, 'utf-8'); +} + +async function setupCodex(result: SetupResult): Promise { + const codexDir = path.join(os.homedir(), '.codex'); + if (!(await dirExists(codexDir))) { + result.skipped.push('Codex (not installed)'); + return; + } + + try { + const entry = getMcpEntry(); + await execFileAsync('codex', ['mcp', 'add', 'gitnexus', '--', entry.command, ...entry.args]); + result.configured.push('Codex'); + return; + } catch { + // Fallback for environments where `codex` binary isn't on PATH. + } + + try { + const configPath = path.join(codexDir, 'config.toml'); + await upsertCodexConfigToml(configPath); + result.configured.push('Codex (MCP added to ~/.codex/config.toml)'); + } catch (err: any) { + result.errors.push(`Codex: ${err.message}`); + } +} + // ─── Skill Installation ─────────────────────────────────────────── const SKILL_NAMES = ['exploring', 'debugging', 'impact-analysis', 'refactoring']; @@ -229,7 +291,7 @@ const SKILL_NAMES = ['exploring', 'debugging', 'impact-analysis', 'refactoring'] /** * Install GitNexus skills to a target directory. * Each skill is installed as {targetDir}/gitnexus-{skillName}/SKILL.md - * following the Agent Skills standard (both Cursor and Claude Code). + * following the Agent Skills standard (Cursor, Claude Code, and Codex). * * Supports two source layouts: * - Flat file: skills/{name}.md → copied as SKILL.md @@ -325,6 +387,24 @@ async function installOpenCodeSkills(result: SetupResult): Promise { } } +/** + * Install global Codex skills to ~/.agents/skills/gitnexus/ + */ +async function installCodexSkills(result: SetupResult): Promise { + const codexDir = path.join(os.homedir(), '.codex'); + if (!(await dirExists(codexDir))) return; + + const skillsDir = path.join(os.homedir(), '.agents', 'skills'); + try { + const installed = await installSkillsTo(skillsDir); + if (installed.length > 0) { + result.configured.push(`Codex skills (${installed.length} skills → ~/.agents/skills/)`); + } + } catch (err: any) { + result.errors.push(`Codex skills: ${err.message}`); + } +} + // ─── Main command ────────────────────────────────────────────────── export const setupCommand = async () => { @@ -347,12 +427,14 @@ export const setupCommand = async () => { await setupCursor(result); await setupClaudeCode(result); await setupOpenCode(result); + await setupCodex(result); // Install global skills for platforms that support them await installClaudeCodeSkills(result); await installClaudeCodeHooks(result); await installCursorSkills(result); await installOpenCodeSkills(result); + await installCodexSkills(result); // Print results if (result.configured.length > 0) { From 2915e60630ac667fbeab48d12345d12f6bbc996c Mon Sep 17 00:00:00 2001 From: Dmytro Date: Fri, 27 Feb 2026 14:01:10 +0100 Subject: [PATCH 02/12] fix(cli): run codex mcp add via shell on Windows --- gitnexus/src/cli/setup.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index 312432971..16c6303f9 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -268,7 +268,11 @@ async function setupCodex(result: SetupResult): Promise { try { const entry = getMcpEntry(); - await execFileAsync('codex', ['mcp', 'add', 'gitnexus', '--', entry.command, ...entry.args]); + await execFileAsync( + 'codex', + ['mcp', 'add', 'gitnexus', '--', entry.command, ...entry.args], + { shell: process.platform === 'win32' } + ); result.configured.push('Codex'); return; } catch { From 999fbf5b112ca3ef73292d7e80ae34d5fd3c1567 Mon Sep 17 00:00:00 2001 From: hiromima Date: Fri, 20 Mar 2026 13:33:20 +0900 Subject: [PATCH 03/12] fix: sequential enrichment queries + stale data detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes three related issues that cause SIGSEGV crashes and stale data: 1. Impact enrichment queries (Promise.all → sequential await) The impact() method ran 3 enrichment queries concurrently via Promise.all against the same LadybugDB connection pool. On arm64 macOS, concurrent native DB access triggers SIGSEGV. Changed to sequential await. Also caps IN-clause to 100 IDs to prevent oversized queries. (#285, #290, #292) 2. Silence stdout during query execution silenceStdout()/restoreStdout() only wrapped createConnection() and initLbug(). Now also wraps executeQuery() and executeParameterized() to prevent native stdout writes from corrupting the MCP stdio stream during all DB operations. (#285) 3. Stale data after re-index ensureInitialized() checked pool existence but never verified whether the underlying index was rebuilt. Now reads meta.json's indexedAt timestamp on each call and closes/re-opens the pool when the index has changed. (#297) --- gitnexus/src/mcp/core/lbug-adapter.ts | 4 ++ gitnexus/src/mcp/local/local-backend.ts | 77 ++++++++++++++++--------- 2 files changed, 54 insertions(+), 27 deletions(-) diff --git a/gitnexus/src/mcp/core/lbug-adapter.ts b/gitnexus/src/mcp/core/lbug-adapter.ts index cf9bb1ad6..888e18b56 100644 --- a/gitnexus/src/mcp/core/lbug-adapter.ts +++ b/gitnexus/src/mcp/core/lbug-adapter.ts @@ -458,12 +458,14 @@ export const executeQuery = async (repoId: string, cypher: string): Promise { - // Always check the actual pool — the idle timer may have evicted the connection - if (this.initializedRepos.has(repoId) && isLbugReady(repoId)) return; - const handle = this.repos.get(repoId); if (!handle) throw new Error(`Unknown repo: ${repoId}`); + // Check if the index was rebuilt since we opened the connection (#297). + // Read meta.json's indexedAt and compare to the cached value — if it + // changed, close the stale pool and re-initialize with the fresh index. + if (this.initializedRepos.has(repoId) && isLbugReady(repoId)) { + try { + const metaPath = path.join(handle.storagePath, 'meta.json'); + const metaRaw = await fs.readFile(metaPath, 'utf-8'); + const meta = JSON.parse(metaRaw); + if (meta.indexedAt && meta.indexedAt !== handle.indexedAt) { + // Index was rebuilt — close stale connection and re-init + await closeLbug(repoId); + this.initializedRepos.delete(repoId); + handle.indexedAt = meta.indexedAt; + } else { + return; // Pool is current + } + } catch { + return; // Can't read meta — assume pool is fine + } + } + try { await initLbug(repoId, handle.lbugPath); this.initializedRepos.add(repoId); @@ -1438,31 +1456,36 @@ export class LocalBackend { let affectedModules: any[] = []; if (impacted.length > 0) { - const allIds = impacted.map(i => `'${i.id.replace(/'/g, "''")}'`).join(', '); - const d1Ids = (grouped[1] || []).map((i: any) => `'${i.id.replace(/'/g, "''")}'`).join(', '); + // Cap IN-clause to 100 IDs to prevent oversized queries that crash + // the native DB engine on arm64 macOS (#292) + const cappedImpacted = impacted.slice(0, 100); + const allIds = cappedImpacted.map(i => `'${i.id.replace(/'/g, "''")}'`).join(', '); + const d1Items = (grouped[1] || []).slice(0, 100); + const d1Ids = d1Items.map((i: any) => `'${i.id.replace(/'/g, "''")}'`).join(', '); - // Affected processes: which execution flows are broken and at which step - const [processRows, moduleRows, directModuleRows] = await Promise.all([ - executeQuery(repo.id, ` - MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process) - WHERE s.id IN [${allIds}] - RETURN p.heuristicLabel AS name, COUNT(DISTINCT s.id) AS hits, MIN(r.step) AS minStep, p.stepCount AS stepCount - ORDER BY hits DESC - LIMIT 20 - `).catch(() => []), - executeQuery(repo.id, ` - MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community) - WHERE s.id IN [${allIds}] - RETURN c.heuristicLabel AS name, COUNT(DISTINCT s.id) AS hits - ORDER BY hits DESC - LIMIT 20 - `).catch(() => []), - d1Ids ? executeQuery(repo.id, ` - MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community) - WHERE s.id IN [${d1Ids}] - RETURN DISTINCT c.heuristicLabel AS name - `).catch(() => []) : Promise.resolve([]), - ]); + // Run enrichment queries sequentially to avoid concurrent native DB + // access that causes SIGSEGV on arm64 macOS (#285, #290, #292) + const processRows = await executeQuery(repo.id, ` + MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process) + WHERE s.id IN [${allIds}] + RETURN p.heuristicLabel AS name, COUNT(DISTINCT s.id) AS hits, MIN(r.step) AS minStep, p.stepCount AS stepCount + ORDER BY hits DESC + LIMIT 20 + `).catch(() => []); + const moduleRows = await executeQuery(repo.id, ` + MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community) + WHERE s.id IN [${allIds}] + RETURN c.heuristicLabel AS name, COUNT(DISTINCT s.id) AS hits + ORDER BY hits DESC + LIMIT 20 + `).catch(() => []); + const directModuleRows = d1Ids + ? await executeQuery(repo.id, ` + MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community) + WHERE s.id IN [${d1Ids}] + RETURN DISTINCT c.heuristicLabel AS name + `).catch(() => []) + : []; affectedProcesses = processRows.map((r: any) => ({ name: r.name || r[0], From 893f77ae8982f330b50607399cc95de25ebaa40c Mon Sep 17 00:00:00 2001 From: hiromima Date: Fri, 20 Mar 2026 16:52:33 +0900 Subject: [PATCH 04/12] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20watchdog,=20TOCTOU=20race,=20null=20guard,=20platfo?= =?UTF-8?q?rm=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Watchdog timer now exempts in-flight queries via activeQueryCount, preventing premature stdout restoration during long queries (>1s) - Stale detection uses reinitPromises Map to prevent TOCTOU race where concurrent callers double-close the connection pool - Throttle meta.json staleness checks to once per 5s per repo - Add null guard for i.id in IN-clause construction - Enrichment queries run in parallel on non-arm64 platforms to preserve performance; sequential only on arm64 macOS where SIGSEGV occurs --- gitnexus/src/mcp/core/lbug-adapter.ts | 10 +++- gitnexus/src/mcp/local/local-backend.ts | 68 +++++++++++++++++++------ 2 files changed, 62 insertions(+), 16 deletions(-) diff --git a/gitnexus/src/mcp/core/lbug-adapter.ts b/gitnexus/src/mcp/core/lbug-adapter.ts index 888e18b56..0de368d73 100644 --- a/gitnexus/src/mcp/core/lbug-adapter.ts +++ b/gitnexus/src/mcp/core/lbug-adapter.ts @@ -148,6 +148,8 @@ function closeOne(repoId: string): void { * Create a new Connection from a repo's Database. * Silences stdout to prevent native module output from corrupting MCP stdio. */ +let activeQueryCount = 0; + function silenceStdout(): void { if (stdoutSilenceCount++ === 0) { process.stdout.write = (() => true) as any; @@ -163,8 +165,10 @@ function restoreStdout(): void { // Safety watchdog: restore stdout if it gets stuck silenced (e.g. native crash // inside createConnection before restoreStdout runs). +// Exempts active queries and pre-warm — these legitimately hold silence for +// longer than 1 second (queries can take up to QUERY_TIMEOUT_MS = 30s). setInterval(() => { - if (stdoutSilenceCount > 0 && !preWarmActive) { + if (stdoutSilenceCount > 0 && !preWarmActive && activeQueryCount === 0) { stdoutSilenceCount = 0; process.stdout.write = realStdoutWrite; } @@ -459,12 +463,14 @@ export const executeQuery = async (repoId: string, cypher: string): Promise = new Map(); private contextCache: Map = new Map(); private initializedRepos: Set = new Set(); + private reinitPromises: Map> = new Map(); + private lastStalenessCheck: Map = new Map(); // ─── Initialization ────────────────────────────────────────────── @@ -246,22 +248,43 @@ export class LocalBackend { // ─── Lazy LadybugDB Init ──────────────────────────────────────────── private async ensureInitialized(repoId: string): Promise { + // If a reinit is already in progress for this repo, wait for it + const pending = this.reinitPromises.get(repoId); + if (pending) return pending; + const handle = this.repos.get(repoId); if (!handle) throw new Error(`Unknown repo: ${repoId}`); // Check if the index was rebuilt since we opened the connection (#297). - // Read meta.json's indexedAt and compare to the cached value — if it - // changed, close the stale pool and re-initialize with the fresh index. + // Throttle staleness checks to at most once per 5 seconds per repo to + // avoid an fs.readFile round-trip on every tool invocation. if (this.initializedRepos.has(repoId) && isLbugReady(repoId)) { + const now = Date.now(); + const lastCheck = this.lastStalenessCheck.get(repoId) ?? 0; + if (now - lastCheck < 5000) return; // Checked recently — skip + + this.lastStalenessCheck.set(repoId, now); try { const metaPath = path.join(handle.storagePath, 'meta.json'); const metaRaw = await fs.readFile(metaPath, 'utf-8'); const meta = JSON.parse(metaRaw); if (meta.indexedAt && meta.indexedAt !== handle.indexedAt) { - // Index was rebuilt — close stale connection and re-init - await closeLbug(repoId); - this.initializedRepos.delete(repoId); - handle.indexedAt = meta.indexedAt; + // Index was rebuilt — close stale connection and re-init. + // Wrap in reinitPromises to prevent TOCTOU race where concurrent + // callers both detect staleness and double-close the pool. + const reinit = (async () => { + try { + await closeLbug(repoId); + this.initializedRepos.delete(repoId); + handle.indexedAt = meta.indexedAt; + await initLbug(repoId, handle.lbugPath); + this.initializedRepos.add(repoId); + } finally { + this.reinitPromises.delete(repoId); + } + })(); + this.reinitPromises.set(repoId, reinit); + return reinit; } else { return; // Pool is current } @@ -1459,33 +1482,48 @@ export class LocalBackend { // Cap IN-clause to 100 IDs to prevent oversized queries that crash // the native DB engine on arm64 macOS (#292) const cappedImpacted = impacted.slice(0, 100); - const allIds = cappedImpacted.map(i => `'${i.id.replace(/'/g, "''")}'`).join(', '); + const allIds = cappedImpacted.map(i => `'${String(i.id ?? '').replace(/'/g, "''")}'`).join(', '); const d1Items = (grouped[1] || []).slice(0, 100); - const d1Ids = d1Items.map((i: any) => `'${i.id.replace(/'/g, "''")}'`).join(', '); + const d1Ids = d1Items.map((i: any) => `'${String(i.id ?? '').replace(/'/g, "''")}'`).join(', '); - // Run enrichment queries sequentially to avoid concurrent native DB - // access that causes SIGSEGV on arm64 macOS (#285, #290, #292) - const processRows = await executeQuery(repo.id, ` + // Enrichment queries: sequential on arm64 macOS to avoid SIGSEGV from + // concurrent native DB access (#285, #290, #292); parallel elsewhere + // to preserve performance on unaffected platforms. + const isArm64Mac = process.platform === 'darwin' && process.arch === 'arm64'; + + const processQuery = executeQuery(repo.id, ` MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process) WHERE s.id IN [${allIds}] RETURN p.heuristicLabel AS name, COUNT(DISTINCT s.id) AS hits, MIN(r.step) AS minStep, p.stepCount AS stepCount ORDER BY hits DESC LIMIT 20 `).catch(() => []); - const moduleRows = await executeQuery(repo.id, ` + const moduleQuery = () => executeQuery(repo.id, ` MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community) WHERE s.id IN [${allIds}] RETURN c.heuristicLabel AS name, COUNT(DISTINCT s.id) AS hits ORDER BY hits DESC LIMIT 20 `).catch(() => []); - const directModuleRows = d1Ids - ? await executeQuery(repo.id, ` + const directModuleQuery = () => d1Ids + ? executeQuery(repo.id, ` MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community) WHERE s.id IN [${d1Ids}] RETURN DISTINCT c.heuristicLabel AS name `).catch(() => []) - : []; + : Promise.resolve([]); + + let processRows: any[], moduleRows: any[], directModuleRows: any[]; + if (isArm64Mac) { + // Sequential: avoid concurrent native DB access + processRows = await processQuery; + moduleRows = await moduleQuery(); + directModuleRows = await directModuleQuery(); + } else { + // Parallel: safe on non-arm64 platforms + processRows = await processQuery; + [moduleRows, directModuleRows] = await Promise.all([moduleQuery(), directModuleQuery()]); + } affectedProcesses = processRows.map((r: any) => ({ name: r.name || r[0], From 5b012c335178a4bd8c86009e3ccb1b693d7f9166 Mon Sep 17 00:00:00 2001 From: hiromima Date: Fri, 20 Mar 2026 18:04:27 +0900 Subject: [PATCH 05/12] test: add e2e tests for stale detection, sequential enrichment, stability (#396) - Stale data detection: verify ensureInitialized() detects meta.json changes and re-opens pool without SIGSEGV or WAL corruption - Staleness throttle: verify 5s throttle window doesn't cause errors - Sequential enrichment: impact() enrichment queries complete on arm64 - Consecutive stability: 10+ sequential cypher calls, mixed tool cycles - Watchdog guard: parallel queries with activeQueryCount protection - stdout restoration: verify process.stdout.write is properly restored Covers test plan items from PR #396 (issues #285, #290, #292, #297) --- .../staleness-and-stability.test.ts | 324 ++++++++++++++++++ gitnexus/vitest.config.ts | 2 + 2 files changed, 326 insertions(+) create mode 100644 gitnexus/test/integration/staleness-and-stability.test.ts diff --git a/gitnexus/test/integration/staleness-and-stability.test.ts b/gitnexus/test/integration/staleness-and-stability.test.ts new file mode 100644 index 000000000..508e23e38 --- /dev/null +++ b/gitnexus/test/integration/staleness-and-stability.test.ts @@ -0,0 +1,324 @@ +/** + * E2E Tests: Stale Data Detection + Sequential Enrichment Stability + * + * Validates the fixes in PR #396: + * 1. Stale data detection: ensureInitialized() detects when the index + * was rebuilt (meta.json changed) and re-opens the connection pool + * 2. Sequential enrichment: impact() enrichment queries run without + * SIGSEGV on arm64 macOS (sequential on arm64, parallel elsewhere) + * 3. Consecutive tool stability: MCP server stays alive after 10+ + * consecutive tool calls (no stdout corruption) + * 4. All core tools (context, query, cypher, impact) return valid + * results after the concurrency changes + * + * Issues: #285, #290, #292, #297 + */ +import { describe, it, expect, afterAll } from 'vitest'; +import fs from 'fs/promises'; +import path from 'path'; +import { + initLbug, + executeQuery, + closeLbug, +} from '../../src/mcp/core/lbug-adapter.js'; +import { withTestLbugDB } from '../helpers/test-indexed-db.js'; +import { LOCAL_BACKEND_SEED_DATA, LOCAL_BACKEND_FTS_INDEXES } from '../fixtures/local-backend-seed.js'; +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { listRegisteredRepos } from '../../src/storage/repo-manager.js'; +import { vi } from 'vitest'; + +vi.mock('../../src/storage/repo-manager.js', () => ({ + listRegisteredRepos: vi.fn().mockResolvedValue([]), + cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }), +})); + +// ─── Block 1: Stale data detection (#297) ──────────────────────────── + +withTestLbugDB('staleness-detection', (handle) => { + + describe('stale data detection via meta.json', () => { + let backend: LocalBackend; + let storagePath: string; + + it('setup backend and verify initial state', async () => { + const ext = handle as typeof handle & { _backend?: LocalBackend }; + if (!ext._backend) throw new Error('LocalBackend not initialized'); + backend = ext._backend; + storagePath = handle.tmpHandle.dbPath; + + // Verify initial query works + const result = await backend.callTool('cypher', { + query: 'MATCH (n:Function) RETURN n.name AS name ORDER BY n.name', + }); + expect(result).toHaveProperty('row_count'); + expect(result.row_count).toBeGreaterThanOrEqual(3); + }); + + it('detects stale index when meta.json indexedAt changes', async () => { + // Write a meta.json with a different indexedAt to simulate re-index + const metaPath = path.join(storagePath, 'meta.json'); + const freshMeta = { + indexedAt: new Date(Date.now() + 60000).toISOString(), + lastCommit: 'new-commit-hash', + stats: { files: 2, nodes: 3, communities: 1, processes: 1 }, + }; + await fs.writeFile(metaPath, JSON.stringify(freshMeta)); + + // The next tool call should trigger re-init internally. + // It may fail (the DB hasn't actually changed) but should NOT crash + // with SIGSEGV or corrupt the WAL. + try { + const result = await backend.callTool('cypher', { + query: 'MATCH (n:Function) RETURN COUNT(n) AS cnt', + }); + // If it succeeds, verify it returns valid data + expect(result).toBeDefined(); + } catch (err: any) { + // Re-init failure is acceptable (DB path didn't actually change) + // but SIGSEGV or WAL corruption would crash the process entirely + expect(err.message).not.toMatch(/SIGSEGV/i); + } + }); + + it('does not re-read meta.json within 5s throttle window', async () => { + // Write meta.json with yet another timestamp + const metaPath = path.join(storagePath, 'meta.json'); + const newerMeta = { + indexedAt: new Date(Date.now() + 120000).toISOString(), + lastCommit: 'another-commit', + stats: { files: 2, nodes: 3, communities: 1, processes: 1 }, + }; + await fs.writeFile(metaPath, JSON.stringify(newerMeta)); + + // Immediate second call should be throttled (no fs.readFile) + // This test verifies the throttle doesn't cause errors + try { + const result = await backend.callTool('cypher', { + query: 'MATCH (n:Function) RETURN COUNT(n) AS cnt', + }); + expect(result).toBeDefined(); + } catch { + // Acceptable — the point is no crash + } + }); + }); + +}, { + seed: LOCAL_BACKEND_SEED_DATA, + ftsIndexes: LOCAL_BACKEND_FTS_INDEXES, + poolAdapter: true, + afterSetup: async (handle) => { + // Write initial meta.json + const metaPath = path.join(handle.tmpHandle.dbPath, 'meta.json'); + const initialMeta = { + indexedAt: new Date().toISOString(), + lastCommit: 'abc123', + stats: { files: 2, nodes: 3, communities: 1, processes: 1 }, + }; + await fs.writeFile(metaPath, JSON.stringify(initialMeta)); + + vi.mocked(listRegisteredRepos).mockResolvedValue([ + { + name: 'test-repo', + path: '/test/repo', + storagePath: handle.tmpHandle.dbPath, + indexedAt: initialMeta.indexedAt, + lastCommit: 'abc123', + stats: { files: 2, nodes: 3, communities: 1, processes: 1 }, + }, + ]); + const backend = new LocalBackend(); + await backend.init(); + (handle as any)._backend = backend; + }, +}); + +// ─── Block 2: Sequential enrichment queries (#285, #290, #292) ─────── + +withTestLbugDB('sequential-enrichment', (handle) => { + + describe('impact enrichment queries run without crashes', () => { + let backend: LocalBackend; + + it('setup', async () => { + const ext = handle as typeof handle & { _backend?: LocalBackend }; + if (!ext._backend) throw new Error('LocalBackend not initialized'); + backend = ext._backend; + }); + + it('impact with enrichment completes without SIGSEGV', async () => { + // This is the core regression test: impact() runs 3 enrichment queries + // that previously used Promise.all and caused SIGSEGV on arm64 macOS. + // Now they run sequentially on arm64 macOS and in parallel elsewhere. + const result = await backend.callTool('impact', { + target: 'validate', + direction: 'upstream', + }); + // May return an error if the DB was affected by prior test blocks. + // The key assertion: no SIGSEGV crash (process would exit if so). + if (!result.error) { + expect(result.impactedCount).toBeGreaterThanOrEqual(1); + expect(result).toHaveProperty('affected_processes'); + expect(result).toHaveProperty('affected_modules'); + } + }); + + it('impact with large maxDepth completes without crash', async () => { + const result = await backend.callTool('impact', { + target: 'login', + direction: 'downstream', + maxDepth: 5, + }); + expect(result).toBeDefined(); + // No crash = success. Error response is acceptable (DB state may vary). + }); + }); + +}, { + seed: LOCAL_BACKEND_SEED_DATA, + ftsIndexes: LOCAL_BACKEND_FTS_INDEXES, + poolAdapter: true, + afterSetup: async (handle) => { + vi.mocked(listRegisteredRepos).mockResolvedValue([ + { + name: 'test-repo', + path: '/test/repo', + storagePath: handle.tmpHandle.dbPath, + indexedAt: new Date().toISOString(), + lastCommit: 'abc123', + stats: { files: 2, nodes: 3, communities: 1, processes: 1 }, + }, + ]); + const backend = new LocalBackend(); + await backend.init(); + (handle as any)._backend = backend; + }, +}); + +// ─── Block 3: Consecutive tool call stability ──────────────────────── + +withTestLbugDB('consecutive-stability', (handle) => { + + describe('MCP server stays alive after 10+ consecutive tool calls', () => { + let backend: LocalBackend; + + it('setup', async () => { + const ext = handle as typeof handle & { _backend?: LocalBackend }; + if (!ext._backend) throw new Error('LocalBackend not initialized'); + backend = ext._backend; + }); + + it('10 consecutive cypher calls complete without stdout corruption', async () => { + for (let i = 0; i < 10; i++) { + const result = await backend.callTool('cypher', { + query: `MATCH (n:Function) RETURN n.name AS name LIMIT ${i + 1}`, + }); + expect(result).toHaveProperty('row_count'); + expect(result.row_count).toBeGreaterThanOrEqual(1); + } + }); + + it('mixed tool calls: context → impact → query → cypher cycle', async () => { + // Cycle through all 4 core tools 3 times + for (let i = 0; i < 3; i++) { + const ctx = await backend.callTool('context', { name: 'login' }); + expect(ctx.status).toBe('found'); + + const imp = await backend.callTool('impact', { + target: 'validate', + direction: 'upstream', + }); + expect(imp).not.toHaveProperty('error'); + + const qry = await backend.callTool('query', { query: 'login' }); + expect(qry).not.toHaveProperty('error'); + + const cyp = await backend.callTool('cypher', { + query: 'MATCH (n:Function) RETURN COUNT(n) AS cnt', + }); + expect(cyp).toHaveProperty('row_count'); + } + }); + + it('stdout.write is properly restored after all calls', () => { + // Verify stdout wasn't permanently silenced by the silenceStdout + // mechanism used to prevent native stdout corruption + const isOriginal = process.stdout.write !== ((() => true) as any); + expect(isOriginal).toBe(true); + }); + }); + +}, { + seed: LOCAL_BACKEND_SEED_DATA, + ftsIndexes: LOCAL_BACKEND_FTS_INDEXES, + poolAdapter: true, + afterSetup: async (handle) => { + vi.mocked(listRegisteredRepos).mockResolvedValue([ + { + name: 'test-repo', + path: '/test/repo', + storagePath: handle.tmpHandle.dbPath, + indexedAt: new Date().toISOString(), + lastCommit: 'abc123', + stats: { files: 2, nodes: 3, communities: 1, processes: 1 }, + }, + ]); + const backend = new LocalBackend(); + await backend.init(); + (handle as any)._backend = backend; + }, +}); + +// ─── Block 4: activeQueryCount watchdog interaction ────────────────── + +withTestLbugDB('watchdog-query-guard', (handle) => { + + describe('watchdog does not restore stdout during active queries', () => { + const REPO = 'watchdog-test'; + let inited = false; + + const ensurePool = async () => { + if (!inited) { + await initLbug(REPO, handle.dbPath); + inited = true; + } + }; + + afterAll(async () => { + try { await closeLbug(REPO); } catch { /* best-effort */ } + }); + + it('parallel queries complete and stdout is restored', async () => { + await ensurePool(); + + // Run 4 parallel queries — if the watchdog incorrectly restores + // stdout during execution, native output could corrupt the MCP + // stdio stream. The test verifies all queries complete cleanly. + const queries = Array.from({ length: 4 }, (_, i) => + executeQuery(REPO, `MATCH (n:Function) RETURN n.name AS name LIMIT ${i + 1}`) + ); + const results = await Promise.all(queries); + expect(results).toHaveLength(4); + for (const r of results) { + expect(r.length).toBeGreaterThanOrEqual(1); + } + + // After all queries complete, stdout should be restored + const isOriginal = process.stdout.write !== ((() => true) as any); + expect(isOriginal).toBe(true); + }); + + it('sequential queries with intentional delay still work', async () => { + await ensurePool(); + + // Run queries one by one — each silences/restores stdout + for (let i = 0; i < 5; i++) { + const rows = await executeQuery(REPO, 'MATCH (n:Function) RETURN n.name'); + expect(rows.length).toBeGreaterThanOrEqual(1); + } + }); + }); + +}, { + seed: LOCAL_BACKEND_SEED_DATA, +}); diff --git a/gitnexus/vitest.config.ts b/gitnexus/vitest.config.ts index 6a2956856..591f06766 100644 --- a/gitnexus/vitest.config.ts +++ b/gitnexus/vitest.config.ts @@ -59,6 +59,7 @@ export default defineConfig({ 'test/integration/search-core.test.ts', 'test/integration/search-pool.test.ts', 'test/integration/augmentation.test.ts', + 'test/integration/staleness-and-stability.test.ts', ], fileParallelism: false, sequence: { groupOrder: 1 }, @@ -79,6 +80,7 @@ export default defineConfig({ 'test/integration/search-core.test.ts', 'test/integration/search-pool.test.ts', 'test/integration/augmentation.test.ts', + 'test/integration/staleness-and-stability.test.ts', ], }, }, From 2ff4d93314c9a67012258fd22b68f70a5d3aa252 Mon Sep 17 00:00:00 2001 From: hiromima Date: Fri, 20 Mar 2026 21:06:30 +0900 Subject: [PATCH 06/12] fix(test): unify staleness-and-stability into single withTestLbugDB block All 4 test blocks now share one DB lifecycle to avoid cross-block "Database is closed" errors caused by LadybugDB's shared global DB in a single vitest fork. Staleness detection (which triggers closeLbug) runs last to avoid invalidating connections for other blocks. 11/11 tests pass on macOS, Ubuntu, and Windows. --- .../staleness-and-stability.test.ts | 350 +++++++----------- 1 file changed, 126 insertions(+), 224 deletions(-) diff --git a/gitnexus/test/integration/staleness-and-stability.test.ts b/gitnexus/test/integration/staleness-and-stability.test.ts index 508e23e38..023ec5969 100644 --- a/gitnexus/test/integration/staleness-and-stability.test.ts +++ b/gitnexus/test/integration/staleness-and-stability.test.ts @@ -2,14 +2,15 @@ * E2E Tests: Stale Data Detection + Sequential Enrichment Stability * * Validates the fixes in PR #396: - * 1. Stale data detection: ensureInitialized() detects when the index - * was rebuilt (meta.json changed) and re-opens the connection pool - * 2. Sequential enrichment: impact() enrichment queries run without + * 1. Sequential enrichment: impact() enrichment queries run without * SIGSEGV on arm64 macOS (sequential on arm64, parallel elsewhere) - * 3. Consecutive tool stability: MCP server stays alive after 10+ + * 2. Consecutive tool stability: MCP server stays alive after 10+ * consecutive tool calls (no stdout corruption) - * 4. All core tools (context, query, cypher, impact) return valid - * results after the concurrency changes + * 3. Watchdog guard: activeQueryCount prevents premature stdout restore + * 4. Stale data detection: ensureInitialized() detects meta.json changes + * + * All tests share one withTestLbugDB lifecycle to avoid cross-block + * DB closure issues (LadybugDB's shared global DB in a single fork). * * Issues: #285, #290, #292, #297 */ @@ -32,21 +33,123 @@ vi.mock('../../src/storage/repo-manager.js', () => ({ cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }), })); -// ─── Block 1: Stale data detection (#297) ──────────────────────────── +withTestLbugDB('staleness-and-stability', (handle) => { + let backend: LocalBackend; + let storagePath: string; -withTestLbugDB('staleness-detection', (handle) => { - - describe('stale data detection via meta.json', () => { - let backend: LocalBackend; - let storagePath: string; - - it('setup backend and verify initial state', async () => { + // ─── Setup ───────────────────────────────────────────────────────── + describe('setup', () => { + it('initialize backend', async () => { const ext = handle as typeof handle & { _backend?: LocalBackend }; if (!ext._backend) throw new Error('LocalBackend not initialized'); backend = ext._backend; storagePath = handle.tmpHandle.dbPath; + }); + }); - // Verify initial query works + // ─── Block 1: Sequential enrichment queries (#285, #290, #292) ───── + describe('impact enrichment queries run without crashes', () => { + it('impact with enrichment completes without SIGSEGV', async () => { + const result = await backend.callTool('impact', { + target: 'validate', + direction: 'upstream', + }); + expect(result).not.toHaveProperty('error'); + expect(result.impactedCount).toBeGreaterThanOrEqual(1); + expect(result).toHaveProperty('affected_processes'); + expect(result).toHaveProperty('affected_modules'); + }); + + it('impact with large maxDepth completes without crash', async () => { + const result = await backend.callTool('impact', { + target: 'login', + direction: 'downstream', + maxDepth: 5, + }); + expect(result).toBeDefined(); + expect(result).not.toHaveProperty('error'); + }); + }); + + // ─── Block 2: Consecutive tool call stability ────────────────────── + describe('MCP server stays alive after 10+ consecutive tool calls', () => { + it('10 consecutive cypher calls complete without stdout corruption', async () => { + for (let i = 0; i < 10; i++) { + const result = await backend.callTool('cypher', { + query: `MATCH (n:Function) RETURN n.name AS name LIMIT ${i + 1}`, + }); + expect(result).toHaveProperty('row_count'); + expect(result.row_count).toBeGreaterThanOrEqual(1); + } + }); + + it('mixed tool calls: context → impact → query → cypher cycle', async () => { + for (let i = 0; i < 3; i++) { + const ctx = await backend.callTool('context', { name: 'login' }); + expect(ctx.status).toBe('found'); + + const imp = await backend.callTool('impact', { + target: 'validate', + direction: 'upstream', + }); + expect(imp).not.toHaveProperty('error'); + + const qry = await backend.callTool('query', { query: 'login' }); + expect(qry).not.toHaveProperty('error'); + + const cyp = await backend.callTool('cypher', { + query: 'MATCH (n:Function) RETURN COUNT(n) AS cnt', + }); + expect(cyp).toHaveProperty('row_count'); + } + }); + + it('stdout.write is still a function after all calls', () => { + expect(typeof process.stdout.write).toBe('function'); + }); + }); + + // ─── Block 3: Watchdog / activeQueryCount ────────────────────────── + describe('watchdog does not restore stdout during active queries', () => { + const REPO = 'watchdog-test'; + let poolInited = false; + + const ensurePool = async () => { + if (!poolInited) { + await initLbug(REPO, handle.dbPath); + poolInited = true; + } + }; + + afterAll(async () => { + try { await closeLbug(REPO); } catch { /* best-effort */ } + }); + + it('parallel queries complete and stdout is restored', async () => { + await ensurePool(); + const queries = Array.from({ length: 4 }, (_, i) => + executeQuery(REPO, `MATCH (n:Function) RETURN n.name AS name LIMIT ${i + 1}`) + ); + const results = await Promise.all(queries); + expect(results).toHaveLength(4); + for (const r of results) { + expect(r.length).toBeGreaterThanOrEqual(1); + } + }); + + it('sequential queries still work', async () => { + await ensurePool(); + for (let i = 0; i < 5; i++) { + const rows = await executeQuery(REPO, 'MATCH (n:Function) RETURN n.name'); + expect(rows.length).toBeGreaterThanOrEqual(1); + } + }); + }); + + // ─── Block 4: Stale data detection (#297) ────────────────────────── + // LAST: triggers closeLbug internally which may affect shared state + describe('stale data detection via meta.json', () => { + it('initial query works', async () => { const result = await backend.callTool('cypher', { query: 'MATCH (n:Function) RETURN n.name AS name ORDER BY n.name', }); @@ -55,50 +158,39 @@ withTestLbugDB('staleness-detection', (handle) => { }); it('detects stale index when meta.json indexedAt changes', async () => { - // Write a meta.json with a different indexedAt to simulate re-index const metaPath = path.join(storagePath, 'meta.json'); - const freshMeta = { + await fs.writeFile(metaPath, JSON.stringify({ indexedAt: new Date(Date.now() + 60000).toISOString(), lastCommit: 'new-commit-hash', stats: { files: 2, nodes: 3, communities: 1, processes: 1 }, - }; - await fs.writeFile(metaPath, JSON.stringify(freshMeta)); + })); - // The next tool call should trigger re-init internally. - // It may fail (the DB hasn't actually changed) but should NOT crash - // with SIGSEGV or corrupt the WAL. + // Next call triggers re-init. May fail but must NOT crash. try { const result = await backend.callTool('cypher', { query: 'MATCH (n:Function) RETURN COUNT(n) AS cnt', }); - // If it succeeds, verify it returns valid data expect(result).toBeDefined(); } catch (err: any) { - // Re-init failure is acceptable (DB path didn't actually change) - // but SIGSEGV or WAL corruption would crash the process entirely expect(err.message).not.toMatch(/SIGSEGV/i); } }); - it('does not re-read meta.json within 5s throttle window', async () => { - // Write meta.json with yet another timestamp + it('throttle: no re-read within 5s window', async () => { const metaPath = path.join(storagePath, 'meta.json'); - const newerMeta = { + await fs.writeFile(metaPath, JSON.stringify({ indexedAt: new Date(Date.now() + 120000).toISOString(), lastCommit: 'another-commit', stats: { files: 2, nodes: 3, communities: 1, processes: 1 }, - }; - await fs.writeFile(metaPath, JSON.stringify(newerMeta)); + })); - // Immediate second call should be throttled (no fs.readFile) - // This test verifies the throttle doesn't cause errors try { const result = await backend.callTool('cypher', { query: 'MATCH (n:Function) RETURN COUNT(n) AS cnt', }); expect(result).toBeDefined(); } catch { - // Acceptable — the point is no crash + // No crash = success } }); }); @@ -108,7 +200,7 @@ withTestLbugDB('staleness-detection', (handle) => { ftsIndexes: LOCAL_BACKEND_FTS_INDEXES, poolAdapter: true, afterSetup: async (handle) => { - // Write initial meta.json + // Write initial meta.json for staleness tests const metaPath = path.join(handle.tmpHandle.dbPath, 'meta.json'); const initialMeta = { indexedAt: new Date().toISOString(), @@ -132,193 +224,3 @@ withTestLbugDB('staleness-detection', (handle) => { (handle as any)._backend = backend; }, }); - -// ─── Block 2: Sequential enrichment queries (#285, #290, #292) ─────── - -withTestLbugDB('sequential-enrichment', (handle) => { - - describe('impact enrichment queries run without crashes', () => { - let backend: LocalBackend; - - it('setup', async () => { - const ext = handle as typeof handle & { _backend?: LocalBackend }; - if (!ext._backend) throw new Error('LocalBackend not initialized'); - backend = ext._backend; - }); - - it('impact with enrichment completes without SIGSEGV', async () => { - // This is the core regression test: impact() runs 3 enrichment queries - // that previously used Promise.all and caused SIGSEGV on arm64 macOS. - // Now they run sequentially on arm64 macOS and in parallel elsewhere. - const result = await backend.callTool('impact', { - target: 'validate', - direction: 'upstream', - }); - // May return an error if the DB was affected by prior test blocks. - // The key assertion: no SIGSEGV crash (process would exit if so). - if (!result.error) { - expect(result.impactedCount).toBeGreaterThanOrEqual(1); - expect(result).toHaveProperty('affected_processes'); - expect(result).toHaveProperty('affected_modules'); - } - }); - - it('impact with large maxDepth completes without crash', async () => { - const result = await backend.callTool('impact', { - target: 'login', - direction: 'downstream', - maxDepth: 5, - }); - expect(result).toBeDefined(); - // No crash = success. Error response is acceptable (DB state may vary). - }); - }); - -}, { - seed: LOCAL_BACKEND_SEED_DATA, - ftsIndexes: LOCAL_BACKEND_FTS_INDEXES, - poolAdapter: true, - afterSetup: async (handle) => { - vi.mocked(listRegisteredRepos).mockResolvedValue([ - { - name: 'test-repo', - path: '/test/repo', - storagePath: handle.tmpHandle.dbPath, - indexedAt: new Date().toISOString(), - lastCommit: 'abc123', - stats: { files: 2, nodes: 3, communities: 1, processes: 1 }, - }, - ]); - const backend = new LocalBackend(); - await backend.init(); - (handle as any)._backend = backend; - }, -}); - -// ─── Block 3: Consecutive tool call stability ──────────────────────── - -withTestLbugDB('consecutive-stability', (handle) => { - - describe('MCP server stays alive after 10+ consecutive tool calls', () => { - let backend: LocalBackend; - - it('setup', async () => { - const ext = handle as typeof handle & { _backend?: LocalBackend }; - if (!ext._backend) throw new Error('LocalBackend not initialized'); - backend = ext._backend; - }); - - it('10 consecutive cypher calls complete without stdout corruption', async () => { - for (let i = 0; i < 10; i++) { - const result = await backend.callTool('cypher', { - query: `MATCH (n:Function) RETURN n.name AS name LIMIT ${i + 1}`, - }); - expect(result).toHaveProperty('row_count'); - expect(result.row_count).toBeGreaterThanOrEqual(1); - } - }); - - it('mixed tool calls: context → impact → query → cypher cycle', async () => { - // Cycle through all 4 core tools 3 times - for (let i = 0; i < 3; i++) { - const ctx = await backend.callTool('context', { name: 'login' }); - expect(ctx.status).toBe('found'); - - const imp = await backend.callTool('impact', { - target: 'validate', - direction: 'upstream', - }); - expect(imp).not.toHaveProperty('error'); - - const qry = await backend.callTool('query', { query: 'login' }); - expect(qry).not.toHaveProperty('error'); - - const cyp = await backend.callTool('cypher', { - query: 'MATCH (n:Function) RETURN COUNT(n) AS cnt', - }); - expect(cyp).toHaveProperty('row_count'); - } - }); - - it('stdout.write is properly restored after all calls', () => { - // Verify stdout wasn't permanently silenced by the silenceStdout - // mechanism used to prevent native stdout corruption - const isOriginal = process.stdout.write !== ((() => true) as any); - expect(isOriginal).toBe(true); - }); - }); - -}, { - seed: LOCAL_BACKEND_SEED_DATA, - ftsIndexes: LOCAL_BACKEND_FTS_INDEXES, - poolAdapter: true, - afterSetup: async (handle) => { - vi.mocked(listRegisteredRepos).mockResolvedValue([ - { - name: 'test-repo', - path: '/test/repo', - storagePath: handle.tmpHandle.dbPath, - indexedAt: new Date().toISOString(), - lastCommit: 'abc123', - stats: { files: 2, nodes: 3, communities: 1, processes: 1 }, - }, - ]); - const backend = new LocalBackend(); - await backend.init(); - (handle as any)._backend = backend; - }, -}); - -// ─── Block 4: activeQueryCount watchdog interaction ────────────────── - -withTestLbugDB('watchdog-query-guard', (handle) => { - - describe('watchdog does not restore stdout during active queries', () => { - const REPO = 'watchdog-test'; - let inited = false; - - const ensurePool = async () => { - if (!inited) { - await initLbug(REPO, handle.dbPath); - inited = true; - } - }; - - afterAll(async () => { - try { await closeLbug(REPO); } catch { /* best-effort */ } - }); - - it('parallel queries complete and stdout is restored', async () => { - await ensurePool(); - - // Run 4 parallel queries — if the watchdog incorrectly restores - // stdout during execution, native output could corrupt the MCP - // stdio stream. The test verifies all queries complete cleanly. - const queries = Array.from({ length: 4 }, (_, i) => - executeQuery(REPO, `MATCH (n:Function) RETURN n.name AS name LIMIT ${i + 1}`) - ); - const results = await Promise.all(queries); - expect(results).toHaveLength(4); - for (const r of results) { - expect(r.length).toBeGreaterThanOrEqual(1); - } - - // After all queries complete, stdout should be restored - const isOriginal = process.stdout.write !== ((() => true) as any); - expect(isOriginal).toBe(true); - }); - - it('sequential queries with intentional delay still work', async () => { - await ensurePool(); - - // Run queries one by one — each silences/restores stdout - for (let i = 0; i < 5; i++) { - const rows = await executeQuery(REPO, 'MATCH (n:Function) RETURN n.name'); - expect(rows.length).toBeGreaterThanOrEqual(1); - } - }); - }); - -}, { - seed: LOCAL_BACKEND_SEED_DATA, -}); From 66c1ffa3701ccaa0743c5450094aebda419fe796 Mon Sep 17 00:00:00 2001 From: ximi Date: Fri, 20 Mar 2026 22:18:31 +0800 Subject: [PATCH 07/12] feat: add MiniMax provider support (#224) Add MiniMax as a new LLM provider using the Anthropic-compatible API. Changes: - Add MiniMax to LLMProvider type and MiniMaxConfig interface - Add MiniMax chat model creation via ChatAnthropic with custom base URL - Add MiniMax settings persistence and model list (MiniMax-M2.5, MiniMax-M2.5-highspeed) - Add MiniMax provider UI in SettingsPanel with API key and model selection --- gitnexus-web/src/components/SettingsPanel.tsx | 61 ++++++++++++++++++- gitnexus-web/src/core/llm/agent.ts | 34 ++++++++--- gitnexus-web/src/core/llm/settings-service.ts | 38 ++++++++++-- gitnexus-web/src/core/llm/types.ts | 19 +++++- 4 files changed, 137 insertions(+), 15 deletions(-) diff --git a/gitnexus-web/src/components/SettingsPanel.tsx b/gitnexus-web/src/components/SettingsPanel.tsx index ab823d240..d60475107 100644 --- a/gitnexus-web/src/components/SettingsPanel.tsx +++ b/gitnexus-web/src/components/SettingsPanel.tsx @@ -281,7 +281,7 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is if (!isOpen) return null; - const providers: LLMProvider[] = ['openai', 'gemini', 'anthropic', 'azure-openai', 'ollama', 'openrouter']; + const providers: LLMProvider[] = ['openai', 'gemini', 'anthropic', 'azure-openai', 'ollama', 'openrouter', 'minimax']; return ( @@ -366,7 +366,7 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is w-8 h-8 rounded-lg flex items-center justify-center text-lg ${settings.activeProvider === provider ? 'bg-accent/20' : 'bg-surface'} `}> - {provider === 'openai' ? '🤖' : provider === 'gemini' ? '💎' : provider === 'anthropic' ? '🧠' : provider === 'ollama' ? '🦙' : provider === 'openrouter' ? '🌐' : '☁️'} + {provider === 'openai' ? '🤖' : provider === 'gemini' ? '💎' : provider === 'anthropic' ? '🧠' : provider === 'ollama' ? '🦙' : provider === 'openrouter' ? '🌐' : provider === 'minimax' ? '⚡' : '☁️'} {getProviderDisplayName(provider)} @@ -814,7 +814,64 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is )} + {/* MiniMax Settings */} + {settings.activeProvider === 'minimax' && ( +
+
+ +
+ setSettings(prev => ({ + ...prev, + minimax: { ...prev.minimax!, apiKey: e.target.value } + }))} + placeholder="Enter your MiniMax API key" + className="w-full px-4 py-3 pr-12 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all" + /> + +
+

+ Get your API key from{' '} + + MiniMax Platform + +

+
+
+ + setSettings(prev => ({ + ...prev, + minimax: { ...prev.minimax!, model: e.target.value } + }))} + placeholder="e.g., MiniMax-M2.5, MiniMax-M2.5-highspeed" + className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm" + /> +

+ Available models: MiniMax-M2.5 (default), MiniMax-M2.5-highspeed (faster) +

+
+
+ )} {/* Privacy Note */}
diff --git a/gitnexus-web/src/core/llm/agent.ts b/gitnexus-web/src/core/llm/agent.ts index 6649f5742..88e07d40f 100644 --- a/gitnexus-web/src/core/llm/agent.ts +++ b/gitnexus-web/src/core/llm/agent.ts @@ -13,14 +13,15 @@ import { ChatAnthropic } from '@langchain/anthropic'; import { ChatOllama } from '@langchain/ollama'; import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; import { createGraphRAGTools } from './tools'; -import type { - ProviderConfig, +import type { + ProviderConfig, OpenAIConfig, - AzureOpenAIConfig, + AzureOpenAIConfig, GeminiConfig, AnthropicConfig, OllamaConfig, OpenRouterConfig, + MiniMaxConfig, AgentStreamChunk, } from './types'; import { @@ -197,7 +198,7 @@ export const createChatModel = (config: ProviderConfig): BaseChatModel => { case 'openrouter': { const openRouterConfig = config as OpenRouterConfig; - + // Debug logging if (import.meta.env.DEV) { console.log('🌐 OpenRouter config:', { @@ -207,11 +208,11 @@ export const createChatModel = (config: ProviderConfig): BaseChatModel => { baseUrl: openRouterConfig.baseUrl, }); } - + if (!openRouterConfig.apiKey || openRouterConfig.apiKey.trim() === '') { throw new Error('OpenRouter API key is required but was not provided'); } - + return new ChatOpenAI({ openAIApiKey: openRouterConfig.apiKey, apiKey: openRouterConfig.apiKey, // Fallback for some versions @@ -225,7 +226,26 @@ export const createChatModel = (config: ProviderConfig): BaseChatModel => { streaming: true, }); } - + + case 'minimax': { + const minimaxConfig = config as MiniMaxConfig; + + if (!minimaxConfig.apiKey || minimaxConfig.apiKey.trim() === '') { + throw new Error('MiniMax API key is required but was not provided'); + } + + return new ChatAnthropic({ + anthropicApiKey: minimaxConfig.apiKey, + model: minimaxConfig.model, + temperature: minimaxConfig.temperature ?? 0.1, + maxTokens: minimaxConfig.maxTokens ?? 8192, + streaming: true, + clientOptions: { + baseURL: 'https://api.minimax.io/anthropic', + }, + }); + } + default: throw new Error(`Unsupported provider: ${(config as any).provider}`); } diff --git a/gitnexus-web/src/core/llm/settings-service.ts b/gitnexus-web/src/core/llm/settings-service.ts index 0c35b9623..f4ef5993a 100644 --- a/gitnexus-web/src/core/llm/settings-service.ts +++ b/gitnexus-web/src/core/llm/settings-service.ts @@ -5,9 +5,9 @@ * All API keys are stored locally - never sent to any server except the LLM provider. */ -import { - LLMSettings, - DEFAULT_LLM_SETTINGS, +import { + LLMSettings, + DEFAULT_LLM_SETTINGS, LLMProvider, OpenAIConfig, AzureOpenAIConfig, @@ -15,6 +15,7 @@ import { AnthropicConfig, OllamaConfig, OpenRouterConfig, + MiniMaxConfig, ProviderConfig, } from './types'; @@ -60,6 +61,10 @@ export const loadSettings = (): LLMSettings => { ...DEFAULT_LLM_SETTINGS.openrouter, ...parsed.openrouter, }, + minimax: { + ...DEFAULT_LLM_SETTINGS.minimax, + ...parsed.minimax, + }, }; } catch (error) { console.warn('Failed to load LLM settings:', error); @@ -89,6 +94,7 @@ export const updateProviderSettings = ( T extends 'gemini' ? Partial> : T extends 'anthropic' ? Partial> : T extends 'ollama' ? Partial> : + T extends 'minimax' ? Partial> : never > ): LLMSettings => { @@ -162,6 +168,17 @@ export const updateProviderSettings = ( saveSettings(updated); return updated; } + case 'minimax': { + const updated: LLMSettings = { + ...current, + minimax: { + ...(current.minimax ?? {}), + ...(updates as Partial>), + }, + }; + saveSettings(updated); + return updated; + } default: { // Should be unreachable due to T extends LLMProvider, but keep a safe fallback const updated: LLMSettings = { ...current }; @@ -245,7 +262,16 @@ export const getActiveProviderConfig = (): ProviderConfig | null => { temperature: settings.openrouter.temperature, maxTokens: settings.openrouter.maxTokens, } as OpenRouterConfig; - + + case 'minimax': + if (!settings.minimax?.apiKey) { + return null; + } + return { + provider: 'minimax', + ...settings.minimax, + } as MiniMaxConfig; + default: return null; } @@ -282,6 +308,8 @@ export const getProviderDisplayName = (provider: LLMProvider): string => { return 'Ollama (Local)'; case 'openrouter': return 'OpenRouter'; + case 'minimax': + return 'MiniMax'; default: return provider; } @@ -303,6 +331,8 @@ export const getAvailableModels = (provider: LLMProvider): string[] => { return ['claude-sonnet-4-20250514', 'claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022', 'claude-3-opus-20240229']; case 'ollama': return ['llama3.2', 'llama3.1', 'mistral', 'codellama', 'deepseek-coder']; + case 'minimax': + return ['MiniMax-M2.5', 'MiniMax-M2.5-highspeed']; default: return []; } diff --git a/gitnexus-web/src/core/llm/types.ts b/gitnexus-web/src/core/llm/types.ts index 8e43673fa..d2597ec18 100644 --- a/gitnexus-web/src/core/llm/types.ts +++ b/gitnexus-web/src/core/llm/types.ts @@ -8,7 +8,7 @@ /** * Supported LLM providers */ -export type LLMProvider = 'openai' | 'azure-openai' | 'gemini' | 'anthropic' | 'ollama' | 'openrouter'; +export type LLMProvider = 'openai' | 'azure-openai' | 'gemini' | 'anthropic' | 'ollama' | 'openrouter' | 'minimax'; /** * Base configuration shared by all providers @@ -78,10 +78,19 @@ export interface OpenRouterConfig extends BaseProviderConfig { baseUrl?: string; // defaults to https://openrouter.ai/api/v1 } +/** + * MiniMax configuration (Anthropic-compatible API) + */ +export interface MiniMaxConfig extends BaseProviderConfig { + provider: 'minimax'; + apiKey: string; + model: string; // e.g., 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed' +} + /** * Union type for all provider configurations */ -export type ProviderConfig = OpenAIConfig | AzureOpenAIConfig | GeminiConfig | AnthropicConfig | OllamaConfig | OpenRouterConfig; +export type ProviderConfig = OpenAIConfig | AzureOpenAIConfig | GeminiConfig | AnthropicConfig | OllamaConfig | OpenRouterConfig | MiniMaxConfig; /** * Stored settings (what goes to localStorage) @@ -98,6 +107,7 @@ export interface LLMSettings { anthropic?: Partial>; ollama?: Partial>; openrouter?: Partial>; + minimax?: Partial>; // Intelligent Clustering Settings intelligentClustering: boolean; @@ -148,6 +158,11 @@ export const DEFAULT_LLM_SETTINGS: LLMSettings = { baseUrl: 'https://openrouter.ai/api/v1', temperature: 0.1, }, + minimax: { + apiKey: '', + model: 'MiniMax-M2.5', + temperature: 0.1, + }, }; /** From cbfdae0303cc8935d3d05aecbe9a41da7134dcfe Mon Sep 17 00:00:00 2001 From: Dmytro Date: Fri, 20 Mar 2026 15:28:42 +0100 Subject: [PATCH 08/12] test(cli): cover full Codex setup flow --- .../test/integration/setup-skills.test.ts | 38 +++++++ gitnexus/test/unit/setup-codex.test.ts | 103 ++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 gitnexus/test/unit/setup-codex.test.ts diff --git a/gitnexus/test/integration/setup-skills.test.ts b/gitnexus/test/integration/setup-skills.test.ts index f6f35f76f..f6e3801e5 100644 --- a/gitnexus/test/integration/setup-skills.test.ts +++ b/gitnexus/test/integration/setup-skills.test.ts @@ -9,6 +9,7 @@ describe('setupCommand skills integration', () => { let tempHome: string; const originalHome = process.env.HOME; const originalUserProfile = process.env.USERPROFILE; + const originalPath = process.env.PATH; const testId = `${Date.now()}-${process.pid}`; const flatSkillName = `test-flat-skill-${testId}`; const dirSkillName = `test-dir-skill-${testId}`; @@ -47,6 +48,7 @@ describe('setupCommand skills integration', () => { await fs.rm(path.join(packageSkillsRoot, dirSkillName), { recursive: true, force: true }); process.env.HOME = originalHome; process.env.USERPROFILE = originalUserProfile; + process.env.PATH = originalPath; await fs.rm(tempHome, { recursive: true, force: true }); }); @@ -85,4 +87,40 @@ describe('setupCommand skills integration', () => { ); expect(nestedInstalled).toContain('Directory Nested File'); }); + + it('falls back to Codex config.toml and installs skills into ~/.agents/skills when codex CLI is unavailable', async () => { + await fs.mkdir(path.join(tempHome, '.codex'), { recursive: true }); + process.env.PATH = ''; + + await setupCommand(); + + const codexConfig = await fs.readFile( + path.join(tempHome, '.codex', 'config.toml'), + 'utf-8', + ); + expect(codexConfig).toContain('[mcp_servers.gitnexus]'); + expect(codexConfig).toContain('gitnexus@latest'); + + const codexSkill = await fs.readFile( + path.join(tempHome, '.agents', 'skills', 'gitnexus-cli', 'SKILL.md'), + 'utf-8', + ); + expect(codexSkill).toContain('GitNexus CLI Commands'); + }); + + it('does not duplicate the Codex MCP section on repeated fallback setup runs', async () => { + await fs.mkdir(path.join(tempHome, '.codex'), { recursive: true }); + process.env.PATH = ''; + + await setupCommand(); + await setupCommand(); + + const codexConfig = await fs.readFile( + path.join(tempHome, '.codex', 'config.toml'), + 'utf-8', + ); + const sectionMatches = codexConfig.match(/\[mcp_servers\.gitnexus\]/g) ?? []; + + expect(sectionMatches).toHaveLength(1); + }); }); diff --git a/gitnexus/test/unit/setup-codex.test.ts b/gitnexus/test/unit/setup-codex.test.ts new file mode 100644 index 000000000..9a6650861 --- /dev/null +++ b/gitnexus/test/unit/setup-codex.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; + +const execFileMock = vi.fn((...args: any[]) => { + const callback = args.at(-1); + if (typeof callback === 'function') { + callback(null, '', ''); + } +}); + +vi.mock('child_process', () => ({ + execFile: execFileMock, +})); + +describe('setupCommand codex execution', () => { + let tempHome: string; + let originalHome: string | undefined; + let originalUserProfile: string | undefined; + let platformDescriptor: PropertyDescriptor | undefined; + + const setPlatform = (value: NodeJS.Platform) => { + Object.defineProperty(process, 'platform', { + value, + configurable: true, + }); + }; + + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + + originalHome = process.env.HOME; + originalUserProfile = process.env.USERPROFILE; + tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-codex-setup-')); + process.env.HOME = tempHome; + process.env.USERPROFILE = tempHome; + + await fs.mkdir(path.join(tempHome, '.codex'), { recursive: true }); + + platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + setPlatform('win32'); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + + if (platformDescriptor) { + Object.defineProperty(process, 'platform', platformDescriptor); + } + + process.env.HOME = originalHome; + process.env.USERPROFILE = originalUserProfile; + await fs.rm(tempHome, { recursive: true, force: true }); + }); + + it('invokes codex mcp add with shell enabled on Windows', async () => { + const { setupCommand } = await import('../../src/cli/setup.js'); + + await setupCommand(); + + expect(execFileMock).toHaveBeenCalledWith( + 'codex', + ['mcp', 'add', 'gitnexus', '--', 'cmd', '/c', 'npx', '-y', 'gitnexus@latest', 'mcp'], + { shell: true }, + expect.any(Function), + ); + }); + + it('invokes codex mcp add without shell on non-Windows and does not write fallback config', async () => { + setPlatform('darwin'); + + const { setupCommand } = await import('../../src/cli/setup.js'); + + await setupCommand(); + + expect(execFileMock).toHaveBeenCalledWith( + 'codex', + ['mcp', 'add', 'gitnexus', '--', 'npx', '-y', 'gitnexus@latest', 'mcp'], + { shell: false }, + expect.any(Function), + ); + + await expect( + fs.access(path.join(tempHome, '.codex', 'config.toml')), + ).rejects.toThrow(); + }); + + it('skips Codex setup entirely when ~/.codex is missing', async () => { + await fs.rm(path.join(tempHome, '.codex'), { recursive: true, force: true }); + + const { setupCommand } = await import('../../src/cli/setup.js'); + + await setupCommand(); + + expect(execFileMock).not.toHaveBeenCalled(); + await expect( + fs.access(path.join(tempHome, '.agents', 'skills')), + ).rejects.toThrow(); + }); +}); From 00758b102a2db528b74b8ee027a103ee1c8dcc1e Mon Sep 17 00:00:00 2001 From: Abhigyan Patwari <126312502+abhigyanpatwari@users.noreply.github.com> Date: Sat, 21 Mar 2026 03:11:42 +0530 Subject: [PATCH 09/12] feat: add markdown file indexing (headings + cross-links) (#399) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: add markdown file indexing (headings + cross-links) Ports #286 by @dp-web4 onto current main, resolving conflicts from kuzu→lbug rename. Closes #286 Co-Authored-By: Dennis Palatov Co-Authored-By: Claude Opus 4.6 (1M context) --- gitnexus/src/core/graph/types.ts | 5 +- .../src/core/ingestion/markdown-processor.ts | 157 ++++++++++++++++++ gitnexus/src/core/ingestion/pipeline.ts | 16 ++ gitnexus/src/core/lbug/csv-generator.ts | 20 ++- gitnexus/src/core/lbug/schema.ts | 15 ++ 5 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 gitnexus/src/core/ingestion/markdown-processor.ts diff --git a/gitnexus/src/core/graph/types.ts b/gitnexus/src/core/graph/types.ts index 60b358195..f3ddc0414 100644 --- a/gitnexus/src/core/graph/types.ts +++ b/gitnexus/src/core/graph/types.ts @@ -32,7 +32,8 @@ export type NodeLabel = | 'Delegate' | 'Annotation' | 'Constructor' - | 'Template'; + | 'Template' + | 'Section'; import { SupportedLanguages } from '../../config/supported-languages.js'; @@ -65,6 +66,8 @@ export type NodeProperties = { entryPointReason?: string, // Method signature (for MRO disambiguation) parameterCount?: number, + // Section-specific (markdown heading level, 1-6) + level?: number, returnType?: string, } diff --git a/gitnexus/src/core/ingestion/markdown-processor.ts b/gitnexus/src/core/ingestion/markdown-processor.ts new file mode 100644 index 000000000..d894cee43 --- /dev/null +++ b/gitnexus/src/core/ingestion/markdown-processor.ts @@ -0,0 +1,157 @@ +/** + * Markdown Processor + * + * Extracts structure from .md files using regex (no tree-sitter dependency). + * Creates Section nodes for headings with hierarchy, and IMPORTS edges for + * cross-file links. + */ + +import path from 'node:path'; +import { generateId } from '../../lib/utils.js'; +import { KnowledgeGraph, GraphNode, GraphRelationship } from '../graph/types.js'; + +const HEADING_RE = /^(#{1,6})\s+(.+)$/; +const LINK_RE = /\[([^\]]*)\]\(([^)]+)\)/g; +const MD_EXTENSIONS = new Set(['.md', '.mdx']); + +interface MdFile { + path: string; + content: string; +} + +export const processMarkdown = ( + graph: KnowledgeGraph, + files: MdFile[], + allPathSet: Set, +): { sections: number; links: number } => { + let totalSections = 0; + let totalLinks = 0; + + for (const file of files) { + const ext = path.extname(file.path).toLowerCase(); + if (!MD_EXTENSIONS.has(ext)) continue; + + const fileNodeId = generateId('File', file.path); + // Skip if file node doesn't exist (shouldn't happen, structure-processor creates it) + if (!graph.getNode(fileNodeId)) continue; + + const lines = file.content.split('\n'); + + // --- Extract headings and build hierarchy --- + // First pass: collect all heading positions so we can compute endLine spans + const headings: { level: number; heading: string; lineNum: number }[] = []; + + for (let i = 0; i < lines.length; i++) { + const match = lines[i].match(HEADING_RE); + if (!match) continue; + + headings.push({ + level: match[1].length, + heading: match[2].trim(), + lineNum: i + 1, // 1-indexed + }); + } + + // Second pass: create nodes with proper endLine spans + const sectionStack: { level: number; id: string }[] = []; + + for (let h = 0; h < headings.length; h++) { + const { level, heading, lineNum } = headings[h]; + + // endLine = line before next heading at same or higher level, or EOF + let endLine = lines.length; + for (let j = h + 1; j < headings.length; j++) { + if (headings[j].level <= level) { + endLine = headings[j].lineNum - 1; + break; + } + } + + const sectionId = generateId('Section', `${file.path}:L${lineNum}:${heading}`); + + const node: GraphNode = { + id: sectionId, + label: 'Section', + properties: { + name: heading, + filePath: file.path, + startLine: lineNum, + endLine, + level, + description: `h${level}`, + }, + }; + graph.addNode(node); + totalSections++; + + // Find parent: pop stack until we find a level strictly less than current + while (sectionStack.length > 0 && sectionStack[sectionStack.length - 1].level >= level) { + sectionStack.pop(); + } + + const parentId = sectionStack.length > 0 + ? sectionStack[sectionStack.length - 1].id + : fileNodeId; + + graph.addRelationship({ + id: generateId('CONTAINS', `${parentId}->${sectionId}`), + type: 'CONTAINS', + sourceId: parentId, + targetId: sectionId, + confidence: 1.0, + reason: 'markdown-heading', + }); + + sectionStack.push({ level, id: sectionId }); + } + + // --- Extract links to other files in the repo --- + const fileDir = path.dirname(file.path); + const seenLinks = new Set(); + let linkMatch: RegExpExecArray | null; + LINK_RE.lastIndex = 0; + + while ((linkMatch = LINK_RE.exec(file.content)) !== null) { + const href = linkMatch[2]; + + // Skip external URLs, anchors, and mailto + if (href.startsWith('http://') || href.startsWith('https://') || + href.startsWith('#') || href.startsWith('mailto:')) { + continue; + } + + // Strip anchor fragments from local links + const cleanHref = href.split('#')[0]; + if (!cleanHref) continue; + + // Resolve relative to the file's directory, then normalize + const resolved = path.posix.normalize(path.posix.join(fileDir, cleanHref)); + + if (allPathSet.has(resolved)) { + const targetFileId = generateId('File', resolved); + + // Skip if target file node doesn't exist + if (!graph.getNode(targetFileId)) continue; + + // Dedup: skip if we've already linked this file pair + const linkKey = `${fileNodeId}->${targetFileId}`; + if (seenLinks.has(linkKey)) continue; + seenLinks.add(linkKey); + + const relId = generateId('IMPORTS', linkKey); + + graph.addRelationship({ + id: relId, + type: 'IMPORTS', + sourceId: fileNodeId, + targetId: targetFileId, + confidence: 0.8, + reason: 'markdown-link', + }); + totalLinks++; + } + } + } + + return { sections: totalSections, links: totalLinks }; +}; diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index dc2d4658b..535da40f4 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -1,5 +1,6 @@ import { createKnowledgeGraph } from '../graph/graph.js'; import { processStructure } from './structure-processor.js'; +import { processMarkdown } from './markdown-processor.js'; import { processParsing } from './parsing-processor.js'; import { processImports, @@ -99,6 +100,21 @@ export const runPipelineFromRepo = async ( stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount }, }); + + // ── Phase 2.5: Markdown processing (headings + cross-links) ──────── + const mdScanned = scannedFiles.filter(f => f.path.endsWith('.md') || f.path.endsWith('.mdx')); + if (mdScanned.length > 0) { + const mdContents = await readFileContents(repoPath, mdScanned.map(f => f.path)); + const mdFiles = mdScanned + .filter(f => mdContents.has(f.path)) + .map(f => ({ path: f.path, content: mdContents.get(f.path)! })); + const allPathSet = new Set(allPaths); + const mdResult = processMarkdown(graph, mdFiles, allPathSet); + if (isDev) { + console.log(` Markdown: ${mdResult.sections} sections, ${mdResult.links} cross-links from ${mdFiles.length} files`); + } + } + // ── Phase 3+4: Chunked read + parse ──────────────────────────────── // Group parseable files into byte-budget chunks so only ~20MB of source // is in memory at a time. Each chunk is: read → parse → extract → free. diff --git a/gitnexus/src/core/lbug/csv-generator.ts b/gitnexus/src/core/lbug/csv-generator.ts index 9c99eb04b..e8f8a2fc8 100644 --- a/gitnexus/src/core/lbug/csv-generator.ts +++ b/gitnexus/src/core/lbug/csv-generator.ts @@ -238,6 +238,9 @@ export const streamAllCSVsToDisk = async ( const communityWriter = new BufferedCSVWriter(path.join(csvDir, 'community.csv'), 'id,label,heuristicLabel,keywords,description,enrichedBy,cohesion,symbolCount'); const processWriter = new BufferedCSVWriter(path.join(csvDir, 'process.csv'), 'id,label,heuristicLabel,processType,stepCount,communities,entryPointId,terminalId'); + // Section nodes have an extra 'level' column + const sectionWriter = new BufferedCSVWriter(path.join(csvDir, 'section.csv'), 'id,name,filePath,startLine,endLine,level,content,description'); + // Multi-language node types share the same CSV shape (no isExported column) const multiLangHeader = 'id,name,filePath,startLine,endLine,content,description'; const MULTI_LANG_TYPES = ['Struct', 'Enum', 'Macro', 'Typedef', 'Union', 'Namespace', 'Trait', 'Impl', @@ -324,6 +327,20 @@ export const streamAllCSVsToDisk = async ( ].join(',')); break; } + case 'Section': { + const content = await extractContent(node, contentCache); + await sectionWriter.addRow([ + escapeCSVField(node.id), + escapeCSVField(node.properties.name || ''), + escapeCSVField(node.properties.filePath || ''), + escapeCSVNumber(node.properties.startLine, -1), + escapeCSVNumber(node.properties.endLine, -1), + escapeCSVNumber((node.properties as any).level, 1), + escapeCSVField(content), + escapeCSVField((node.properties as any).description || ''), + ].join(',')); + break; + } default: { // Code element nodes (Function, Class, Interface, CodeElement) const writer = codeWriterMap[node.label]; @@ -361,7 +378,7 @@ export const streamAllCSVsToDisk = async ( } // Finish all node writers - const allWriters = [fileWriter, folderWriter, functionWriter, classWriter, interfaceWriter, methodWriter, codeElemWriter, communityWriter, processWriter, ...multiLangWriters.values()]; + const allWriters = [fileWriter, folderWriter, functionWriter, classWriter, interfaceWriter, methodWriter, codeElemWriter, communityWriter, processWriter, sectionWriter, ...multiLangWriters.values()]; await Promise.all(allWriters.map(w => w.finish())); // --- Stream relationship CSV --- @@ -387,6 +404,7 @@ export const streamAllCSVsToDisk = async ( ['Interface', interfaceWriter], ['Method', methodWriter], ['CodeElement', codeElemWriter], ['Community', communityWriter], ['Process', processWriter], + ['Section' as NodeTableName, sectionWriter], ...Array.from(multiLangWriters.entries()).map(([name, w]) => [name as NodeTableName, w] as [NodeTableName, BufferedCSVWriter]), ]; for (const [name, writer] of tableMap) { diff --git a/gitnexus/src/core/lbug/schema.ts b/gitnexus/src/core/lbug/schema.ts index ef1fbad50..c08292466 100644 --- a/gitnexus/src/core/lbug/schema.ts +++ b/gitnexus/src/core/lbug/schema.ts @@ -192,6 +192,19 @@ export const ANNOTATION_SCHEMA = CODE_ELEMENT_BASE('Annotation'); export const CONSTRUCTOR_SCHEMA = CODE_ELEMENT_BASE('Constructor'); export const TEMPLATE_SCHEMA = CODE_ELEMENT_BASE('Template'); export const MODULE_SCHEMA = CODE_ELEMENT_BASE('Module'); +// Markdown heading sections +export const SECTION_SCHEMA = ` +CREATE NODE TABLE Section ( + id STRING, + name STRING, + filePath STRING, + startLine INT64, + endLine INT64, + level INT64, + content STRING, + description STRING, + PRIMARY KEY (id) +)`; // ============================================================================ // RELATION TABLE SCHEMA @@ -289,6 +302,8 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( FROM \`Template\` TO Interface, FROM \`Template\` TO \`Constructor\`, FROM \`Module\` TO \`Module\`, + FROM Section TO Section, + FROM Section TO File, FROM CodeElement TO Community, FROM Interface TO Community, FROM Interface TO Function, From 1f7764c49baae7901f8c6308927dffbb8e1d41de Mon Sep 17 00:00:00 2001 From: Abhigyan Patwari <126312502+abhigyanpatwari@users.noreply.github.com> Date: Sat, 21 Mar 2026 04:00:16 +0530 Subject: [PATCH 10/12] fix: register Section in NODE_TABLES and NODE_SCHEMA_QUERIES (#401) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add markdown file indexing (headings + cross-links) Parse .md/.mdx files using regex (no tree-sitter dependency) to extract: - Section nodes from headings (h1-h6) with hierarchy via CONTAINS edges - Cross-file IMPORTS edges from markdown links to other repo files Ported from #286 to resolve conflicts with kuzu→lbug rename. Co-Authored-By: Dennis Palatov Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add Section to NODE_TABLES and NODE_SCHEMA_QUERIES The Section schema was defined but not registered in NODE_TABLES or NODE_SCHEMA_QUERIES, so the table was never created in the database. Also adds missing FROM File TO Section relation entry. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: update schema test counts for Section node type NODE_TABLES: 27→28, NODE_SCHEMA_QUERIES: 27→28, SCHEMA_QUERIES: 29→30 Co-Authored-By: Claude Opus 4.6 (1M context) * test: add diagnostic output to skills-e2e idempotency test Show stdout/stderr in assertion message so CI failures reveal why the second analyze --skills run exits with code 1. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add Section COPY query with level column in lbug-adapter Section table has 8 columns (includes level) but getCopyQuery fell through to the default 7-column multi-language path. Adds explicit Section cases to getCopyQuery and insertNodeToLbug/upsertNodeToLbug. Error was: COPY failed for Section: Number of columns mismatch. Expected 7 but got 8. --------- Co-authored-by: Dennis Palatov --- gitnexus/src/core/lbug/lbug-adapter.ts | 9 +++++++++ gitnexus/src/core/lbug/schema.ts | 5 ++++- gitnexus/test/integration/skills-e2e.test.ts | 12 ++++++++++-- gitnexus/test/unit/schema.test.ts | 6 +++--- 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index bd4061797..ff8345d06 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -336,6 +336,9 @@ const getCopyQuery = (table: NodeTableName, filePath: string): string => { if (table === 'Process') { return `COPY ${t}(id, label, heuristicLabel, processType, stepCount, communities, entryPointId, terminalId) FROM "${filePath}" ${COPY_CSV_OPTS}`; } + if (table === 'Section') { + return `COPY ${t}(id, name, filePath, startLine, endLine, level, content, description) FROM "${filePath}" ${COPY_CSV_OPTS}`; + } if (table === 'Method') { return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content, description, parameterCount, returnType) FROM "${filePath}" ${COPY_CSV_OPTS}`; } @@ -380,6 +383,9 @@ export const insertNodeToLbug = async ( query = `CREATE (n:File {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}, content: ${escapeValue(properties.content || '')}})`; } else if (label === 'Folder') { query = `CREATE (n:Folder {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}})`; + } else if (label === 'Section') { + const descPart = properties.description ? `, description: ${escapeValue(properties.description)}` : ''; + query = `CREATE (n:Section {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, level: ${properties.level || 1}, content: ${escapeValue(properties.content || '')}${descPart}})`; } else if (TABLES_WITH_EXPORTED.has(label)) { const descPart = properties.description ? `, description: ${escapeValue(properties.description)}` : ''; query = `CREATE (n:${t} {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, isExported: ${!!properties.isExported}, content: ${escapeValue(properties.content || '')}${descPart}})`; @@ -451,6 +457,9 @@ export const batchInsertNodesToLbug = async ( query = `MERGE (n:File {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.content = ${escapeValue(properties.content || '')}`; } else if (label === 'Folder') { query = `MERGE (n:Folder {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}`; + } else if (label === 'Section') { + const descPart = properties.description ? `, n.description = ${escapeValue(properties.description)}` : ''; + query = `MERGE (n:Section {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.level = ${properties.level || 1}, n.content = ${escapeValue(properties.content || '')}${descPart}`; } else if (TABLES_WITH_EXPORTED.has(label)) { const descPart = properties.description ? `, n.description = ${escapeValue(properties.description)}` : ''; query = `MERGE (n:${t} {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.isExported = ${!!properties.isExported}, n.content = ${escapeValue(properties.content || '')}${descPart}`; diff --git a/gitnexus/src/core/lbug/schema.ts b/gitnexus/src/core/lbug/schema.ts index c08292466..a47aa9674 100644 --- a/gitnexus/src/core/lbug/schema.ts +++ b/gitnexus/src/core/lbug/schema.ts @@ -13,7 +13,7 @@ // NODE TABLE NAMES // ============================================================================ export const NODE_TABLES = [ - 'File', 'Folder', 'Function', 'Class', 'Interface', 'Method', 'CodeElement', 'Community', 'Process', + 'File', 'Folder', 'Function', 'Class', 'Interface', 'Method', 'CodeElement', 'Community', 'Process', 'Section', // Multi-language support 'Struct', 'Enum', 'Macro', 'Typedef', 'Union', 'Namespace', 'Trait', 'Impl', 'TypeAlias', 'Const', 'Static', 'Property', 'Record', 'Delegate', 'Annotation', 'Constructor', 'Template', 'Module' @@ -238,6 +238,7 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( FROM File TO \`Constructor\`, FROM File TO \`Template\`, FROM File TO \`Module\`, + FROM File TO Section, FROM Folder TO Folder, FROM Folder TO File, FROM Function TO Function, @@ -462,6 +463,8 @@ export const NODE_SCHEMA_QUERIES = [ CONSTRUCTOR_SCHEMA, TEMPLATE_SCHEMA, MODULE_SCHEMA, + // Markdown support + SECTION_SCHEMA, ]; export const REL_SCHEMA_QUERIES = [ diff --git a/gitnexus/test/integration/skills-e2e.test.ts b/gitnexus/test/integration/skills-e2e.test.ts index 31f836179..58a98e984 100644 --- a/gitnexus/test/integration/skills-e2e.test.ts +++ b/gitnexus/test/integration/skills-e2e.test.ts @@ -2389,8 +2389,16 @@ export function createEntry(level: string, msg: string) { /* CI timeout tolerance */ if (result1.status === null || result2.status === null) return; - expect(result1.status).toBe(0); - expect(result2.status).toBe(0); + expect(result1.status, [ + `first analyze --skills exited with code ${result1.status}`, + `stdout: ${result1.stdout?.slice(0, 500)}`, + `stderr: ${result1.stderr?.slice(0, 500)}`, + ].join('\n')).toBe(0); + expect(result2.status, [ + `second analyze --skills exited with code ${result2.status}`, + `stdout: ${result2.stdout?.slice(0, 500)}`, + `stderr: ${result2.stderr?.slice(0, 500)}`, + ].join('\n')).toBe(0); const generatedDir = path.join(tmpDir, '.claude', 'skills', 'generated'); expect(fs.existsSync(generatedDir)).toBe(true); diff --git a/gitnexus/test/unit/schema.test.ts b/gitnexus/test/unit/schema.test.ts index 87e369652..75235f0e4 100644 --- a/gitnexus/test/unit/schema.test.ts +++ b/gitnexus/test/unit/schema.test.ts @@ -40,7 +40,7 @@ describe('LadybugDB Schema', () => { it('has expected total count', () => { // 9 core + 18 multi-language = 27 - expect(NODE_TABLES).toHaveLength(27); + expect(NODE_TABLES).toHaveLength(28); }); }); @@ -164,7 +164,7 @@ describe('LadybugDB Schema', () => { describe('schema query ordering', () => { it('NODE_SCHEMA_QUERIES has correct count', () => { - expect(NODE_SCHEMA_QUERIES).toHaveLength(27); + expect(NODE_SCHEMA_QUERIES).toHaveLength(28); }); it('REL_SCHEMA_QUERIES has one relation table', () => { @@ -173,7 +173,7 @@ describe('LadybugDB Schema', () => { it('SCHEMA_QUERIES includes all node + rel + embedding schemas', () => { // 27 node + 1 rel + 1 embedding = 29 - expect(SCHEMA_QUERIES).toHaveLength(29); + expect(SCHEMA_QUERIES).toHaveLength(30); }); it('node schemas come before relation schemas in SCHEMA_QUERIES', () => { From 01ff2a75404e7f157cb7664bcac1d99d012ef06b Mon Sep 17 00:00:00 2001 From: abhigyanpatwari Date: Sat, 21 Mar 2026 05:07:00 +0530 Subject: [PATCH 11/12] docs: add gitnexus-stable-ops to community integrations Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a885fcf6e..5f2255889 100644 --- a/README.md +++ b/README.md @@ -91,11 +91,16 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up > **Claude Code** gets the deepest integration: MCP tools + agent skills + PreToolUse hooks that enrich searches with graph context + PostToolUse hooks that auto-reindex after commits. -### Community Integrations +## Community Integrations -| Agent | Install | Source | -|-------|---------|--------| -| [pi](https://pi.dev) | `pi install npm:pi-gitnexus` | [pi-gitnexus](https://github.com/tintinweb/pi-gitnexus) | +Built by the community — not officially maintained, but worth checking out. + +| Project | Author | Description | +|---------|--------|-------------| +| [pi-gitnexus](https://github.com/tintinweb/pi-gitnexus) | [@tintinweb](https://github.com/tintinweb) | GitNexus plugin for [pi](https://pi.dev) — `pi install npm:pi-gitnexus` | +| [gitnexus-stable-ops](https://github.com/ShunsukeHayashi/gitnexus-stable-ops) | [@ShunsukeHayashi](https://github.com/ShunsukeHayashi) | Stable ops & deployment workflows (Miyabi ecosystem) | + +> Have a project built on GitNexus? Open a PR to add it here! If you prefer manual configuration: From 11575cf6c86dc3e4daf10456eefe9722fe431855 Mon Sep 17 00:00:00 2001 From: 0xfabs Date: Sat, 21 Mar 2026 01:39:25 +0100 Subject: [PATCH 12/12] fix: hydrate worker DB in server mode + fix LadybugDB getAll API mismatch (#398) (#404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix server/bridge mode leaving the web UI with 0 nodes and broken Query/Processes/embeddings by hydrating the worker-side LadybugDB and BM25 indexes after loading graph data from the backend. Also fix LadybugDB QueryResult API mismatch where result.getAll() does not exist in some @ladybugdb/wasm-core versions — falls back to getAllObjects() or getAllRows(). --- gitnexus-web/src/App.tsx | 33 +++++++++----- gitnexus-web/src/core/lbug/lbug-adapter.ts | 16 +++---- gitnexus-web/src/hooks/useAppState.tsx | 36 +++++++++++---- gitnexus-web/src/workers/ingestion.worker.ts | 46 ++++++++++++++++++++ 4 files changed, 104 insertions(+), 27 deletions(-) diff --git a/gitnexus-web/src/App.tsx b/gitnexus-web/src/App.tsx index 2de2a4a34..72b78a5d9 100644 --- a/gitnexus-web/src/App.tsx +++ b/gitnexus-web/src/App.tsx @@ -40,6 +40,7 @@ const AppContent = () => { availableRepos, setAvailableRepos, switchRepo, + hydrateWorkerFromServer, } = useAppState(); const graphCanvasRef = useRef(null); @@ -157,21 +158,31 @@ const AppContent = () => { // Transition directly to exploring view setViewMode('exploring'); + setProgress(null); - // Initialize agent if LLM is configured - if (getActiveProviderConfig()) { - initializeAgent(projectName); - } + // Hydrate the worker-side DB (LadybugDB + BM25) so Query/Processes/embeddings work + hydrateWorkerFromServer(result.nodes, result.relationships, result.fileContents).then(() => { + // Initialize agent if LLM is configured + if (getActiveProviderConfig()) { + initializeAgent(projectName); + } - // Auto-start embeddings - startEmbeddings().catch((err) => { - if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) { - startEmbeddings('wasm').catch(console.warn); - } else { - console.warn('Embeddings auto-start failed:', err); + // Auto-start embeddings (now that LadybugDB is ready) + startEmbeddings().catch((err) => { + if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) { + startEmbeddings('wasm').catch(console.warn); + } else { + console.warn('Embeddings auto-start failed:', err); + } + }); + }).catch((err) => { + console.warn('Worker hydration failed (non-fatal):', err); + // Still initialize agent even if hydration fails + if (getActiveProviderConfig()) { + initializeAgent(projectName); } }); - }, [setViewMode, setGraph, setFileContents, setProjectName, initializeAgent, startEmbeddings]); + }, [setViewMode, setGraph, setFileContents, setProjectName, setProgress, initializeAgent, startEmbeddings, hydrateWorkerFromServer]); // Auto-connect when ?server query param is present (bookmarkable shortcut) const autoConnectRan = useRef(false); diff --git a/gitnexus-web/src/core/lbug/lbug-adapter.ts b/gitnexus-web/src/core/lbug/lbug-adapter.ts index eed042ce2..916d4dacc 100644 --- a/gitnexus-web/src/core/lbug/lbug-adapter.ts +++ b/gitnexus-web/src/core/lbug/lbug-adapter.ts @@ -190,7 +190,7 @@ export const loadGraphToLbug = async ( for (const tableName of NODE_TABLES) { try { const countRes = await conn.query(`MATCH (n:${tableName}) RETURN count(n) AS cnt`); - const countRows = await countRes.getAll(); + const countRows = await (countRes.getAll?.() ?? countRes.getAllObjects?.() ?? countRes.getAllRows?.() ?? []); const countRow = countRows[0]; const count = countRow ? (countRow.cnt ?? countRow[0] ?? 0) : 0; totalNodes += Number(count); @@ -293,8 +293,8 @@ export const executeQuery = async (cypher: string): Promise => { }); } - // Collect all rows - const allRows = await result.getAll(); + // Collect all rows (handle API differences across LadybugDB versions) + const allRows = await (result.getAll?.() ?? result.getAllObjects?.() ?? result.getAllRows?.() ?? []); const rows: any[] = []; for (const row of allRows) { // Convert tuple to named object if we have column names and row is array @@ -331,7 +331,7 @@ export const getLbugStats = async (): Promise<{ nodes: number; edges: number }> for (const tableName of NODE_TABLES) { try { const nodeResult = await conn.query(`MATCH (n:${tableName}) RETURN count(n) AS cnt`); - const nodeRows = await nodeResult.getAll(); + const nodeRows = await (nodeResult.getAll?.() ?? nodeResult.getAllObjects?.() ?? nodeResult.getAllRows?.() ?? []); const nodeRow = nodeRows[0]; totalNodes += Number(nodeRow?.cnt ?? nodeRow?.[0] ?? 0); } catch { @@ -343,7 +343,7 @@ export const getLbugStats = async (): Promise<{ nodes: number; edges: number }> let totalEdges = 0; try { const edgeResult = await conn.query(`MATCH ()-[r:${REL_TABLE_NAME}]->() RETURN count(r) AS cnt`); - const edgeRows = await edgeResult.getAll(); + const edgeRows = await (edgeResult.getAll?.() ?? edgeResult.getAllObjects?.() ?? edgeResult.getAllRows?.() ?? []); const edgeRow = edgeRows[0]; totalEdges = Number(edgeRow?.cnt ?? edgeRow?.[0] ?? 0); } catch { @@ -408,7 +408,7 @@ export const executePrepared = async ( const result = await conn.execute(stmt, params); - const rows = await result.getAll(); + const rows = await (result.getAll?.() ?? result.getAllObjects?.() ?? result.getAllRows?.() ?? []); await stmt.close(); return rows; @@ -472,7 +472,7 @@ export const testArrayParams = async (): Promise<{ success: boolean; error?: str for (const tableName of NODE_TABLES) { try { const nodeResult = await conn.query(`MATCH (n:${tableName}) RETURN n.id AS id LIMIT 1`); - const nodeRows = await nodeResult.getAll(); + const nodeRows = await (nodeResult.getAll?.() ?? nodeResult.getAllObjects?.() ?? nodeResult.getAllRows?.() ?? []); const nodeRow = nodeRows[0]; if (nodeRow) { testNodeId = nodeRow.id ?? nodeRow[0]; @@ -509,7 +509,7 @@ export const testArrayParams = async (): Promise<{ success: boolean; error?: str const verifyResult = await conn.query( `MATCH (e:${EMBEDDING_TABLE_NAME} {nodeId: '${testNodeId}'}) RETURN e.embedding AS emb` ); - const verifyRows = await verifyResult.getAll(); + const verifyRows = await (verifyResult.getAll?.() ?? verifyResult.getAllObjects?.() ?? verifyResult.getAllRows?.() ?? []); const verifyRow = verifyRows[0]; const storedEmb = verifyRow?.emb ?? verifyRow?.[0]; diff --git a/gitnexus-web/src/hooks/useAppState.tsx b/gitnexus-web/src/hooks/useAppState.tsx index 233a710ee..843f967d7 100644 --- a/gitnexus-web/src/hooks/useAppState.tsx +++ b/gitnexus-web/src/hooks/useAppState.tsx @@ -125,6 +125,7 @@ interface AppState { runPipelineFromFiles: (files: FileEntry[], onProgress: (p: PipelineProgress) => void, clusteringConfig?: ProviderConfig) => Promise; runQuery: (cypher: string) => Promise; isDatabaseReady: () => Promise; + hydrateWorkerFromServer: (nodes: any[], relationships: any[], fileContents: Record) => Promise; // Embedding state embeddingStatus: EmbeddingStatus; @@ -482,6 +483,16 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { } }, []); + const hydrateWorkerFromServer = useCallback(async ( + nodes: any[], + relationships: any[], + fileContents: Record + ): Promise => { + const api = apiRef.current; + if (!api) throw new Error('Worker not initialized'); + await api.hydrateFromServerData(nodes, relationships, fileContents); + }, []); + // Embedding methods const startEmbeddings = useCallback(async (forceDevice?: 'webgpu' | 'wasm'): Promise => { const api = apiRef.current; @@ -1018,15 +1029,23 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { setFileContents(fileMap); setViewMode('exploring'); + setProgress(null); - if (getActiveProviderConfig()) initializeAgent(pName); + // Hydrate the worker-side DB (LadybugDB + BM25) so Query/Processes/embeddings work + hydrateWorkerFromServer(result.nodes, result.relationships, result.fileContents).then(() => { + if (getActiveProviderConfig()) initializeAgent(pName); - startEmbeddings().catch((err) => { - if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) { - startEmbeddings('wasm').catch(console.warn); - } else { - console.warn('Embeddings auto-start failed:', err); - } + startEmbeddings().catch((err) => { + if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) { + startEmbeddings('wasm').catch(console.warn); + } else { + console.warn('Embeddings auto-start failed:', err); + } + }); + }).catch((err) => { + console.warn('Worker hydration failed (non-fatal):', err); + // Still initialize agent even if hydration fails + if (getActiveProviderConfig()) initializeAgent(pName); }); } catch (err) { console.error('Repo switch failed:', err); @@ -1037,7 +1056,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { }); setTimeout(() => { setViewMode('exploring'); setProgress(null); }, 3000); } - }, [serverBaseUrl, setProgress, setViewMode, setProjectName, setGraph, setFileContents, initializeAgent, startEmbeddings, setHighlightedNodeIds, clearAIToolHighlights, clearBlastRadius, setSelectedNode, setQueryResult, setCodeReferences, setCodePanelOpen, setCodeReferenceFocus]); + }, [serverBaseUrl, setProgress, setViewMode, setProjectName, setGraph, setFileContents, initializeAgent, startEmbeddings, hydrateWorkerFromServer, setHighlightedNodeIds, clearAIToolHighlights, clearBlastRadius, setSelectedNode, setQueryResult, setCodeReferences, setCodePanelOpen, setCodeReferenceFocus]); const removeCodeReference = useCallback((id: string) => { setCodeReferences(prev => { @@ -1142,6 +1161,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { runPipelineFromFiles, runQuery, isDatabaseReady, + hydrateWorkerFromServer, // Embedding state and methods embeddingStatus, embeddingProgress, diff --git a/gitnexus-web/src/workers/ingestion.worker.ts b/gitnexus-web/src/workers/ingestion.worker.ts index 0f36d8577..54974ca56 100644 --- a/gitnexus-web/src/workers/ingestion.worker.ts +++ b/gitnexus-web/src/workers/ingestion.worker.ts @@ -1,5 +1,7 @@ import * as Comlink from 'comlink'; import { runIngestionPipeline, runPipelineFromFiles } from '../core/ingestion/pipeline'; +import { createKnowledgeGraph } from '../core/graph/graph'; +import type { GraphNode, GraphRelationship } from '../core/graph/types'; import { PipelineProgress, SerializablePipelineResult, serializePipelineResult } from '../types/pipeline'; import { FileEntry } from '../services/zip'; import { @@ -207,6 +209,50 @@ const workerApi = { return serializePipelineResult(result); }, + /** + * Hydrate the worker-side database and indexes from server-loaded data. + * This is the missing step when using server/bridge mode — the main thread + * builds the React graph, but the worker's LadybugDB + BM25 stay empty. + */ + async hydrateFromServerData( + nodes: GraphNode[], + relationships: GraphRelationship[], + fileContents: Record + ): Promise { + // 1. Build a KnowledgeGraph the same way the pipeline does + const graph = createKnowledgeGraph(); + for (const node of nodes) graph.addNode(node); + for (const rel of relationships) graph.addRelationship(rel); + + // 2. Store file contents for grep/read tools + storedFileContents = new Map(Object.entries(fileContents)); + + // 3. Build BM25 keyword index + const bm25DocCount = buildBM25Index(storedFileContents); + if (import.meta.env.DEV) { + console.log(`🔍 BM25 index built (server mode): ${bm25DocCount} documents`); + } + + // 4. Set currentGraphResult so the agent context builder works + currentGraphResult = { graph, fileContents: storedFileContents }; + + // 5. Load graph into LadybugDB for Cypher queries (optional — gracefully degrades) + try { + const lbug = await getLbugAdapter(); + await lbug.loadGraphToLbug(graph, storedFileContents); + + if (import.meta.env.DEV) { + const stats = await lbug.getLbugStats(); + console.log('✅ LadybugDB hydrated (server mode):', stats); + } + } catch (err) { + // LadybugDB is optional — silently continue without it + if (import.meta.env.DEV) { + console.warn('⚠️ LadybugDB hydration failed (non-fatal):', err); + } + } + }, + /** * Execute a Cypher query against the LadybugDB database * @param cypher - The Cypher query string