diff --git a/README.md b/README.md index 44f56edd0..1b77686a3 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,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, 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. +**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, Antigravity, 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. --- @@ -48,7 +48,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, Codex, Windsurf, OpenCode | Quick exploration, demos, one-off analysis | +| **For** | Daily development with Cursor, Claude Code, Antigravity, 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** | LadybugDB native (fast, persistent) | LadybugDB WASM (in-memory, per session) | @@ -114,16 +114,20 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up ### Editor Support -| Editor | MCP | Skills | Hooks (auto-augment) | Support | -| --------------- | --- | ------ | --------------------------------------------------------------------------------------- | ------------ | -| **Claude Code** | Yes | Yes | Yes (PreToolUse + PostToolUse) | **Full** | -| **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](gitnexus-cursor-integration/README.md#hook-install)) | **Full** | -| **Codex** | Yes | Yes | — | MCP + Skills | -| **Windsurf** | Yes | — | — | MCP | -| **OpenCode** | Yes | Yes | — | MCP + Skills | +| Editor | MCP | Skills | Hooks (auto-augment) | Support | +| -------------------- | --- | ------ | --------------------------------------------------------------------------------------- | ------------ | +| **Claude Code** | Yes | Yes | Yes (PreToolUse + PostToolUse) | **Full** | +| **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](gitnexus-cursor-integration/README.md#hook-install)) | **Full** | +| **Antigravity** (Google) | Yes | Yes | Yes (AfterTool, [Gemini CLI hooks schema](https://geminicli.com/docs/hooks/reference/))[¹](#fn-antigravity-hooks) | **Full** | +| **Codex** | Yes | Yes | — | MCP + Skills | +| **Windsurf** | Yes | — | — | MCP | +| **OpenCode** | Yes | Yes | — | MCP + Skills | > **Claude Code** gets the deepest integration: MCP tools + agent skills + PreToolUse hooks that enrich searches with graph context + PostToolUse hooks that detect a stale index after commits and prompt the agent to reindex. + +> ¹ **Antigravity hooks** follow the [Gemini CLI hooks reference](https://geminicli.com/docs/hooks/reference/) (Antigravity 2.0 is the documented successor to Gemini CLI). Augmentation runs in `AfterTool` because `BeforeTool` has no context-injection channel in the Gemini contract — the agent sees graph context appended to the tool result via `hookSpecificOutput.additionalContext`. Stale-index hints land in the same channel after a successful `git commit/merge/rebase/cherry-pick/pull`. The schema may evolve if Antigravity-specific hook docs diverge from Gemini CLI's; the implementation will track those changes. + ## Community Integrations Built by the community — not officially maintained, but worth checking out. @@ -168,6 +172,21 @@ codex mcp add gitnexus -- npx -y gitnexus@latest mcp } ``` +**Antigravity** (Google) — `~/.gemini/antigravity/mcp_config.json`: + +```json +{ + "mcpServers": { + "gitnexus": { + "command": "npx", + "args": ["-y", "gitnexus@latest", "mcp"] + } + } +} +``` + +> `gitnexus setup` also merges an `AfterTool` entry into `~/.gemini/settings.json` (under the canonical [Gemini CLI hooks schema](https://geminicli.com/docs/hooks/reference/)) and installs skills to `~/.gemini/antigravity/skills/`. Existing user hooks are preserved. The hook adapter's path is rewritten at install time, so run `gitnexus setup` rather than hand-editing. + **OpenCode** (`~/.config/opencode/config.json`): ```json diff --git a/gitnexus/README.md b/gitnexus/README.md index 9b18e1dbb..b313a7f46 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**, **Codex**, **Windsurf**, **Cline**, **OpenCode**, and any MCP-compatible tool. +Works with **Cursor**, **Claude Code**, **Antigravity** (Google), **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 | Yes (postToolUse, [manual install](../gitnexus-cursor-integration/README.md#hook-install)) | **Full** | +| **Antigravity** (Google) | Yes | Yes | Yes (AfterTool, [Gemini CLI hooks schema](https://geminicli.com/docs/hooks/reference/)) | **Full** | | **Codex** | Yes | Yes | — | MCP + Skills | | **Windsurf** | Yes | — | — | MCP | | **OpenCode** | Yes | Yes | — | MCP + Skills | diff --git a/gitnexus/hooks/antigravity/gitnexus-antigravity-hook.cjs b/gitnexus/hooks/antigravity/gitnexus-antigravity-hook.cjs new file mode 100755 index 000000000..4bd807631 --- /dev/null +++ b/gitnexus/hooks/antigravity/gitnexus-antigravity-hook.cjs @@ -0,0 +1,346 @@ +#!/usr/bin/env node +/** + * GitNexus Antigravity / Gemini CLI Hook Adapter + * + * Bridges the Gemini CLI hooks contract (also used by Antigravity 2.0 — see + * https://geminicli.com/docs/hooks/reference/) to the same graph-aware + * augmentation / staleness signals the Claude Code hook provides. + * + * Schema differences from the Claude adapter: + * - Events are BeforeTool / AfterTool (not PreToolUse / PostToolUse). + * - Tool names are snake_case (run_shell_command, search_file_content, glob). + * - BeforeTool cannot inject context — decision: "allow" provides no channel + * to surface text to the agent. Augmentation therefore runs in AfterTool, + * where `hookSpecificOutput.additionalContext` is appended to the tool + * result the agent sees. + * - Stale-index hints after git commit/merge/rebase/cherry-pick/pull are + * surfaced via the same `additionalContext` channel (so the agent reads + * them, not only the user) and mirrored to stderr for terminal users. + * - Stdin uses `tool_name`, `tool_input`, and `tool_response` + * (with `llmContent`, `returnDisplay`, optional `error`). + */ + +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const { acquireHookSlot } = require('./hook-lock.cjs'); +const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs'); + +function readInput() { + try { + const data = fs.readFileSync(0, 'utf-8'); + return JSON.parse(data); + } catch { + return {}; + } +} + +function isGlobalRegistryDir(candidate) { + if (fs.existsSync(path.join(candidate, 'meta.json'))) return false; + return ( + fs.existsSync(path.join(candidate, 'registry.json')) || + fs.existsSync(path.join(candidate, 'repos')) + ); +} + +function walkForGitNexusDir(startDir) { + let dir = startDir; + for (let i = 0; i < 5; i++) { + const candidate = path.join(dir, '.gitnexus'); + if (fs.existsSync(candidate)) { + if (!isGlobalRegistryDir(candidate)) return candidate; + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; +} + +function findCanonicalRepoRoot(cwd) { + try { + const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], { + encoding: 'utf-8', + timeout: 2000, + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + if (result.error || result.status !== 0) return null; + const commonDir = (result.stdout || '').trim(); + if (!commonDir || !path.isAbsolute(commonDir)) return null; + return path.dirname(commonDir); + } catch { + return null; + } +} + +function findGitNexusDir(startDir) { + const cwd = startDir || process.cwd(); + const fromCwd = walkForGitNexusDir(cwd); + if (fromCwd) return fromCwd; + const canonicalRoot = findCanonicalRepoRoot(cwd); + if (canonicalRoot && canonicalRoot !== cwd) { + return walkForGitNexusDir(canonicalRoot); + } + return null; +} + +function hasGitNexusServerOwner(gitNexusDir) { + return hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid); +} + +function extractAugmentContext(stderr) { + const output = (stderr || '').trim(); + const marker = output.indexOf('[GitNexus]'); + const debug = process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true'; + if (debug && output.length > 0) { + // Emit the FULL discarded prefix (everything before the marker, or all of + // it when no marker is present) so suppressed diagnostics — LadybugDB lock + // warnings, parser errors, etc. — remain recoverable on the hook's own + // stderr. Mirrors the Claude adapter's debug behavior. + const discarded = marker === -1 ? output : output.slice(0, marker).trim(); + if (discarded.length > 0) { + process.stderr.write(`[GitNexus hook] augment stderr discarded prefix:\n${discarded}\n`); + } + } + return marker === -1 ? '' : output.slice(marker).trim(); +} + +/** + * Extract a usable search token from a tool invocation. + * - search_file_content / glob: top-level `pattern` (sometimes `query`). + * - run_shell_command: parse rg/grep argv, returning the first non-flag + * positional ≥ 3 chars. + * Returns null when the tool is not a recognized search or the pattern is + * too short. + */ +function extractPattern(toolName, toolInput) { + if (toolName === 'search_file_content') { + const q = toolInput.pattern || toolInput.query || ''; + return typeof q === 'string' && q.length >= 3 ? q : null; + } + + if (toolName === 'glob') { + const raw = toolInput.pattern || ''; + const match = raw.match(/[*\/]([a-zA-Z][a-zA-Z0-9_-]{2,})/); + return match ? match[1] : null; + } + + if (toolName === 'run_shell_command') { + const cmd = toolInput.command || ''; + if (!/\brg\b|\bgrep\b/.test(cmd)) return null; + + const tokens = cmd.split(/\s+/); + let foundCmd = false; + let skipNext = false; + const flagsWithValues = new Set([ + '-e', + '-f', + '-m', + '-A', + '-B', + '-C', + '-g', + '--glob', + '-t', + '--type', + '--include', + '--exclude', + ]); + + for (const token of tokens) { + if (skipNext) { + skipNext = false; + continue; + } + if (!foundCmd) { + if (/\brg$|\bgrep$/.test(token)) foundCmd = true; + continue; + } + if (token.startsWith('-')) { + if (flagsWithValues.has(token)) skipNext = true; + continue; + } + const cleaned = token.replace(/['"]/g, ''); + return cleaned.length >= 3 ? cleaned : null; + } + return null; + } + + return null; +} + +function resolveCliPath() { + const fromEnv = process.env.GITNEXUS_HOOK_CLI_PATH; + if (fromEnv !== undefined && String(fromEnv).trim() && fs.existsSync(String(fromEnv))) { + return String(fromEnv); + } + let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js'); + if (!fs.existsSync(cliPath)) { + try { + cliPath = require.resolve('gitnexus/dist/cli/index.js'); + } catch { + cliPath = ''; + } + } + return cliPath; +} + +function runGitNexusCli(cliPath, args, cwd, timeout) { + const isWin = process.platform === 'win32'; + if (cliPath) { + return spawnSync(process.execPath, [cliPath, ...args], { + encoding: 'utf-8', + timeout, + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + } + return spawnSync(isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args], { + encoding: 'utf-8', + timeout: timeout + 5000, + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); +} + +function writeAdditionalContext(text) { + process.stdout.write( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'AfterTool', + additionalContext: text, + }, + }), + ); +} + +function toolSucceeded(toolResponse) { + if (!toolResponse || typeof toolResponse !== 'object') return true; + if (toolResponse.error) return false; + if (toolResponse.exit_code != null && Number(toolResponse.exit_code) !== 0) return false; + return true; +} + +/** + * Compute the additionalContext for a tool result, if any. + * 1. Graph augment for search-like tools (search_file_content, glob, + * run_shell_command-with-rg/grep) that completed successfully. + * 2. Stale-index hint after a successful git commit/merge/rebase/cherry- + * pick/pull. + * Returns null when nothing is to be appended. + */ +function buildAfterToolContext(input) { + const cwd = input.cwd || process.cwd(); + if (!path.isAbsolute(cwd)) return null; + const gitNexusDir = findGitNexusDir(cwd); + if (!gitNexusDir) return null; + + const toolName = input.tool_name || ''; + const toolInput = input.tool_input || {}; + const toolResponse = input.tool_response || {}; + const parts = []; + + if (toolSucceeded(toolResponse)) { + const pattern = extractPattern(toolName, toolInput); + if (pattern) { + const augmentText = runAugment(gitNexusDir, cwd, pattern); + if (augmentText) parts.push(augmentText); + } + } + + if (toolName === 'run_shell_command' && toolSucceeded(toolResponse)) { + const command = toolInput.command || ''; + if (/\bgit\s+(commit|merge|rebase|cherry-pick|pull)(\s|$)/.test(command)) { + const hint = buildStaleIndexHint(gitNexusDir, cwd); + if (hint) { + process.stderr.write(`${hint}\n`); + parts.push(hint); + } + } + } + + return parts.length > 0 ? parts.join('\n\n') : null; +} + +function runAugment(gitNexusDir, cwd, pattern) { + if (hasGitNexusServerOwner(gitNexusDir)) { + process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n'); + return ''; + } + const release = acquireHookSlot(gitNexusDir); + if (!release) return ''; + const cliPath = resolveCliPath(); + try { + const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000); + if (!child.error && child.status === 0) { + return extractAugmentContext(child.stderr || ''); + } + } catch { + /* graceful failure */ + } finally { + release(); + } + return ''; +} + +function buildStaleIndexHint(gitNexusDir, cwd) { + let currentHead = ''; + try { + const headResult = spawnSync('git', ['rev-parse', 'HEAD'], { + encoding: 'utf-8', + timeout: 3000, + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + currentHead = (headResult.stdout || '').trim(); + } catch { + return ''; + } + if (!currentHead) return ''; + + let lastCommit = ''; + let hadEmbeddings = false; + try { + const meta = JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'meta.json'), 'utf-8')); + lastCommit = meta.lastCommit || ''; + hadEmbeddings = meta.stats && meta.stats.embeddings > 0; + } catch { + /* no meta — treat as stale */ + } + + if (currentHead === lastCommit) return ''; + + const analyzeCmd = `npx gitnexus analyze${hadEmbeddings ? ' --embeddings' : ''}`; + return ( + `[GitNexus] index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` + + `Run \`${analyzeCmd}\` to refresh the knowledge graph.` + ); +} + +function handleAfterTool(input) { + const context = buildAfterToolContext(input); + if (context) writeAdditionalContext(context); +} + +const handlers = { + AfterTool: handleAfterTool, +}; + +function main() { + try { + const input = readInput(); + const handler = handlers[input.hook_event_name || '']; + if (handler) handler(input); + } catch (err) { + if (process.env.GITNEXUS_DEBUG) { + console.error('GitNexus antigravity hook error:', (err.message || '').slice(0, 200)); + } + } +} + +main(); diff --git a/gitnexus/scripts/build.js b/gitnexus/scripts/build.js index e651708e3..044fc36ee 100644 --- a/gitnexus/scripts/build.js +++ b/gitnexus/scripts/build.js @@ -35,6 +35,22 @@ function getBuildTimeoutMs() { const BUILD_TIMEOUT_MS = getBuildTimeoutMs(); +// Published-package guard: when installed from the npm registry the +// monorepo sibling `gitnexus-shared` does not exist and `dist/` is +// already pre-built. Skip the build to avoid a misleading ENOENT +// crash (#1795). +if (!fs.existsSync(SHARED_ROOT)) { + if (fs.existsSync(DIST)) { + console.log('[build] skipping — dist/ already present (published package).'); + process.exit(0); + } + console.error( + `[build] gitnexus-shared not found at ${SHARED_ROOT} and no dist/ exists.\n` + + 'Are you running from the monorepo checkout? Run `npm install` from the repo root first.', + ); + process.exit(1); +} + // ── 1. Build gitnexus-shared ─────────────────────────────────────── console.log('[build] compiling gitnexus-shared…'); const tscCmd = diff --git a/gitnexus/scripts/cross-platform-tests.ts b/gitnexus/scripts/cross-platform-tests.ts index 122ce064b..88afdd75d 100644 --- a/gitnexus/scripts/cross-platform-tests.ts +++ b/gitnexus/scripts/cross-platform-tests.ts @@ -29,6 +29,7 @@ const PLATFORM_LOGIC = [ 'test/unit/setup.test.ts', 'test/unit/setup-jsonc.test.ts', 'test/unit/setup-codex.test.ts', + 'test/unit/setup-antigravity.test.ts', 'test/unit/platform-capabilities.test.ts', 'test/unit/worker-pool-windows-quarantine.test.ts', 'test/unit/lbug-pool-win-fts-probe.test.ts', @@ -79,6 +80,9 @@ const SPAWN_CLI = [ 'test/integration/group/group-cli.test.ts', 'test/integration/cli/tool-no-index-stderr.test.ts', 'test/integration/setup-skills.test.ts', + 'test/integration/setup-antigravity.test.ts', + 'test/integration/antigravity-hook-e2e.test.ts', + 'test/unit/local-cli-subprocess.test.ts', ]; // Worker threads tests — exercise real worker_threads which have diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index 5649bec30..e808f244b 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -185,7 +185,8 @@ export const en = { 'help.option.clean.all': 'Clean all indexed repos', 'help.option.clean.lbugSidecars': 'Clean quarantined LadybugDB missing-shadow WAL sidecars', 'help.option.wiki.force': 'Force full regeneration even if up to date', - 'help.option.wiki.provider': 'LLM provider: openai or cursor (default: openai)', + 'help.option.wiki.provider': + 'LLM provider: openai, openrouter, azure, custom, cursor, claude, or codex (default: openai)', 'help.option.wiki.model': 'LLM model or Azure deployment name (default: minimax/minimax-m2.5)', 'help.option.wiki.baseUrl': 'LLM API base URL. Azure v1: https://{resource}.openai.azure.com/openai/v1', diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index 639c36a81..9e896f33e 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -174,7 +174,8 @@ export const zhCN = { 'help.option.clean.all': '清理所有已索引仓库', 'help.option.clean.lbugSidecars': '清理已隔离的 LadybugDB missing-shadow WAL sidecar', 'help.option.wiki.force': '即使已是最新也强制完整重新生成', - 'help.option.wiki.provider': 'LLM 提供商:openai 或 cursor(默认:openai)', + 'help.option.wiki.provider': + 'LLM 提供商:openai、openrouter、azure、custom、cursor、claude 或 codex(默认:openai)', 'help.option.wiki.model': 'LLM 模型或 Azure deployment 名称(默认:minimax/minimax-m2.5)', 'help.option.wiki.baseUrl': 'LLM API base URL。Azure v1:https://{resource}.openai.azure.com/openai/v1', diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 6d619a62c..a5347effd 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -18,7 +18,9 @@ program.name('gitnexus').description('GitNexus local CLI and MCP server').versio program .command('setup') - .description('One-time setup: configure MCP for Cursor, Claude Code, OpenCode, Codex') + .description( + 'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, Codex', + ) .action(createLazyAction(() => import('./setup.js'), 'setupCommand')); program @@ -146,7 +148,10 @@ program .command('wiki [path]') .description('Generate repository wiki from knowledge graph') .option('-f, --force', 'Force full regeneration even if up to date') - .option('--provider ', 'LLM provider: openai or cursor (default: openai)') + .option( + '--provider ', + 'LLM provider: openai, openrouter, azure, custom, cursor, claude, or codex (default: openai)', + ) .option('--model ', 'LLM model or Azure deployment name (default: minimax/minimax-m2.5)') .option( '--base-url ', diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index 915c19dec..9b6c0944a 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -13,7 +13,6 @@ import { execFile, execFileSync } from 'child_process'; import { createRequire } from 'module'; import { promisify } from 'util'; import { fileURLToPath } from 'url'; -import { glob } from 'glob'; import { parseTree, modify, applyEdits, ParseError, parse as parseJsonc } from 'jsonc-parser'; import { getGlobalDir } from '../storage/repo-manager.js'; @@ -254,14 +253,18 @@ async function installClaudeCodeSkills(result: SetupResult): Promise { /** * Check whether an event array already contains a gitnexus-hook entry. */ -function hasGitnexusHook(hooksObj: any, eventName: string): boolean { +function hasGitnexusHook( + hooksObj: any, + eventName: string, + commandFragment = 'gitnexus-hook', +): boolean { const entries = hooksObj?.[eventName]; if (!Array.isArray(entries)) return false; return entries.some( (h: any) => Array.isArray(h.hooks) && h.hooks.some( - (hh: any) => typeof hh.command === 'string' && hh.command.includes('gitnexus-hook'), + (hh: any) => typeof hh.command === 'string' && hh.command.includes(commandFragment), ), ); } @@ -468,6 +471,192 @@ async function installClaudeCodeHooks(result: SetupResult): Promise { } } +// ─── Antigravity (Google) ────────────────────────────────────────── +// +// Antigravity stores its MCP config under ~/.gemini/antigravity/mcp_config.json +// and inherits Gemini CLI's hooks contract +// (https://geminicli.com/docs/hooks/reference/), which lives at +// ~/.gemini/settings.json under the canonical `hooks.` array layout. +// +// We register a single AfterTool entry matching Gemini's built-in search/shell +// tools (search_file_content|glob|run_shell_command). BeforeTool is not used: +// the Gemini contract provides no documented context-injection channel for it, +// so augmentation runs in AfterTool where `hookSpecificOutput.additionalContext` +// is appended to the tool result the agent reads. See the antigravity hook +// adapter for the stdin/stdout contract details. + +async function setupAntigravity(result: SetupResult): Promise { + const antigravityDir = path.join(os.homedir(), '.gemini', 'antigravity'); + if (!(await dirExists(antigravityDir))) { + result.skipped.push('Antigravity (not installed)'); + return; + } + + const mcpPath = path.join(antigravityDir, 'mcp_config.json'); + try { + const ok = await mergeJsoncFile(mcpPath, ['mcpServers', 'gitnexus'], getMcpEntry()); + if (ok) { + result.configured.push('Antigravity'); + } else { + result.errors.push( + 'Antigravity: mcp_config.json is corrupt — skipping to preserve existing content', + ); + } + } catch (err: any) { + result.errors.push(`Antigravity: ${err.message}`); + } +} + +/** + * Install GitNexus skills to ~/.gemini/antigravity/skills/ (global scope, + * per https://codelabs.developers.google.com/getting-started-with-antigravity-skills). + * Each skill is laid out as {skillName}/SKILL.md just like the other editors. + */ +async function installAntigravitySkills(result: SetupResult): Promise { + const antigravityDir = path.join(os.homedir(), '.gemini', 'antigravity'); + if (!(await dirExists(antigravityDir))) return; + + const skillsDir = path.join(antigravityDir, 'skills'); + try { + const installed = await installSkillsTo(skillsDir); + if (installed.length > 0) { + result.configured.push( + `Antigravity skills (${installed.length} skills → ~/.gemini/antigravity/skills/)`, + ); + } + } catch (err: any) { + result.errors.push(`Antigravity skills: ${err.message}`); + } +} + +/** + * Install the Antigravity/Gemini-CLI hook adapter to + * ~/.gemini/config/hooks/gitnexus/ and register an AfterTool entry in + * ~/.gemini/settings.json under `hooks.AfterTool`. + * + * Why AfterTool (and not BeforeTool): the Gemini hooks reference + * (https://geminicli.com/docs/hooks/reference/) does not provide a context- + * injection channel for BeforeTool. AfterTool's + * `hookSpecificOutput.additionalContext` is the only documented way to + * append text the agent will read. + */ +async function installAntigravityHooks(result: SetupResult): Promise { + const antigravityDir = path.join(os.homedir(), '.gemini', 'antigravity'); + if (!(await dirExists(antigravityDir))) return; + + const geminiDir = path.join(os.homedir(), '.gemini'); + const settingsPath = path.join(geminiDir, 'settings.json'); + const destHooksDir = path.join(geminiDir, 'config', 'hooks', 'gitnexus'); + + // The antigravity adapter shares its lock/probe helpers with the claude + // adapter — same DB, same concurrency rules — so we reuse those CJS files + // from gitnexus/hooks/claude/ rather than duplicating them. + const pluginAntigravityDir = path.join(__dirname, '..', '..', 'hooks', 'antigravity'); + const pluginClaudeDir = path.join(__dirname, '..', '..', 'hooks', 'claude'); + + try { + await fs.mkdir(destHooksDir, { recursive: true }); + + // Adapter script: rewrite the dist path baked into the file so it resolves + // to the installed gitnexus CLI rather than the cwd-relative dev path. + const adapterSrc = path.join(pluginAntigravityDir, 'gitnexus-antigravity-hook.cjs'); + const adapterDest = path.join(destHooksDir, 'gitnexus-antigravity-hook.cjs'); + try { + let content = await fs.readFile(adapterSrc, 'utf-8'); + const resolvedCli = path.join(__dirname, '..', 'cli', 'index.js'); + const normalizedCli = path.resolve(resolvedCli).replace(/\\/g, '/'); + const jsonCli = JSON.stringify(normalizedCli); + content = content.replace( + "let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');", + `let cliPath = ${jsonCli};`, + ); + await fs.writeFile(adapterDest, content, 'utf-8'); + } catch { + // Adapter not found in source — skip + } + + // Bail out if the adapter was not written — registering the hook entry + // without the script would crash on every tool invocation (top-level + // require() of sibling helpers fails with MODULE_NOT_FOUND). + try { + await fs.access(adapterDest); + } catch { + result.errors.push( + 'Antigravity hooks: adapter script was not installed — skipping hook registration', + ); + return; + } + + // Shared helpers (copied from hooks/claude/). win-rm-list-json.ps1 is + // required by hook-db-lock-probe.cjs on Windows — without it, the MCP + // server ownership probe silently fails open and the hook may contend + // with the MCP server on the LadybugDB. + for (const helper of ['hook-lock.cjs', 'hook-db-lock-probe.cjs', 'win-rm-list-json.ps1']) { + try { + await fs.copyFile(path.join(pluginClaudeDir, helper), path.join(destHooksDir, helper)); + } catch { + result.errors.push( + `Antigravity hooks: failed to copy ${helper} — hook may crash at runtime`, + ); + } + } + + const hookPath = path.join(destHooksDir, 'gitnexus-antigravity-hook.cjs').replace(/\\/g, '/'); + const escapedHookPath = hookPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + const hookCmd = `node "${escapedHookPath}"`; + + const parsed = await (async () => { + try { + const r = await fs.readFile(settingsPath, 'utf-8'); + return parseJsonc(r); + } catch { + return null; + } + })(); + + const hookEntries: Array<{ eventName: string; value: unknown }> = []; + + if (!hasGitnexusHook(parsed?.hooks, 'AfterTool', 'gitnexus-antigravity-hook')) { + // Matcher follows the Gemini CLI built-in tool naming (snake_case). + // search_file_content / glob cover content + filename search; run_shell_command + // catches rg/grep invocations and the git commit family for stale-index hints. + hookEntries.push({ + eventName: 'AfterTool', + value: { + matcher: 'search_file_content|glob|run_shell_command', + hooks: [ + { + type: 'command', + command: hookCmd, + name: 'gitnexus', + // ms — Gemini CLI uses milliseconds (default 60000); Claude Code + // uses seconds. 10000 ms = 10 s. + timeout: 10000, + description: 'GitNexus graph context + stale-index hints', + }, + ], + }, + }); + } + + if (hookEntries.length === 0) { + result.configured.push('Antigravity hooks (already configured)'); + return; + } + + const ok = await mergeHooksJsonc(settingsPath, hookEntries); + if (ok) { + result.configured.push('Antigravity hooks (AfterTool)'); + } else { + result.errors.push( + 'Antigravity hooks: settings.json is corrupt — skipping to preserve existing content', + ); + } + } catch (err: any) { + result.errors.push(`Antigravity hooks: ${err.message}`); + } +} + async function setupOpenCode(result: SetupResult): Promise { const opencodeDir = path.join(os.homedir(), '.config', 'opencode'); if (!(await dirExists(opencodeDir))) { @@ -563,15 +752,33 @@ async function setupCodex(result: SetupResult): Promise { */ async function installSkillsTo(targetDir: string): Promise { const installed: string[] = []; - const skillsRoot = path.join(__dirname, '..', '..', 'skills'); + // GITNEXUS_TEST_SKILLS_ROOT lets tests stage a fixture skills tree without + // depending on __dirname resolution under Vitest. + const skillsRoot = + process.env.GITNEXUS_TEST_SKILLS_ROOT ?? path.join(__dirname, '..', '..', 'skills'); + // Was glob('*.md') + glob('*/SKILL.md'); replaced with fs.readdir because + // glob v13's cwd handling did not match the fixture path on Windows runners + // (absolute temp paths containing the 8.3 short-name `RUNNER~1` returned + // zero matches). fs.readdir has no such path quirks. let flatFiles: string[] = []; let dirSkillFiles: string[] = []; try { - [flatFiles, dirSkillFiles] = await Promise.all([ - glob('*.md', { cwd: skillsRoot }), - glob('*/SKILL.md', { cwd: skillsRoot }), - ]); + const entries = await fs.readdir(skillsRoot, { withFileTypes: true }); + flatFiles = entries.filter((e) => e.isFile() && e.name.endsWith('.md')).map((e) => e.name); + const subdirSkillFiles = await Promise.all( + entries + .filter((e) => e.isDirectory()) + .map(async (e) => { + try { + await fs.access(path.join(skillsRoot, e.name, 'SKILL.md')); + return path.join(e.name, 'SKILL.md'); + } catch { + return null; + } + }), + ); + dirSkillFiles = subdirSkillFiles.filter((p): p is string => p !== null); } catch { return []; } @@ -705,12 +912,15 @@ export const setupCommand = async () => { // Detect and configure each editor's MCP await setupCursor(result); await setupClaudeCode(result); + await setupAntigravity(result); await setupOpenCode(result); await setupCodex(result); // Install global skills for platforms that support them await installClaudeCodeSkills(result); await installClaudeCodeHooks(result); + await installAntigravitySkills(result); + await installAntigravityHooks(result); await installCursorSkills(result); await installOpenCodeSkills(result); await installCodexSkills(result); diff --git a/gitnexus/src/cli/wiki.ts b/gitnexus/src/cli/wiki.ts index 97ac8fc6e..d10d10875 100644 --- a/gitnexus/src/cli/wiki.ts +++ b/gitnexus/src/cli/wiki.ts @@ -19,6 +19,7 @@ import { import { WikiGenerator, type WikiOptions } from '../core/wiki/generator.js'; import { resolveLLMConfig, type LLMProvider } from '../core/wiki/llm-client.js'; import { detectCursorCLI } from '../core/wiki/cursor-client.js'; +import { detectLocalCLI } from '../core/wiki/local-cli-client.js'; import { logger } from '../core/logger.js'; export interface WikiCommandOptions { @@ -55,6 +56,18 @@ function parsePositiveIntegerOption( return parsed; } +function isLocalProvider( + provider: LLMProvider | undefined, +): provider is 'cursor' | 'claude' | 'codex' { + return provider === 'cursor' || provider === 'claude' || provider === 'codex'; +} + +function localModelConfigKey(provider: 'cursor' | 'claude' | 'codex') { + if (provider === 'cursor') return 'cursorModel'; + if (provider === 'claude') return 'claudeModel'; + return 'codexModel'; +} + /** * Prompt the user for input via stdin. */ @@ -191,10 +204,11 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) if (options.provider) updates.provider = options.provider; if (options.apiVersion) updates.apiVersion = options.apiVersion; if (options.reasoningModel !== undefined) updates.isReasoningModel = options.reasoningModel; - // Save model to appropriate field based on provider + // Save model to appropriate field based on provider. if (options.model) { - if (options.provider === 'cursor') { - updates.cursorModel = options.model; + const targetProvider = options.provider ?? existing.provider; + if (isLocalProvider(targetProvider)) { + updates[localModelConfigKey(targetProvider)] = options.model; } else { updates.model = options.model; } @@ -205,7 +219,7 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) const savedConfig = await loadCLIConfig(); const hasSavedConfig = !!( - savedConfig.provider === 'cursor' || + isLocalProvider(savedConfig.provider) || (savedConfig.apiKey && savedConfig.baseUrl) ); const hasCLIOverrides = !!( @@ -231,10 +245,10 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) if (!hasSavedConfig && !hasCLIOverrides) { if (!process.stdin.isTTY) { // Non-interactive mode — need either API key or Cursor CLI - if (!llmConfig.apiKey && llmConfig.provider !== 'cursor') { + if (!llmConfig.apiKey && !isLocalProvider(llmConfig.provider)) { console.log(' Error: No LLM API key found.'); console.log(' Set OPENAI_API_KEY or GITNEXUS_API_KEY environment variable,'); - console.log(' or pass --api-key , or use --provider cursor.\n'); + console.log(' or pass --api-key , or use --provider cursor|claude|codex.\n'); process.exitCode = 1; return; } @@ -242,23 +256,51 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) } else { console.log(" No LLM configured. Let's set it up.\n"); console.log( - ' Supports OpenAI, OpenRouter, Azure, any OpenAI-compatible API, or Cursor CLI.\n', + ' Supports OpenAI, OpenRouter, Azure, any OpenAI-compatible API, Cursor CLI, Claude CLI, or Codex CLI.\n', ); - // Check if Cursor CLI is available + // Check if local agent CLIs are available. const hasCursor = detectCursorCLI(); + const hasClaude = detectLocalCLI('claude'); + const hasCodex = detectLocalCLI('codex'); + const localChoices: Array<{ + choice: string; + provider: 'cursor' | 'claude' | 'codex'; + }> = []; // Provider selection console.log(' [1] OpenAI (api.openai.com)'); console.log(' [2] OpenRouter (openrouter.ai)'); console.log(' [3] Azure OpenAI'); console.log(' [4] Custom endpoint'); + let nextChoice = 5; if (hasCursor) { - console.log(' [5] Cursor CLI (local, uses your Cursor subscription)'); + const choice = String(nextChoice++); + localChoices.push({ + choice, + provider: 'cursor', + }); + console.log(` [${choice}] Cursor CLI (local, uses your Cursor subscription)`); + } + if (hasClaude) { + const choice = String(nextChoice++); + localChoices.push({ + choice, + provider: 'claude', + }); + console.log(` [${choice}] Claude CLI (local, uses your Claude Code login)`); + } + if (hasCodex) { + const choice = String(nextChoice++); + localChoices.push({ + choice, + provider: 'codex', + }); + console.log(` [${choice}] Codex CLI (local, uses your Codex login)`); } console.log(''); - const maxChoice = hasCursor ? '5' : '4'; + const maxChoice = String(nextChoice - 1); const choice = await prompt(` Select provider (1/${maxChoice}): `); let baseUrl: string; @@ -266,21 +308,21 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) let provider: LLMProvider = 'openai'; let key = ''; - if (choice === '5' && hasCursor) { - // Cursor CLI selected - model defaults to 'auto' (Cursor's default) - provider = 'cursor'; + const selectedLocal = localChoices.find((item) => item.choice === choice); + if (selectedLocal) { + // Local CLI selected - model defaults to the CLI's configured default. + provider = selectedLocal.provider; baseUrl = ''; - const modelInput = await prompt(' Model (leave empty for auto): '); + const modelInput = await prompt(' Model (leave empty for CLI default): '); const model = modelInput || ''; - // Save config for Cursor - const cursorConfig: Record = { provider: 'cursor' }; - if (model) cursorConfig.cursorModel = model; - await saveCLIConfig(cursorConfig); + const localConfig = { ...savedConfig, provider }; + if (model) (localConfig as Record)[localModelConfigKey(provider)] = model; + await saveCLIConfig(localConfig); console.log(' Config saved to ~/.gitnexus/config.json\n'); - llmConfig = { ...llmConfig, provider: 'cursor', model, apiKey: '', baseUrl: '' }; + llmConfig = { ...llmConfig, provider, model, apiKey: '', baseUrl: '' }; } else if (choice === '3') { // Azure OpenAI guided setup — minimal prompts console.log('\n Azure OpenAI setup.\n'); @@ -328,6 +370,7 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) const azureBaseUrl = `${endpoint}/openai/v1`; await saveCLIConfig({ + ...savedConfig, apiKey: azureKey, baseUrl: azureBaseUrl, model: deploymentName, diff --git a/gitnexus/src/core/ingestion/languages/cpp/two-phase-lookup.ts b/gitnexus/src/core/ingestion/languages/cpp/two-phase-lookup.ts index 8d0050eb1..ec95d7701 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/two-phase-lookup.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/two-phase-lookup.ts @@ -161,12 +161,33 @@ export function populateCppDependentBases(parsedFiles: readonly ParsedFile[]): v // Multiple classes share the same simple name — prefer the one // whose namespace matches the deriving class's namespace. - // V1: exact dot-prefix match only. Cross-namespace inheritance - // (e.g., `ns::outer::Derived` extending bare `Inner` defined in - // `ns::outer::inner`) and inline-namespace cases are deferred to - // V2; the conservative skip-on-ambiguity below avoids false - // associations in those edge cases. - const nsMatch = candidates.find((c) => c.nsPrefix === classEntry.nsPrefix); + // V2: filter by prefix-match capped at one level deeper, then + // accept only if exactly one candidate survives. This lets + // Derived in ns::outer find Inner in ns::outer::inner + // (or ns::v1 for inline-namespace variants) while rejecting + // sibling collisions (e.g. detail::Inner vs public_api::Inner). + // + // The one-segment cap limits walk depth: ns → ns.a ✓, ns → ns.a.b ✗. + // Global-scope deriving classes match any single-segment namespace. + // + // LIMITATION: True ISO behavior would use the base specifier's + // syntactic qualifier (available at captures.ts:611 as + // qualified_identifier scope) to navigate from the current scope, + // which would resolve `detail::Inner` vs `public_api::Inner` + // unambiguously. Threading the qualifier is tracked in #1815. + // Until then, sibling collisions correctly suppress. + const nsMatches = candidates.filter((c) => { + if (c.nsPrefix === classEntry.nsPrefix) return true; + if (classEntry.nsPrefix === '') { + return c.nsPrefix !== '' && !c.nsPrefix.includes('.'); + } + if (c.nsPrefix.startsWith(classEntry.nsPrefix + '.')) { + const suffix = c.nsPrefix.slice(classEntry.nsPrefix.length + 1); + return !suffix.includes('.'); + } + return false; + }); + const nsMatch = nsMatches.length === 1 ? nsMatches[0] : undefined; if (nsMatch !== undefined) { bases.add(nsMatch.nodeId); } diff --git a/gitnexus/src/core/ingestion/languages/rust.ts b/gitnexus/src/core/ingestion/languages/rust.ts index e7638e4ea..be4be7cba 100644 --- a/gitnexus/src/core/ingestion/languages/rust.ts +++ b/gitnexus/src/core/ingestion/languages/rust.ts @@ -32,6 +32,15 @@ import { rustVariableConfig } from '../variable-extractors/configs/rust.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { rustCallConfig } from '../call-extractors/configs/rust.js'; import { createHeritageExtractor } from '../heritage-extractors/generic.js'; +import { + emitRustScopeCaptures, + rustArityCompatibility, + rustBindingScopeFor, + rustImportOwningScope, + rustReceiverBinding, + interpretRustImport, + interpretRustTypeBinding, +} from './rust/index.js'; /** Rust impl_item: find the function_item child and extract its name as a Method. */ const rustExtractFunctionName = ( @@ -173,4 +182,12 @@ export const rustProvider = defineLanguage({ classExtractor: createClassExtractor(rustClassConfig), heritageExtractor: createHeritageExtractor(SupportedLanguages.Rust), builtInNames: BUILT_INS, + // ── RFC #909 Ring 3: scope-based resolution hooks ────────── + emitScopeCaptures: emitRustScopeCaptures, + interpretImport: interpretRustImport, + interpretTypeBinding: interpretRustTypeBinding, + bindingScopeFor: rustBindingScopeFor, + importOwningScope: rustImportOwningScope, + receiverBinding: rustReceiverBinding, + arityCompatibility: rustArityCompatibility, }); diff --git a/gitnexus/src/core/ingestion/languages/rust/arity.ts b/gitnexus/src/core/ingestion/languages/rust/arity.ts new file mode 100644 index 000000000..8b082c482 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/arity.ts @@ -0,0 +1,15 @@ +import type { Callsite, SymbolDefinition } from 'gitnexus-shared'; + +export function rustArityCompatibility( + def: SymbolDefinition, + callsite: Callsite, +): 'compatible' | 'unknown' | 'incompatible' { + const max = def.parameterCount; + const min = def.requiredParameterCount; + if (max === undefined && min === undefined) return 'unknown'; + if (!Number.isFinite(callsite.arity) || callsite.arity < 0) return 'unknown'; + + if (min !== undefined && callsite.arity < min) return 'incompatible'; + if (max !== undefined && callsite.arity > max) return 'incompatible'; + return 'compatible'; +} diff --git a/gitnexus/src/core/ingestion/languages/rust/cache-stats.ts b/gitnexus/src/core/ingestion/languages/rust/cache-stats.ts new file mode 100644 index 000000000..8ba83ae89 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/cache-stats.ts @@ -0,0 +1,18 @@ +let hits = 0; +let misses = 0; + +export function recordRustCacheHit(): void { + hits++; +} +export function recordRustCacheMiss(): void { + misses++; +} + +export function getRustCaptureCacheStats(): { readonly hits: number; readonly misses: number } { + return { hits, misses }; +} + +export function resetRustCaptureCacheStats(): void { + hits = 0; + misses = 0; +} diff --git a/gitnexus/src/core/ingestion/languages/rust/captures.ts b/gitnexus/src/core/ingestion/languages/rust/captures.ts new file mode 100644 index 000000000..56ce009d7 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/captures.ts @@ -0,0 +1,223 @@ +import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { + findNodeAtRange, + nodeToCapture, + syntheticCapture, + type SyntaxNode, +} from '../../utils/ast-helpers.js'; +import { getRustParser, getRustScopeQuery } from './query.js'; +import { recordRustCacheHit, recordRustCacheMiss } from './cache-stats.js'; +import { splitRustUseDeclaration } from './import-decomposer.js'; +import { synthesizeRustReceiverBinding } from './receiver-binding.js'; +import { getTreeSitterBufferSize } from '../../constants.js'; +import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; + +export function emitRustScopeCaptures( + sourceText: string, + _filePath: string, + cachedTree?: unknown, +): readonly CaptureMatch[] { + let tree = cachedTree as ReturnType['parse']> | undefined; + if (tree === undefined) { + tree = parseSourceSafe(getRustParser(), sourceText, undefined, { + bufferSize: getTreeSitterBufferSize(sourceText), + }); + recordRustCacheMiss(); + } else { + recordRustCacheHit(); + } + + const rawMatches = getRustScopeQuery().matches(tree.rootNode); + const out: CaptureMatch[] = []; + + for (const m of rawMatches) { + const grouped: Record = {}; + for (const c of m.captures) { + const tag = '@' + c.name; + if (tag.startsWith('@_')) continue; + grouped[tag] = nodeToCapture(tag, c.node); + } + if (Object.keys(grouped).length === 0) continue; + + // Decompose use declarations into individual import captures + if (grouped['@import.statement'] !== undefined) { + const anchor = grouped['@import.statement']!; + const useNode = findNodeAtRange(tree.rootNode, anchor.range, 'use_declaration'); + if (useNode !== null) { + out.push(...splitRustUseDeclaration(useNode)); + continue; + } + } + + // Synthesize self receiver bindings for methods inside impl blocks + let cachedImplLookup: { fnNode: SyntaxNode; implNode: SyntaxNode | null } | undefined; + if (grouped['@scope.function'] !== undefined) { + const scopeCap = grouped['@scope.function']!; + const fnNode = findNodeAtRange(tree.rootNode, scopeCap.range, 'function_item'); + if (fnNode !== null) { + const implNode = findEnclosingImpl(fnNode); + cachedImplLookup = { fnNode, implNode }; + const receiver = synthesizeRustReceiverBinding(fnNode, implNode); + if (receiver !== null) out.push(receiver); + } + } + + // Attach declaration arity for functions/methods + const declAnchor = grouped['@declaration.function']; + if (declAnchor !== undefined) { + const fnNode = findNodeAtRange(tree.rootNode, declAnchor.range, 'function_item'); + if (fnNode !== null) { + const implNode = + cachedImplLookup?.fnNode === fnNode + ? cachedImplLookup.implNode + : findEnclosingImpl(fnNode); + const traitNode = implNode === null ? findEnclosingTrait(fnNode) : null; + // Reclassify as method if inside an impl block or trait definition + if (implNode !== null || traitNode !== null) { + const nameCap = grouped['@declaration.name']; + delete (grouped as Record)['@declaration.function']; + grouped['@declaration.method'] = syntheticCapture( + '@declaration.method', + fnNode, + fnNode.text, + ); + if (nameCap !== undefined) { + grouped['@declaration.name'] = nameCap; + } + } + + const arity = computeRustDeclarationArity(fnNode); + if (arity.parameterCount !== undefined) { + grouped['@declaration.parameter-count'] = syntheticCapture( + '@declaration.parameter-count', + fnNode, + String(arity.parameterCount), + ); + } + if (arity.requiredParameterCount !== undefined) { + grouped['@declaration.required-parameter-count'] = syntheticCapture( + '@declaration.required-parameter-count', + fnNode, + String(arity.requiredParameterCount), + ); + } + } + } + + // Hoist return-type bindings from impl block functions to module level. + // The auto-hoist in the scope-extractor places a type binding whose + // anchor matches its innermost scope on the parent scope. By using the + // impl_item node as the anchor (which matches the impl's Class scope), + // the binding lands on the Module scope — making it visible to the + // compound receiver's hoistTypeBindingsToModule walk. + if ( + grouped['@type-binding.return'] !== undefined && + grouped['@type-binding.name'] !== undefined + ) { + const tbReturnAnchor = grouped['@type-binding.return']!; + const fnNode = findNodeAtRange(tree.rootNode, tbReturnAnchor.range, 'function_item'); + if (fnNode !== null) { + const implNode = findEnclosingImpl(fnNode); + if (implNode !== null) { + out.push({ + '@type-binding.name': syntheticCapture( + '@type-binding.name', + implNode, + grouped['@type-binding.name']!.text, + ), + '@type-binding.type': syntheticCapture( + '@type-binding.type', + implNode, + grouped['@type-binding.type']!.text, + ), + '@type-binding.return': syntheticCapture( + '@type-binding.return', + implNode, + tbReturnAnchor.text, + ), + }); + } + } + } + + // Attach call arity for call expressions + const callAnchor = + grouped['@reference.call.free'] ?? + grouped['@reference.call.member'] ?? + grouped['@reference.call.constructor']; + if (callAnchor !== undefined) { + const callNode = + findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression') ?? + findNodeAtRange(tree.rootNode, callAnchor.range, 'struct_expression'); + if (callNode !== null) { + const arity = computeRustCallArity(callNode); + grouped['@reference.arity'] = syntheticCapture('@reference.arity', callNode, String(arity)); + } + } + + out.push(grouped); + } + + return out; +} + +function findEnclosingImpl(node: SyntaxNode): SyntaxNode | null { + let current: SyntaxNode | null = node.parent; + while (current !== null) { + if (current.type === 'impl_item') return current; + if (current.type === 'source_file' || current.type === 'mod_item') return null; + current = current.parent; + } + return null; +} + +function findEnclosingTrait(node: SyntaxNode): SyntaxNode | null { + let current: SyntaxNode | null = node.parent; + while (current !== null) { + if (current.type === 'trait_item') return current; + if (current.type === 'source_file' || current.type === 'mod_item') return null; + current = current.parent; + } + return null; +} + +function computeRustDeclarationArity(fnNode: SyntaxNode): { + parameterCount?: number; + requiredParameterCount?: number; +} { + const params = fnNode.childForFieldName('parameters'); + if (params === null) return {}; + + let count = 0; + for (let i = 0; i < params.namedChildCount; i++) { + const child = params.namedChild(i); + if (child === null) continue; + if (child.type === 'self_parameter') continue; + if (child.type === 'parameter') count++; + } + // Rust has no default parameters or overloading + return { parameterCount: count, requiredParameterCount: count }; +} + +function computeRustCallArity(callNode: SyntaxNode): number { + if (callNode.type === 'struct_expression') { + const body = callNode.childForFieldName('body'); + if (body === null) return 0; + let count = 0; + for (let i = 0; i < body.namedChildCount; i++) { + const t = body.namedChild(i)?.type; + if (t === 'field_initializer' || t === 'shorthand_field_initializer') count++; + } + return count; + } + + const args = callNode.childForFieldName('arguments'); + if (args === null) return 0; + + let count = 0; + for (let i = 0; i < args.namedChildCount; i++) { + const child = args.namedChild(i); + if (child !== null) count++; + } + return count; +} diff --git a/gitnexus/src/core/ingestion/languages/rust/import-decomposer.ts b/gitnexus/src/core/ingestion/languages/rust/import-decomposer.ts new file mode 100644 index 000000000..c209d570b --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/import-decomposer.ts @@ -0,0 +1,207 @@ +import type { CaptureMatch } from 'gitnexus-shared'; +import { syntheticCapture } from '../../utils/ast-helpers.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +/** + * Decompose a Rust `use_declaration` into individual import captures. + * Handles simple paths, grouped imports ({A, B}), wildcards (*), + * renames (as), and `pub use` re-exports. + */ +export function splitRustUseDeclaration(node: SyntaxNode): CaptureMatch[] { + if (node.type !== 'use_declaration') return []; + + const isReexport = hasVisibilityModifier(node); + const argument = getUseArgument(node); + if (argument === null) return []; + + return decomposeUseArgument(argument, '', isReexport, node); +} + +function hasVisibilityModifier(node: SyntaxNode): boolean { + for (let i = 0; i < node.childCount; i++) { + if (node.child(i)?.type === 'visibility_modifier') return true; + } + return false; +} + +function getUseArgument(node: SyntaxNode): SyntaxNode | null { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child === null) continue; + if ( + child.type === 'scoped_identifier' || + child.type === 'scoped_use_list' || + child.type === 'use_wildcard' || + child.type === 'use_as_clause' || + child.type === 'identifier' || + child.type === 'use_list' + ) { + return child; + } + } + return null; +} + +function decomposeUseArgument( + node: SyntaxNode, + prefixPath: string, + isReexport: boolean, + anchor: SyntaxNode, +): CaptureMatch[] { + switch (node.type) { + case 'scoped_identifier': { + const path = buildScopedPath(node); + const segments = path.split('::'); + const name = segments[segments.length - 1]; + return [ + makeImportCapture( + anchor, + isReexport ? 'reexport' : 'named', + joinPaths(prefixPath, path), + name, + undefined, + ), + ]; + } + + case 'scoped_use_list': { + const pathNode = node.childForFieldName('path'); + const listNode = node.childForFieldName('list'); + const pathStr = pathNode ? buildNodePath(pathNode) : ''; + const fullPrefix = joinPaths(prefixPath, pathStr); + if (listNode === null) return []; + return decomposeUseList(listNode, fullPrefix, isReexport, anchor); + } + + case 'use_list': { + return decomposeUseList(node, prefixPath, isReexport, anchor); + } + + case 'use_wildcard': { + const wcPath = buildWildcardPath(node); + return [makeImportCapture(anchor, 'wildcard', joinPaths(prefixPath, wcPath), '*', undefined)]; + } + + case 'use_as_clause': { + const pathChild = node.childForFieldName('path'); + const aliasChild = node.childForFieldName('alias'); + if (pathChild === null || aliasChild === null) return []; + const originalName = + pathChild.type === 'scoped_identifier' ? buildScopedPath(pathChild) : pathChild.text; + const aliasName = aliasChild.text; + const segments = originalName.split('::'); + const importedName = segments[segments.length - 1]; + return [ + makeImportCapture( + anchor, + isReexport ? 'reexport' : 'named', + joinPaths(prefixPath, originalName), + importedName, + aliasName, + ), + ]; + } + + case 'identifier': { + return [ + makeImportCapture( + anchor, + isReexport ? 'reexport' : 'named', + joinPaths(prefixPath, node.text), + node.text, + undefined, + ), + ]; + } + + default: + return []; + } +} + +function decomposeUseList( + listNode: SyntaxNode, + prefix: string, + isReexport: boolean, + anchor: SyntaxNode, +): CaptureMatch[] { + const out: CaptureMatch[] = []; + for (let i = 0; i < listNode.namedChildCount; i++) { + const child = listNode.namedChild(i); + if (child === null) continue; + + if (child.type === 'self') { + // `use crate::models::{self}` — imports the module itself + const segments = prefix.split('::').filter(Boolean); + const name = segments[segments.length - 1] ?? 'self'; + out.push(makeImportCapture(anchor, 'namespace', prefix, name, undefined)); + } else { + out.push(...decomposeUseArgument(child, prefix, isReexport, anchor)); + } + } + return out; +} + +function buildScopedPath(node: SyntaxNode): string { + if (node.type === 'scoped_identifier') { + const parts: string[] = []; + collectScopedParts(node, parts); + return parts.join('::'); + } + return node.text; +} + +function collectScopedParts(node: SyntaxNode, parts: string[]): void { + if (node.type === 'scoped_identifier') { + const pathNode = node.childForFieldName('path'); + const nameNode = node.childForFieldName('name'); + if (pathNode) collectScopedParts(pathNode, parts); + if (nameNode) parts.push(nameNode.text); + } else { + parts.push(node.text); + } +} + +function buildNodePath(node: SyntaxNode): string { + if (node.type === 'scoped_identifier') { + return buildScopedPath(node); + } + return node.text; +} + +function buildWildcardPath(node: SyntaxNode): string { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child === null) continue; + if (child.type === 'scoped_identifier') return buildScopedPath(child); + if (child.type === 'identifier') return child.text; + } + return ''; +} + +function joinPaths(prefix: string, suffix: string): string { + if (!prefix) return suffix; + if (!suffix) return prefix; + return `${prefix}::${suffix}`; +} + +function makeImportCapture( + anchor: SyntaxNode, + kind: string, + source: string, + name: string, + alias: string | undefined, +): CaptureMatch { + return { + '@import.statement': syntheticCapture('@import.statement', anchor, anchor.text), + '@import.kind': syntheticCapture('@import.kind', anchor, kind), + '@import.source': syntheticCapture('@import.source', anchor, source), + '@import.name': syntheticCapture('@import.name', anchor, alias ?? name), + ...(alias !== undefined + ? { + '@import.alias': syntheticCapture('@import.alias', anchor, alias), + '@import.original-name': syntheticCapture('@import.original-name', anchor, name), + } + : {}), + }; +} diff --git a/gitnexus/src/core/ingestion/languages/rust/import-target.ts b/gitnexus/src/core/ingestion/languages/rust/import-target.ts new file mode 100644 index 000000000..ef634a247 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/import-target.ts @@ -0,0 +1,129 @@ +/** + * Resolve a Rust `use` import path to a repo-relative file path. + * + * Rust module resolution rules: + * - `crate::foo::bar` → `src/foo/bar.rs` or `src/foo/bar/mod.rs` + * - `super::foo` → parent directory's `foo.rs` or `foo/mod.rs` + * - `self::foo` → same directory's `foo.rs` or `foo/mod.rs` + * - External crate imports (no `crate::`/`super::`/`self::`) → null + */ +export function resolveRustImportTarget( + targetRaw: string, + fromFile: string, + allFilePaths: ReadonlySet, + _resolutionConfig?: unknown, +): string | readonly string[] | null { + if (!targetRaw) return null; + + const segments = targetRaw.split('::').filter(Boolean); + if (segments.length === 0) return null; + + const fromNormalized = fromFile.replace(/\\/g, '/'); + const fromDir = fromNormalized.includes('/') + ? fromNormalized.slice(0, fromNormalized.lastIndexOf('/')) + : ''; + + if (segments[0] === 'crate') { + const cratePath = segments.slice(1); + return resolveModulePath(cratePath, findSrcRoot(fromNormalized), allFilePaths); + } + + if (segments[0] === 'super') { + const parentDir = fromDir.includes('/') ? fromDir.slice(0, fromDir.lastIndexOf('/')) : ''; + const restPath = segments.slice(1); + return resolveModulePath(restPath, parentDir, allFilePaths); + } + + if (segments[0] === 'self') { + const restPath = segments.slice(1); + return resolveModulePath(restPath, fromDir, allFilePaths); + } + + // External crate — try workspace-level resolution + const workspaceResult = resolveWorkspaceCrate(segments, allFilePaths); + if (workspaceResult !== null) return workspaceResult; + + // Fallback: treat as implicit crate-relative (Rust 2015 edition or + // when the first segment matches a sibling module name). + return resolveModulePath(segments, findSrcRoot(fromNormalized), allFilePaths); +} + +function findSrcRoot(filePath: string): string { + const normalized = filePath.replace(/\\/g, '/'); + const srcIdx = normalized.lastIndexOf('/src/'); + if (srcIdx !== -1) return normalized.slice(0, srcIdx + 4); // includes trailing /src + if (normalized.startsWith('src/')) return 'src'; + return ''; +} + +function resolveModulePath( + pathSegments: string[], + baseDir: string, + allFilePaths: ReadonlySet, +): string | readonly string[] | null { + if (pathSegments.length === 0) { + const modPath = baseDir ? `${baseDir}/mod.rs` : 'mod.rs'; + if (allFilePaths.has(modPath)) return modPath; + return null; + } + + const modulePath = pathSegments.join('/'); + + // Try direct file + const directFile = baseDir ? `${baseDir}/${modulePath}.rs` : `${modulePath}.rs`; + if (allFilePaths.has(directFile)) return directFile; + + // Try mod.rs inside directory + const modFile = baseDir ? `${baseDir}/${modulePath}/mod.rs` : `${modulePath}/mod.rs`; + if (allFilePaths.has(modFile)) return modFile; + + // Try partial path resolution: for `use crate::models::User` where + // User is a type inside models.rs, resolve to `src/models.rs` + if (pathSegments.length >= 2) { + const parentPath = pathSegments.slice(0, -1).join('/'); + const parentFile = baseDir ? `${baseDir}/${parentPath}.rs` : `${parentPath}.rs`; + if (allFilePaths.has(parentFile)) return parentFile; + + const parentModFile = baseDir ? `${baseDir}/${parentPath}/mod.rs` : `${parentPath}/mod.rs`; + if (allFilePaths.has(parentModFile)) return parentModFile; + } + + // Fallback: try increasingly shorter path prefixes + for (let i = pathSegments.length - 2; i >= 1; i--) { + const prefix = pathSegments.slice(0, i).join('/'); + const prefixFile = baseDir ? `${baseDir}/${prefix}.rs` : `${prefix}.rs`; + if (allFilePaths.has(prefixFile)) return prefixFile; + const prefixModFile = baseDir ? `${baseDir}/${prefix}/mod.rs` : `${prefix}/mod.rs`; + if (allFilePaths.has(prefixModFile)) return prefixModFile; + } + + return null; +} + +function resolveWorkspaceCrate( + segments: string[], + allFilePaths: ReadonlySet, +): string | null { + const crateName = segments[0]; + const restSegments = segments.slice(1); + + const candidates = [ + restSegments.length > 0 + ? `${crateName}/src/${restSegments.join('/')}.rs` + : `${crateName}/src/lib.rs`, + restSegments.length > 0 + ? `${crateName}/src/${restSegments.join('/')}/mod.rs` + : `${crateName}/src/lib.rs`, + ]; + + for (const candidate of candidates) { + if (allFilePaths.has(candidate)) return candidate; + } + + return null; +} + +export interface RustResolveContext { + readonly fromFile: string; + readonly allFilePaths: ReadonlySet; +} diff --git a/gitnexus/src/core/ingestion/languages/rust/index.ts b/gitnexus/src/core/ingestion/languages/rust/index.ts new file mode 100644 index 000000000..fa47a8157 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/index.ts @@ -0,0 +1,16 @@ +/** + * Rust scope-resolution hooks (RFC #909 Ring 3). + */ +export { emitRustScopeCaptures } from './captures.js'; +export { getRustCaptureCacheStats, resetRustCaptureCacheStats } from './cache-stats.js'; +export { + interpretRustImport, + interpretRustTypeBinding, + normalizeRustTypeName, +} from './interpret.js'; +export { splitRustUseDeclaration } from './import-decomposer.js'; +export { synthesizeRustReceiverBinding } from './receiver-binding.js'; +export { rustArityCompatibility } from './arity.js'; +export { rustMergeBindings } from './merge-bindings.js'; +export { rustBindingScopeFor, rustImportOwningScope, rustReceiverBinding } from './simple-hooks.js'; +export { resolveRustImportTarget, type RustResolveContext } from './import-target.js'; diff --git a/gitnexus/src/core/ingestion/languages/rust/interpret.ts b/gitnexus/src/core/ingestion/languages/rust/interpret.ts new file mode 100644 index 000000000..a53a6e1c2 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/interpret.ts @@ -0,0 +1,166 @@ +import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared'; + +const REF_PREFIX_RE = /^&\s*(mut\s+)?/; +const PTR_PREFIX_RE = /^\*\s*(const|mut)?\s*/; +const ENUM_VARIANT_NAMES = new Set(['Some', 'None', 'Ok', 'Err']); + +// ─── interpretImport ────────────────────────────────────────────────────── + +export function interpretRustImport(captures: CaptureMatch): ParsedImport | null { + const kind = captures['@import.kind']?.text; + const source = captures['@import.source']?.text; + const name = captures['@import.name']?.text; + const alias = captures['@import.alias']?.text; + if (kind === undefined || source === undefined) return null; + + if (kind === 'wildcard') return { kind: 'wildcard', targetRaw: source }; + if (kind === 'namespace') { + if (name === undefined) return null; + return { kind: 'namespace', localName: name, importedName: name, targetRaw: source }; + } + if (kind === 'reexport') { + if (name === undefined) return null; + const originalName = captures['@import.original-name']?.text; + return { + kind: 'reexport', + localName: alias ?? name, + importedName: originalName ?? name, + targetRaw: source, + }; + } + // kind === 'named' + if (name === undefined) return null; + const originalName = captures['@import.original-name']?.text; + return { + kind: 'named', + localName: alias ?? name, + importedName: originalName ?? name, + targetRaw: source, + }; +} + +// ─── interpretTypeBinding ───────────────────────────────────────────────── + +export function interpretRustTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null { + const name = captures['@type-binding.name']?.text; + const type = captures['@type-binding.type']?.text; + if (name === undefined || type === undefined) return null; + + let source: TypeRef['source'] = 'annotation'; + let normalizedType: string; + + if (captures['@type-binding.self'] !== undefined) { + source = 'self'; + normalizedType = normalizeRustTypeName(type); + } else if (captures['@type-binding.constructor'] !== undefined) { + source = 'constructor-inferred'; + normalizedType = normalizeRustTypeName(type); + } else if (captures['@type-binding.call-return'] !== undefined) { + if (ENUM_VARIANT_NAMES.has(type)) return null; + source = 'constructor-inferred'; + normalizedType = normalizeRustCallReturnType(type); + } else if (captures['@type-binding.return'] !== undefined) { + source = 'return-annotation'; + normalizedType = normalizeRustReturnType(type); + } else if (captures['@type-binding.assignment'] !== undefined) { + source = 'assignment-inferred'; + normalizedType = normalizeRustTypeName(type); + } else if (captures['@type-binding.alias'] !== undefined) { + source = 'assignment-inferred'; + normalizedType = normalizeRustTypeName(type); + } else if (captures['@type-binding.parameter'] !== undefined) { + source = 'parameter-annotation'; + normalizedType = normalizeRustTypeName(type); + } else { + normalizedType = normalizeRustTypeName(type); + } + + return { boundName: name, rawTypeName: normalizedType, source }; +} + +export function normalizeRustTypeName(text: string): string { + let t = text.trim(); + // Strip reference prefixes (&, &mut, *const, *mut) + while (t.startsWith('&')) t = t.replace(REF_PREFIX_RE, ''); + while (t.startsWith('*')) t = t.replace(PTR_PREFIX_RE, ''); + // Unwrap common smart-pointer/container wrappers to their inner type + const wrappers = ['Box', 'Option', 'Arc', 'Rc', 'Mutex', 'RwLock', 'RefCell', 'Cell']; + for (const w of wrappers) { + if (t.startsWith(`${w}<`)) { + const inner = extractFirstGenericArg(t); + if (inner !== null) { + t = inner; + break; + } + } + } + if (t.startsWith('Vec<')) { + const inner = extractFirstGenericArg(t); + if (inner !== null) t = inner; + } + const bracket = t.indexOf('<'); + if (bracket !== -1) t = t.slice(0, bracket); + // Take last segment of qualified paths (crate::foo::Bar → Bar) + const lastColon = t.lastIndexOf('::'); + if (lastColon !== -1) t = t.slice(lastColon + 2); + return t.trim(); +} + +function extractFirstGenericArg(text: string): string | null { + const open = text.indexOf('<'); + if (open === -1) return null; + let depth = 0; + for (let i = open; i < text.length; i++) { + if (text[i] === '<') depth++; + else if (text[i] === '>') { + depth--; + if (depth === 0) { + const inner = text.slice(open + 1, i).trim(); + const comma = findTopLevelComma(inner); + return comma === -1 ? inner : inner.slice(0, comma).trim(); + } + } + } + return null; +} + +function findTopLevelComma(text: string): number { + let depth = 0; + for (let i = 0; i < text.length; i++) { + if (text[i] === '<') depth++; + else if (text[i] === '>') depth--; + else if (text[i] === ',' && depth === 0) return i; + } + return -1; +} + +function normalizeRustCallReturnType(text: string): string { + let t = text.trim(); + // For scoped calls like `Foo::new()`, extract the type part before `::` + const scopeIdx = t.indexOf('::'); + if (scopeIdx !== -1) { + t = t.slice(0, scopeIdx); + } + return normalizeRustTypeName(t); +} + +function normalizeRustReturnType(text: string): string { + let t = text.trim(); + while (t.startsWith('&')) t = t.replace(REF_PREFIX_RE, ''); + // Unwrap Result, Option for return types + const wrappers = ['Result', 'Option']; + for (const w of wrappers) { + if (t.startsWith(`${w}<`)) { + const inner = extractFirstGenericArg(t); + if (inner !== null) { + t = inner; + break; + } + } + } + const bracket = t.indexOf('<'); + if (bracket !== -1) t = t.slice(0, bracket); + const lastColon = t.lastIndexOf('::'); + if (lastColon !== -1) t = t.slice(lastColon + 2); + return t.trim(); +} diff --git a/gitnexus/src/core/ingestion/languages/rust/merge-bindings.ts b/gitnexus/src/core/ingestion/languages/rust/merge-bindings.ts new file mode 100644 index 000000000..6e62da3aa --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/merge-bindings.ts @@ -0,0 +1,27 @@ +import type { BindingRef } from 'gitnexus-shared'; + +const TIER: Record = { + local: 0, + namespace: 1, + import: 2, + reexport: 3, + wildcard: 4, +}; + +export function rustMergeBindings( + existing: readonly BindingRef[], + incoming: readonly BindingRef[], + _scopeId: string, +): BindingRef[] { + const seen = new Set(); + return [...existing, ...incoming] + .sort( + (a, b) => + (TIER[a.origin] ?? 99) - (TIER[b.origin] ?? 99) || a.def.nodeId.localeCompare(b.def.nodeId), + ) + .filter((binding) => { + if (seen.has(binding.def.nodeId)) return false; + seen.add(binding.def.nodeId); + return true; + }); +} diff --git a/gitnexus/src/core/ingestion/languages/rust/method-owners.ts b/gitnexus/src/core/ingestion/languages/rust/method-owners.ts new file mode 100644 index 000000000..7633a132b --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/method-owners.ts @@ -0,0 +1,68 @@ +import type { ParsedFile } from 'gitnexus-shared'; +import { isClassLike, populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js'; + +/** + * Populate `ownerId` on Rust method defs. + * + * Rust methods are declared inside `impl TypeName { ... }` blocks, not + * directly inside struct bodies. The tree-sitter query creates Class scopes + * for impl blocks, and the generic `populateClassOwnedMembers` handles methods + * that are structurally nested inside those Class scopes. But we also need to + * bridge the impl block's methods to the actual struct def, since the impl + * block is semantically "owned by" the struct. + * + * Strategy: + * 1. Run the generic `populateClassOwnedMembers` (handles property fields in + * structs and methods in impl blocks). + * 2. For each method in an impl block's Class scope whose ownerId points to + * the impl block, re-point ownerId to the struct def (if found in the + * same module). + */ +export function populateRustOwners(parsed: ParsedFile): void { + populateClassOwnedMembers(parsed); + populateRustImplOwners(parsed); +} + +function populateRustImplOwners(parsed: ParsedFile): void { + // Build a map of struct name → def nodeId from all scopes. + const structByName = new Map(); + for (const scope of parsed.scopes) { + for (const def of scope.ownedDefs) { + if (isClassLike(def.type) && def.qualifiedName) { + structByName.set(def.qualifiedName, def.nodeId); + } + } + } + if (structByName.size === 0) return; + + const structBySuffix = new Map(); + for (const [qname, nodeId] of structByName) { + const dot = qname.lastIndexOf('.'); + const suffix = dot !== -1 ? qname.slice(dot + 1) : qname; + structBySuffix.set(suffix, nodeId); + } + + for (const scope of parsed.scopes) { + if (scope.kind !== 'Function') continue; + const methodDefs = scope.ownedDefs.filter( + (d) => d.type === 'Method' && d.ownerId === undefined, + ); + if (methodDefs.length === 0) continue; + + let receiverType: string | undefined; + for (const [, tb] of scope.typeBindings) { + if (tb.source === 'self') { + receiverType = tb.rawName; + break; + } + } + if (receiverType === undefined) continue; + + const ownerId = structByName.get(receiverType) ?? structBySuffix.get(receiverType); + if (ownerId !== undefined) { + for (const def of methodDefs) { + (def as { ownerId?: string }).ownerId = ownerId; + } + } + } +} diff --git a/gitnexus/src/core/ingestion/languages/rust/query.ts b/gitnexus/src/core/ingestion/languages/rust/query.ts new file mode 100644 index 000000000..8e4671ba3 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/query.ts @@ -0,0 +1,149 @@ +import Parser from 'tree-sitter'; +import Rust from 'tree-sitter-rust'; + +const RUST_SCOPE_QUERY = ` +;; Scopes +(source_file) @scope.module +(struct_item) @scope.class +(trait_item) @scope.class +(impl_item) @scope.class +(enum_item) @scope.class +(function_item) @scope.function +(closure_expression) @scope.function +(block) @scope.block +(if_expression) @scope.block +(match_expression) @scope.block +(for_expression) @scope.block +(while_expression) @scope.block +(loop_expression) @scope.block +(mod_item) @scope.namespace + +;; Declarations — struct +(struct_item + name: (type_identifier) @declaration.name) @declaration.struct + +;; Declarations — trait +(trait_item + name: (type_identifier) @declaration.name) @declaration.trait + +;; Declarations — enum +(enum_item + name: (type_identifier) @declaration.name) @declaration.enum + +;; Declarations — function (top-level or inside mod) +(function_item + name: (identifier) @declaration.name) @declaration.function + +;; Declarations — struct fields +(field_declaration + name: (field_identifier) @declaration.name + type: (_) @declaration.field-type) @declaration.field + +;; Declarations — variables (let bindings) +(let_declaration + pattern: (identifier) @declaration.name) @declaration.variable + +;; Declarations — const +(const_item + name: (identifier) @declaration.name) @declaration.const + +;; Declarations — static +(static_item + name: (identifier) @declaration.name) @declaration.const + +;; Imports +(use_declaration) @import.statement + +;; Type bindings — parameter annotations +(parameter + pattern: (identifier) @type-binding.name + type: (_) @type-binding.type) @type-binding.parameter + +;; Type bindings — let with type annotation +(let_declaration + pattern: (identifier) @type-binding.name + type: (_) @type-binding.type) @type-binding.assignment + +;; Type bindings — struct literal constructor inference +(let_declaration + pattern: (identifier) @type-binding.name + value: (struct_expression + name: (_) @type-binding.type)) @type-binding.constructor + +;; Type bindings — call-return inference (let x = Foo::new()) +(let_declaration + pattern: (identifier) @type-binding.name + value: (call_expression + function: (_) @type-binding.type)) @type-binding.call-return + +;; Type bindings — call-return inference through .await (let x = foo().await) +(let_declaration + pattern: (identifier) @type-binding.name + value: (await_expression + (call_expression + function: (_) @type-binding.type))) @type-binding.call-return + +;; Type bindings — variable alias (let x = y) +(let_declaration + pattern: (identifier) @type-binding.name + value: (identifier) @type-binding.type) @type-binding.alias + +;; Type bindings — return type annotation +(function_item + name: (identifier) @type-binding.name + return_type: (_) @type-binding.type) @type-binding.return + +;; References — free calls +(call_expression + function: (identifier) @reference.name) @reference.call.free + +;; References — member calls (obj.method()) +(call_expression + function: (field_expression + value: (_) @reference.receiver + field: (field_identifier) @reference.name)) @reference.call.member + +;; References — scoped calls (Foo::bar()) +(call_expression + function: (scoped_identifier + name: (identifier) @reference.name)) @reference.call.free + +;; References — constructor calls (struct literal) +(struct_expression + name: (_) @reference.name) @reference.call.constructor + +;; References — field reads +(field_expression + value: (_) @reference.receiver + field: (field_identifier) @reference.name) @reference.read + +;; References — field writes (assignment) +(assignment_expression + left: (field_expression + value: (_) @reference.receiver + field: (field_identifier) @reference.name)) @reference.write + +;; References — field writes (compound assignment: +=, -=, etc.) +(compound_assignment_expr + left: (field_expression + value: (_) @reference.receiver + field: (field_identifier) @reference.name)) @reference.write +`; + +let _parser: Parser | null = null; +let _query: Parser.Query | null = null; + +export function getRustParser(): Parser { + if (_parser === null) { + _parser = new Parser(); + _parser.setLanguage(Rust as Parameters[0]); + } + return _parser; +} + +export function getRustScopeQuery(): Parser.Query { + if (_query === null) { + _query = new Parser.Query(Rust as Parameters[0], RUST_SCOPE_QUERY); + } + return _query; +} diff --git a/gitnexus/src/core/ingestion/languages/rust/range-binding.ts b/gitnexus/src/core/ingestion/languages/rust/range-binding.ts new file mode 100644 index 000000000..a09eaa23c --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/range-binding.ts @@ -0,0 +1,708 @@ +import type { ParsedFile, Scope, ScopeId, TypeRef } from 'gitnexus-shared'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import { getRustParser } from './query.js'; +import { getTreeSitterBufferSize } from '../../constants.js'; +import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +/** + * Populate type bindings for patterns and iterators that the tree-sitter + * query can't handle (they need runtime type lookup, not just syntax). + * + * Covers: for-loop element types, if-let/while-let pattern bindings, + * match arm patterns, and struct destructuring. + * + * Runs in Phase 2 (after propagateImportedReturnTypes) so all cross-file + * type bindings are available for lookup. + */ +export function populateRustRangeBindings( + parsedFiles: readonly ParsedFile[], + _indexes: ScopeResolutionIndexes, + ctx: { + readonly fileContents: ReadonlyMap; + readonly treeCache?: { get(filePath: string): unknown }; + }, +): void { + const parser = getRustParser(); + const allReturnTypes = new Map(); + const allFieldTypes = new Map>(); + + for (const parsed of parsedFiles) { + const sourceText = ctx.fileContents.get(parsed.filePath); + if (sourceText === undefined) continue; + + const cachedTree = ctx.treeCache?.get(parsed.filePath); + const tree = + (cachedTree as ReturnType | undefined) ?? + parseSourceSafe(parser, sourceText, undefined, { + bufferSize: getTreeSitterBufferSize(sourceText), + }); + + for (const fn of tree.rootNode.descendantsOfType('function_item')) { + const nameNode = fn.childForFieldName('name'); + const retType = fn.childForFieldName('return_type'); + if (nameNode !== null && retType !== null) { + const name = nameNode.text; + if (allReturnTypes.has(name)) { + allReturnTypes.delete(name); + } else { + allReturnTypes.set(name, retType.text); + } + } + } + + for (const structNode of tree.rootNode.descendantsOfType('struct_item')) { + const nameNode = structNode.childForFieldName('name'); + const body = structNode.childForFieldName('body'); + if (nameNode === null || body === null) continue; + const fields = new Map(); + for (const field of body.descendantsOfType('field_declaration')) { + const fieldName = field.childForFieldName('name'); + const fieldType = field.childForFieldName('type'); + if (fieldName !== null && fieldType !== null) { + fields.set(fieldName.text, normalizeFieldType(fieldType.text)); + } + } + if (fields.size > 0) { + const name = nameNode.text; + if (allFieldTypes.has(name)) { + allFieldTypes.delete(name); + } else { + allFieldTypes.set(name, fields); + } + } + } + } + + for (const parsed of parsedFiles) { + const sourceText = ctx.fileContents.get(parsed.filePath); + if (sourceText === undefined) continue; + + const cachedTree = ctx.treeCache?.get(parsed.filePath); + const tree = + (cachedTree as ReturnType | undefined) ?? + parseSourceSafe(parser, sourceText, undefined, { + bufferSize: getTreeSitterBufferSize(sourceText), + }); + + const scopeMap = new Map(parsed.scopes.map((s) => [s.id, s])); + const moduleScope = parsed.scopes.find((s) => s.kind === 'Module'); + if (moduleScope === undefined) continue; + + processFieldTypeBindings(tree.rootNode, parsed, scopeMap); + processIdentityMethodBindings(parsed); + processForLoops(tree.rootNode, parsed, scopeMap, moduleScope, allReturnTypes); + processPatternBindings(tree.rootNode, parsed, scopeMap, moduleScope); + processStructDestructuring(tree.rootNode, parsed, scopeMap, moduleScope, allFieldTypes); + processPendingAssignments( + tree.rootNode, + parsed, + parsedFiles, + scopeMap, + moduleScope, + allReturnTypes, + ); + } +} + +function processFieldTypeBindings( + root: SyntaxNode, + parsed: ParsedFile, + scopeMap: ReadonlyMap, +): void { + for (const structNode of root.descendantsOfType('struct_item')) { + const nameNode = structNode.childForFieldName('name'); + if (nameNode === null) continue; + + const structScope = findScopeForNode(structNode, parsed, scopeMap); + if (structScope === null) continue; + + const body = structNode.childForFieldName('body'); + if (body === null) continue; + + for (const field of body.descendantsOfType('field_declaration')) { + const fieldName = field.childForFieldName('name'); + const fieldType = field.childForFieldName('type'); + if (fieldName === null || fieldType === null) continue; + + const normalizedType = normalizeFieldType(fieldType.text); + injectTypeBinding(structScope, fieldName.text, normalizedType); + } + } +} + +function findScopeForNode( + node: SyntaxNode, + parsed: ParsedFile, + scopeMap: ReadonlyMap, +): Scope | null { + for (const scope of parsed.scopes) { + if ( + scope.kind === 'Class' && + scope.range.startLine === node.startPosition.row + 1 && + scope.range.startCol === node.startPosition.column + ) { + return scope; + } + } + return null; +} + +function normalizeFieldType(text: string): string { + let t = text.trim(); + if (t.startsWith('&')) t = t.replace(/^&\s*(mut\s+)?/, ''); + const bracket = t.indexOf('<'); + if (bracket !== -1) t = t.slice(0, bracket); + const lastColon = t.lastIndexOf('::'); + if (lastColon !== -1) t = t.slice(lastColon + 2); + return t.trim(); +} + +function processForLoops( + root: SyntaxNode, + parsed: ParsedFile, + scopeMap: ReadonlyMap, + moduleScope: Scope, + allReturnTypes: ReadonlyMap, +): void { + for (const forNode of root.descendantsOfType('for_expression')) { + const patternNode = forNode.childForFieldName('pattern'); + const valueNode = forNode.childForFieldName('value'); + if (patternNode === null || valueNode === null) continue; + + const varName = extractVarName(patternNode); + if (varName === null) continue; + + const elementType = resolveIterableElementType( + valueNode, + parsed, + scopeMap, + moduleScope, + allReturnTypes, + ); + if (elementType === null) continue; + + const targetScope = findEnclosingFunctionScope(forNode, scopeMap) ?? moduleScope; + injectTypeBinding(targetScope, varName, elementType); + } +} + +function processPatternBindings( + root: SyntaxNode, + parsed: ParsedFile, + scopeMap: ReadonlyMap, + moduleScope: Scope, +): void { + for (const nodeType of ['let_condition', 'match_arm'] as const) { + for (const node of root.descendantsOfType(nodeType)) { + const patternNode = + nodeType === 'let_condition' + ? node.childForFieldName('pattern') + : (node.childForFieldName('pattern')?.firstNamedChild ?? null); + if (patternNode === null) continue; + + if (patternNode.type === 'captured_pattern') { + processCapturedPattern(patternNode, node, parsed, scopeMap, moduleScope); + continue; + } + + if (patternNode.type === 'tuple_struct_pattern') { + processTupleStructPattern(patternNode, node, parsed, scopeMap, moduleScope); + } + } + } +} + +function processCapturedPattern( + patternNode: SyntaxNode, + contextNode: SyntaxNode, + _parsed: ParsedFile, + scopeMap: ReadonlyMap, + moduleScope: Scope, +): void { + const varNode = patternNode.namedChildren.find((c) => c.type === 'identifier'); + const structPatternNode = patternNode.namedChildren.find((c) => c.type === 'struct_pattern'); + if (varNode === undefined || structPatternNode === undefined) return; + + const typeName = structPatternNode.childForFieldName('type')?.text; + if (typeName === undefined) return; + + const targetScope = findEnclosingFunctionScope(contextNode, scopeMap) ?? moduleScope; + injectTypeBinding(targetScope, varNode.text, typeName); +} + +function processTupleStructPattern( + patternNode: SyntaxNode, + contextNode: SyntaxNode, + parsed: ParsedFile, + scopeMap: ReadonlyMap, + moduleScope: Scope, +): void { + const wrapperNode = patternNode.childForFieldName('type'); + if (wrapperNode === null) return; + const wrapper = wrapperNode.text; + + const wrapperIdx = patternNode.namedChildren.indexOf(wrapperNode); + const innerIdent = patternNode.namedChildren.find( + (c, i) => i > wrapperIdx && c.type === 'identifier', + ); + if (innerIdent === null || innerIdent === undefined) return; + const varName = innerIdent.text; + + let sourceVarNode: SyntaxNode | null = null; + if (contextNode.type === 'let_condition') { + sourceVarNode = contextNode.childForFieldName('value'); + } else { + let matchExpr: SyntaxNode | null = contextNode.parent; + while (matchExpr !== null && matchExpr.type !== 'match_expression') { + matchExpr = matchExpr.parent; + } + sourceVarNode = matchExpr?.childForFieldName('value') ?? null; + } + if (sourceVarNode === null || sourceVarNode === undefined) return; + + const sourceVarName = sourceVarNode.type === 'identifier' ? sourceVarNode.text : null; + if (sourceVarName === null) return; + + const sourceType = lookupTypeInScopes(sourceVarName, contextNode, parsed, scopeMap, moduleScope); + if (sourceType === null) return; + + let resolvedType: string | null = null; + + if (wrapper === 'Some') { + resolvedType = unwrapGeneric(sourceType); + } else if (wrapper === 'Ok' || wrapper === 'Err') { + const rawType = lookupRawParameterType(sourceVarName, contextNode); + if (rawType !== null) { + const argIdx = wrapper === 'Ok' ? 0 : 1; + resolvedType = extractNthGenericArg(rawType, argIdx); + } + if (resolvedType === null) { + resolvedType = wrapper === 'Ok' ? unwrapGeneric(sourceType) : null; + } + } + + if (resolvedType === null) return; + + const targetScope = findEnclosingFunctionScope(contextNode, scopeMap) ?? moduleScope; + injectTypeBinding(targetScope, varName, resolvedType); +} + +function processStructDestructuring( + root: SyntaxNode, + parsed: ParsedFile, + scopeMap: ReadonlyMap, + moduleScope: Scope, + allFieldTypes?: ReadonlyMap>, +): void { + for (const letNode of root.descendantsOfType('let_declaration')) { + const patternNode = letNode.childForFieldName('pattern'); + if (patternNode === null || patternNode.type !== 'struct_pattern') continue; + + const typeName = patternNode.childForFieldName('type')?.text; + if (typeName === undefined) continue; + + const valueNode = letNode.childForFieldName('value'); + if (valueNode === null) continue; + + const targetScope = findEnclosingFunctionScope(letNode, scopeMap) ?? moduleScope; + + for (const fieldNode of patternNode.namedChildren) { + let fieldName: string | undefined; + if (fieldNode.type === 'field_pattern') { + fieldName = fieldNode.childForFieldName('name')?.text; + } else if (fieldNode.type === 'shorthand_field_pattern') { + fieldName = fieldNode.firstNamedChild?.text; + } + if (fieldName === undefined) continue; + + let fieldType = lookupFieldType(typeName, fieldName, parsed, scopeMap, moduleScope); + if (fieldType === null) { + fieldType = allFieldTypes?.get(typeName)?.get(fieldName) ?? null; + } + if (fieldType !== null) { + injectTypeBinding(targetScope, fieldName, fieldType); + } + } + } +} + +const IDENTITY_METHODS = ['unwrap', 'expect', 'clone', 'as_ref', 'as_mut']; + +function processIdentityMethodBindings(parsed: ParsedFile): void { + for (const scope of parsed.scopes) { + if (scope.kind !== 'Class') continue; + const classDef = scope.ownedDefs.find((d) => { + const t = d.type; + return t === 'Struct' || t === 'Enum' || t === 'Class'; + }); + if (classDef === undefined) continue; + const name = classDef.qualifiedName?.split('.').pop(); + if (name === undefined) continue; + + for (const method of IDENTITY_METHODS) { + if (!scope.typeBindings.has(method)) { + (scope.typeBindings as Map).set(method, { + rawName: name, + declaredAtScope: scope.id, + source: 'return-annotation', + }); + } + } + } +} + +function processPendingAssignments( + root: SyntaxNode, + parsed: ParsedFile, + allParsedFiles: readonly ParsedFile[], + scopeMap: ReadonlyMap, + moduleScope: Scope, + allReturnTypes: ReadonlyMap, +): void { + for (let pass = 0; pass < 3; pass++) { + for (const letNode of root.descendantsOfType('let_declaration')) { + const patternNode = letNode.childForFieldName('pattern'); + if (patternNode === null) continue; + const varName = extractVarName(patternNode); + if (varName === null) continue; + + const targetScope = findEnclosingFunctionScope(letNode, scopeMap) ?? moduleScope; + if (targetScope.typeBindings.has(varName)) continue; + + const valueNode = letNode.childForFieldName('value'); + if (valueNode === null) continue; + + if (valueNode.type === 'identifier') { + const rhsType = lookupTypeInScopes(valueNode.text, letNode, parsed, scopeMap, moduleScope); + if (rhsType !== null) { + injectTypeBinding(targetScope, varName, rhsType); + continue; + } + } + + if (valueNode.type === 'field_expression') { + const receiver = valueNode.childForFieldName('value'); + const field = valueNode.childForFieldName('field'); + if (receiver !== null && field !== null && receiver.type === 'identifier') { + const receiverType = lookupTypeInScopes( + receiver.text, + letNode, + parsed, + scopeMap, + moduleScope, + ); + if (receiverType !== null) { + const fieldType = findFieldTypeAcrossFiles(receiverType, field.text, allParsedFiles); + if (fieldType !== null) { + injectTypeBinding(targetScope, varName, fieldType); + } + } + } + } + + if (valueNode.type === 'call_expression') { + const func = valueNode.childForFieldName('function'); + if (func !== null && func.type === 'field_expression') { + const receiver = func.childForFieldName('value'); + const method = func.childForFieldName('field'); + if (receiver !== null && method !== null && receiver.type === 'identifier') { + const receiverType = lookupTypeInScopes( + receiver.text, + letNode, + parsed, + scopeMap, + moduleScope, + ); + if (receiverType !== null) { + const retType = findMethodReturnTypeAcrossFiles( + receiverType, + method.text, + allParsedFiles, + ); + if (retType !== null) { + injectTypeBinding(targetScope, varName, retType); + } + } + } + } + + if (func !== null && func.type === 'identifier') { + const rawReturn = allReturnTypes.get(func.text); + if (rawReturn !== undefined) { + injectTypeBinding(targetScope, varName, normalizeFieldType(rawReturn)); + } + } + } + } + } +} + +function resolveIterableElementType( + valueNode: SyntaxNode, + parsed: ParsedFile, + scopeMap: ReadonlyMap, + moduleScope: Scope, + allReturnTypes?: ReadonlyMap, +): string | null { + let iterableNode = valueNode; + if (iterableNode.type === 'reference_expression') { + iterableNode = iterableNode.firstNamedChild ?? iterableNode; + } + + if (iterableNode.type === 'identifier') { + const rawType = lookupTypeInScopes(iterableNode.text, valueNode, parsed, scopeMap, moduleScope); + if (rawType !== null) return unwrapGeneric(rawType); + } + + if (iterableNode.type === 'call_expression') { + const func = iterableNode.childForFieldName('function'); + if (func === null) return null; + + if (func.type === 'field_expression') { + const receiver = func.childForFieldName('value'); + if (receiver !== null && receiver.type === 'identifier') { + const rawType = lookupTypeInScopes(receiver.text, valueNode, parsed, scopeMap, moduleScope); + if (rawType !== null) return unwrapGeneric(rawType); + } + } + + if (func.type === 'identifier') { + const crossFileReturn = allReturnTypes?.get(func.text); + if (crossFileReturn !== undefined) return unwrapGeneric(crossFileReturn); + const rawReturn = lookupRawFunctionReturnType(func.text, valueNode); + if (rawReturn !== null) return unwrapGeneric(rawReturn); + const returnType = lookupReturnTypeInScopes(func.text, parsed, scopeMap, moduleScope); + if (returnType !== null) return unwrapGeneric(returnType); + } + } + + return null; +} + +function findFieldTypeAcrossFiles( + structName: string, + fieldName: string, + allParsedFiles: readonly ParsedFile[], +): string | null { + for (const pf of allParsedFiles) { + for (const scope of pf.scopes) { + if (scope.kind !== 'Class') continue; + const hasDef = scope.ownedDefs.some( + (d) => d.qualifiedName === structName || d.qualifiedName?.endsWith('.' + structName), + ); + if (!hasDef) continue; + const tb = scope.typeBindings.get(fieldName); + if (tb !== undefined) return tb.rawName; + } + } + return null; +} + +function findMethodReturnTypeAcrossFiles( + structName: string, + methodName: string, + allParsedFiles: readonly ParsedFile[], +): string | null { + for (const pf of allParsedFiles) { + for (const scope of pf.scopes) { + if (scope.kind !== 'Class') continue; + const hasDef = scope.ownedDefs.some( + (d) => d.qualifiedName === structName || d.qualifiedName?.endsWith('.' + structName), + ); + if (!hasDef) continue; + const tb = scope.typeBindings.get(methodName); + if (tb !== undefined && tb.source === 'return-annotation') return tb.rawName; + } + } + return null; +} + +function lookupRawFunctionReturnType(funcName: string, contextNode: SyntaxNode): string | null { + let root: SyntaxNode = contextNode; + while (root.parent !== null) root = root.parent; + for (const fn of root.descendantsOfType('function_item')) { + const nameNode = fn.childForFieldName('name'); + if (nameNode !== null && nameNode.text === funcName) { + const retType = fn.childForFieldName('return_type'); + if (retType !== null) return retType.text; + } + } + return null; +} + +function lookupRawParameterType(paramName: string, contextNode: SyntaxNode): string | null { + let current: SyntaxNode | null = contextNode; + while (current !== null) { + if (current.type === 'function_item') { + const params = current.childForFieldName('parameters'); + if (params !== null) { + for (let i = 0; i < params.namedChildCount; i++) { + const param = params.namedChild(i); + if (param === null || param.type !== 'parameter') continue; + const pattern = param.childForFieldName('pattern'); + const typeNode = param.childForFieldName('type'); + if (pattern !== null && typeNode !== null && pattern.text === paramName) { + return typeNode.text; + } + } + } + break; + } + current = current.parent; + } + return null; +} + +function lookupTypeInScopes( + name: string, + contextNode: SyntaxNode, + parsed: ParsedFile, + scopeMap: ReadonlyMap, + moduleScope: Scope, +): string | null { + const fnScope = findEnclosingFunctionScope(contextNode, scopeMap); + if (fnScope !== null) { + const tb = fnScope.typeBindings.get(name); + if (tb !== undefined) return tb.rawName; + } + + const mtb = moduleScope.typeBindings.get(name); + if (mtb !== undefined) return mtb.rawName; + + return null; +} + +function lookupReturnTypeInScopes( + funcName: string, + parsed: ParsedFile, + _scopeMap: ReadonlyMap, + moduleScope: Scope, +): string | null { + const tb = moduleScope.typeBindings.get(funcName); + if (tb !== undefined && tb.source === 'return-annotation') return tb.rawName; + + for (const scope of parsed.scopes) { + const stb = scope.typeBindings.get(funcName); + if (stb !== undefined && stb.source === 'return-annotation') return stb.rawName; + } + + return null; +} + +function lookupFieldType( + structName: string, + fieldName: string, + parsed: ParsedFile, + _scopeMap: ReadonlyMap, + _moduleScope: Scope, +): string | null { + for (const scope of parsed.scopes) { + if (scope.kind !== 'Class') continue; + const hasDef = scope.ownedDefs.some( + (d) => d.qualifiedName === structName || d.qualifiedName?.endsWith('.' + structName), + ); + if (!hasDef) continue; + + const tb = scope.typeBindings.get(fieldName); + if (tb !== undefined) return tb.rawName; + + for (const def of scope.ownedDefs) { + const defName = def.qualifiedName?.split('.').pop(); + if (def.type === 'Property' && defName === fieldName) { + const tb = scope.typeBindings.get(fieldName); + if (tb !== undefined) return tb.rawName; + } + } + } + return null; +} + +function unwrapGeneric(rawType: string): string { + const match = rawType.match(/^(?:Vec|Option|Arc|Rc|Box|Mutex|RwLock|RefCell|Cell)<(.+)>$/); + if (match) { + const inner = match[1].trim(); + const comma = findTopLevelComma(inner); + return comma === -1 ? inner : inner.slice(0, comma).trim(); + } + if (rawType.startsWith('&[') && rawType.endsWith(']')) { + return rawType.slice(2, -1).trim(); + } + return rawType; +} + +function extractNthGenericArg(rawType: string, n: number): string | null { + const open = rawType.indexOf('<'); + if (open === -1) return null; + const close = rawType.lastIndexOf('>'); + if (close === -1) return null; + const inner = rawType.slice(open + 1, close).trim(); + const args = splitTopLevelComma(inner); + return n < args.length ? args[n].trim() : null; +} + +function splitTopLevelComma(text: string): string[] { + const parts: string[] = []; + let depth = 0; + let start = 0; + for (let i = 0; i < text.length; i++) { + if (text[i] === '<') depth++; + else if (text[i] === '>') depth--; + else if (text[i] === ',' && depth === 0) { + parts.push(text.slice(start, i)); + start = i + 1; + } + } + parts.push(text.slice(start)); + return parts; +} + +function findTopLevelComma(text: string): number { + let depth = 0; + for (let i = 0; i < text.length; i++) { + if (text[i] === '<') depth++; + else if (text[i] === '>') depth--; + else if (text[i] === ',' && depth === 0) return i; + } + return -1; +} + +function extractVarName(node: SyntaxNode): string | null { + if (node.type === 'identifier') return node.text; + if (node.type === 'mut_pattern' || node.type === 'reference_pattern') { + return node.firstNamedChild?.text ?? null; + } + return null; +} + +function injectTypeBinding(scope: Scope, name: string, typeName: string): void { + if (scope.typeBindings.has(name)) return; + (scope.typeBindings as Map).set(name, { + rawName: typeName, + declaredAtScope: scope.id, + source: 'annotation', + }); +} + +function findEnclosingFunctionScope( + node: SyntaxNode, + scopeMap: ReadonlyMap, +): Scope | null { + let current: SyntaxNode | null = node as SyntaxNode; + while (current !== null) { + if (current.type === 'function_item') { + for (const scope of scopeMap.values()) { + if ( + scope.kind === 'Function' && + scope.range.startLine === current.startPosition.row + 1 && + scope.range.startCol === current.startPosition.column + ) { + return scope; + } + } + break; + } + current = current.parent; + } + return null; +} diff --git a/gitnexus/src/core/ingestion/languages/rust/receiver-binding.ts b/gitnexus/src/core/ingestion/languages/rust/receiver-binding.ts new file mode 100644 index 000000000..bafeb8896 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/receiver-binding.ts @@ -0,0 +1,139 @@ +import type { CaptureMatch } from 'gitnexus-shared'; +import { syntheticCapture } from '../../utils/ast-helpers.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +/** + * Given a function_item node that is inside an impl_item, synthesize a + * self-type-binding capture if the function has a `self_parameter`. + * + * The impl_item structure: + * impl [TraitName for] TypeName { fn method(&self) { ... } } + */ +export function synthesizeRustReceiverBinding( + fnNode: SyntaxNode, + implNode: SyntaxNode | null, +): CaptureMatch | null { + if (fnNode.type !== 'function_item') return null; + if (implNode === null) return null; + + const params = fnNode.childForFieldName('parameters'); + if (params === null) return null; + + let hasSelf = false; + for (let i = 0; i < params.namedChildCount; i++) { + if (params.namedChild(i)?.type === 'self_parameter') { + hasSelf = true; + break; + } + } + if (!hasSelf) return null; + + const implType = getImplTargetType(implNode); + if (implType === null) return null; + + return { + '@type-binding.self': syntheticCapture('@type-binding.self', fnNode, 'self'), + '@type-binding.name': syntheticCapture('@type-binding.name', fnNode, 'self'), + '@type-binding.type': syntheticCapture('@type-binding.type', fnNode, implType), + }; +} + +/** + * Extract the target type from an impl_item. + * `impl TypeName { ... }` → "TypeName" + * `impl TraitName for TypeName { ... }` → "TypeName" + */ +export function getImplTargetType(implNode: SyntaxNode): string | null { + if (implNode.type !== 'impl_item') return null; + + // Look for `for` keyword — if present, impl is `impl Trait for Type` + let hasFor = false; + let typeAfterFor: SyntaxNode | null = null; + for (let i = 0; i < implNode.childCount; i++) { + const child = implNode.child(i); + if (child === null) continue; + if (child.type === 'for') { + hasFor = true; + continue; + } + if (hasFor && child.type === 'type_identifier') { + typeAfterFor = child; + break; + } + if (hasFor && child.type === 'scoped_type_identifier') { + typeAfterFor = child; + break; + } + if (hasFor && child.type === 'generic_type') { + typeAfterFor = child; + break; + } + } + if (hasFor && typeAfterFor !== null) { + return normalizeRustTypeName(typeAfterFor.text); + } + + // No `for` keyword: impl TypeName { ... } + const typeField = implNode.childForFieldName('type'); + if (typeField !== null) { + return normalizeRustTypeName(typeField.text); + } + + // Fallback: find first type_identifier after `impl` + let afterImpl = false; + for (let i = 0; i < implNode.childCount; i++) { + const child = implNode.child(i); + if (child === null) continue; + if (child.type === 'impl') { + afterImpl = true; + continue; + } + if (afterImpl && (child.type === 'type_identifier' || child.type === 'generic_type')) { + return normalizeRustTypeName(child.text); + } + } + return null; +} + +/** + * Extract the trait name from an impl_item when it's `impl Trait for Type`. + */ +export function getImplTraitName(implNode: SyntaxNode): string | null { + if (implNode.type !== 'impl_item') return null; + + let afterImpl = false; + for (let i = 0; i < implNode.childCount; i++) { + const child = implNode.child(i); + if (child === null) continue; + if (child.type === 'impl') { + afterImpl = true; + continue; + } + if (child.type === 'for') { + break; + } + if ( + afterImpl && + (child.type === 'type_identifier' || child.type === 'scoped_type_identifier') + ) { + for (let j = i + 1; j < implNode.childCount; j++) { + const next = implNode.child(j); + if (next === null) continue; + if (next.type === 'for') { + return normalizeRustTypeName(child.text); + } + break; + } + } + } + return null; +} + +function normalizeRustTypeName(text: string): string { + let t = text.trim(); + while (t.startsWith('&')) t = t.replace(/^&\s*(mut\s+)?/, ''); + while (t.startsWith('*')) t = t.slice(1).trim(); + const bracket = t.indexOf('<'); + if (bracket !== -1) t = t.slice(0, bracket); + return t.trim(); +} diff --git a/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts new file mode 100644 index 000000000..fb06fd248 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts @@ -0,0 +1,79 @@ +import type { ParsedFile } from 'gitnexus-shared'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; +import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; +import { rustProvider } from '../rust.js'; +import { rustArityCompatibility, rustMergeBindings, resolveRustImportTarget } from './index.js'; +import { populateRustOwners } from './method-owners.js'; +import { populateRustRangeBindings } from './range-binding.js'; +import { isClassLike } from '../../scope-resolution/scope/walkers.js'; +import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js'; + +function buildRustMro( + graph: Parameters[0], + parsedFiles: readonly ParsedFile[], + nodeLookup: Parameters[2], +): Map { + const baseMro = buildMro(graph, parsedFiles, nodeLookup, defaultLinearize); + + const defIdByGraphId = new Map(); + for (const parsed of parsedFiles) { + for (const def of parsed.localDefs) { + if (!isClassLike(def.type)) continue; + const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup); + if (graphId !== undefined) defIdByGraphId.set(graphId, def.nodeId); + } + } + + const fileByDefId = new Map(); + for (const parsed of parsedFiles) { + for (const def of parsed.localDefs) { + fileByDefId.set(def.nodeId, parsed.filePath); + } + } + + for (const rel of graph.iterRelationshipsByType('IMPLEMENTS')) { + const childDefId = defIdByGraphId.get(rel.sourceId); + const parentDefId = defIdByGraphId.get(rel.targetId); + if (childDefId === undefined || parentDefId === undefined) continue; + + const childFile = fileByDefId.get(childDefId); + const parentFile = fileByDefId.get(parentDefId); + if (childFile !== parentFile) continue; + + const existing = baseMro.get(childDefId); + if (existing !== undefined) { + if (!existing.includes(parentDefId)) existing.push(parentDefId); + } else { + baseMro.set(childDefId, [parentDefId]); + } + } + + return baseMro; +} + +export const rustScopeResolver: ScopeResolver = { + language: SupportedLanguages.Rust, + languageProvider: rustProvider, + importEdgeReason: 'rust-scope: use', + + resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) => + resolveRustImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig), + + mergeBindings: (existing, incoming, scopeId) => rustMergeBindings(existing, incoming, scopeId), + + arityCompatibility: (callsite, def) => rustArityCompatibility(def, callsite), + + buildMro: (graph, parsedFiles, nodeLookup) => buildRustMro(graph, parsedFiles, nodeLookup), + + populateOwners: (parsed: ParsedFile) => populateRustOwners(parsed), + + isSuperReceiver: () => false, + + populateRangeBindings: populateRustRangeBindings, + + fieldFallbackOnMethodLookup: false, + hoistTypeBindingsToModule: true, + propagatesReturnTypesAcrossImports: true, + allowGlobalFreeCallFallback: true, +}; diff --git a/gitnexus/src/core/ingestion/languages/rust/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/rust/simple-hooks.ts new file mode 100644 index 000000000..2be487b03 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/simple-hooks.ts @@ -0,0 +1,44 @@ +import type { + CaptureMatch, + ParsedImport, + Scope, + ScopeId, + ScopeTree, + TypeRef, +} from 'gitnexus-shared'; + +export function rustBindingScopeFor( + decl: CaptureMatch, + innermost: Scope, + _tree: ScopeTree, +): ScopeId | null { + // Keep self typeBindings in the method's Function scope so + // populateRustOwners can match Method defs to their receiver types. + if (decl['@type-binding.self'] !== undefined) { + return innermost.id; + } + return null; +} + +/** + * Rust `use` statements inside a function body should attach at function scope, + * not module scope. If the innermost scope is a Function, attach there. + */ +export function rustImportOwningScope( + _imp: ParsedImport, + innermost: Scope, + _tree: ScopeTree, +): ScopeId | null { + if (innermost.kind === 'Function') { + return innermost.id; + } + return null; +} + +export function rustReceiverBinding(functionScope: Scope): TypeRef | null { + if (functionScope.kind !== 'Function') return null; + for (const binding of functionScope.typeBindings.values()) { + if (binding.source === 'self') return binding; + } + return null; +} diff --git a/gitnexus/src/core/ingestion/registry-primary-flag.ts b/gitnexus/src/core/ingestion/registry-primary-flag.ts index 56e39962a..72e5f5b3e 100644 --- a/gitnexus/src/core/ingestion/registry-primary-flag.ts +++ b/gitnexus/src/core/ingestion/registry-primary-flag.ts @@ -78,6 +78,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet = new Set = n [SupportedLanguages.C, cScopeResolver], [SupportedLanguages.CPlusPlus, cppScopeResolver], [SupportedLanguages.PHP, phpScopeResolver], + [SupportedLanguages.Rust, rustScopeResolver], [SupportedLanguages.JavaScript, javascriptScopeResolver], [SupportedLanguages.Kotlin, kotlinScopeResolver], ]); diff --git a/gitnexus/src/core/wiki/generator.ts b/gitnexus/src/core/wiki/generator.ts index b17ad3006..aaf7bb048 100644 --- a/gitnexus/src/core/wiki/generator.ts +++ b/gitnexus/src/core/wiki/generator.ts @@ -39,6 +39,7 @@ import { } from './llm-client.js'; import { callCursorLLM, resolveCursorConfig } from './cursor-client.js'; +import { callClaudeLLM, callCodexLLM, resolveLocalCLIConfig } from './local-cli-client.js'; import { GROUPING_SYSTEM_PROMPT, @@ -203,7 +204,7 @@ export class WikiGenerator { } /** - * Route LLM call to the appropriate provider (OpenAI-compatible or Cursor CLI). + * Route LLM call to the appropriate provider. */ private async invokeLLM( prompt: string, @@ -217,6 +218,16 @@ export class WikiGenerator { }); return callCursorLLM(prompt, cursorConfig, systemPrompt, options); } + if (this.llmConfig.provider === 'claude' || this.llmConfig.provider === 'codex') { + const localConfig = resolveLocalCLIConfig({ + model: this.llmConfig.model, + workingDirectory: this.repoPath, + requestTimeoutMs: this.llmConfig.requestTimeoutMs, + }); + return this.llmConfig.provider === 'claude' + ? callClaudeLLM(prompt, localConfig, systemPrompt, options) + : callCodexLLM(prompt, localConfig, systemPrompt, options); + } return callLLM(prompt, this.llmConfig, systemPrompt, options); } diff --git a/gitnexus/src/core/wiki/llm-client.ts b/gitnexus/src/core/wiki/llm-client.ts index 72948b6b0..6e88f8e74 100644 --- a/gitnexus/src/core/wiki/llm-client.ts +++ b/gitnexus/src/core/wiki/llm-client.ts @@ -9,7 +9,14 @@ import { CircuitOpenError, ResilientFetchExhaustedError, resilientFetch } from ' * Config priority: CLI flags > env vars > defaults */ -export type LLMProvider = 'openai' | 'openrouter' | 'azure' | 'custom' | 'cursor'; +export type LLMProvider = + | 'openai' + | 'openrouter' + | 'azure' + | 'custom' + | 'cursor' + | 'claude' + | 'codex'; export interface LLMConfig { apiKey: string; @@ -18,7 +25,7 @@ export interface LLMConfig { maxTokens: number; temperature: number; /** Provider type — controls auth header behaviour */ - provider?: 'openai' | 'openrouter' | 'azure' | 'custom' | 'cursor'; + provider?: LLMProvider; /** Azure api-version query param (e.g. '2024-10-21'). Appended to URL when set. */ apiVersion?: string; /** When true, strips sampling params and uses max_completion_tokens instead of max_tokens */ @@ -44,6 +51,17 @@ export interface LLMResponse { export async function resolveLLMConfig(overrides?: Partial): Promise { const { loadCLIConfig } = await import('../../storage/repo-manager.js'); const savedConfig = await loadCLIConfig(); + const savedProvider = overrides?.provider ?? savedConfig.provider; + const savedLocalModel = + savedProvider === 'cursor' + ? savedConfig.cursorModel + : savedProvider === 'claude' + ? savedConfig.claudeModel + : savedProvider === 'codex' + ? savedConfig.codexModel + : undefined; + const localProvider = + savedProvider === 'cursor' || savedProvider === 'claude' || savedProvider === 'codex'; const apiKey = overrides?.apiKey || @@ -61,13 +79,12 @@ export async function resolveLLMConfig(overrides?: Partial): Promise< 'https://openrouter.ai/api/v1', model: overrides?.model || - process.env.GITNEXUS_MODEL || - (savedConfig.provider === 'cursor' ? savedConfig.cursorModel : undefined) || - savedConfig.model || - 'minimax/minimax-m2.5', + (localProvider ? undefined : process.env.GITNEXUS_MODEL) || + savedLocalModel || + (localProvider ? '' : savedConfig.model || 'minimax/minimax-m2.5'), maxTokens: overrides?.maxTokens ?? 16_384, temperature: overrides?.temperature ?? 0, - provider: overrides?.provider ?? savedConfig.provider ?? 'openai', + provider: savedProvider ?? 'openai', apiVersion: overrides?.apiVersion || process.env.GITNEXUS_AZURE_API_VERSION || savedConfig.apiVersion, isReasoningModel: overrides?.isReasoningModel ?? savedConfig.isReasoningModel, diff --git a/gitnexus/src/core/wiki/local-cli-client.ts b/gitnexus/src/core/wiki/local-cli-client.ts new file mode 100644 index 000000000..cd3e36237 --- /dev/null +++ b/gitnexus/src/core/wiki/local-cli-client.ts @@ -0,0 +1,352 @@ +/** + * Local agent CLI clients for wiki generation. + * + * These providers use the user's authenticated local CLI session instead of + * an OpenAI-compatible HTTP API. + */ + +import fs from 'fs/promises'; +import { existsSync } from 'fs'; +import os from 'os'; +import path from 'path'; +import { execFileSync, spawn } from 'child_process'; +import { StringDecoder } from 'string_decoder'; +import type { LLMResponse, CallLLMOptions } from './llm-client.js'; + +import { logger } from '../logger.js'; + +export type LocalAgentProvider = 'claude' | 'codex'; + +export interface LocalCLIConfig { + model?: string; + workingDirectory?: string; + requestTimeoutMs?: number; +} + +const COMMANDS: Record = { + claude: 'claude', + codex: 'codex', +}; + +interface LocalCommand { + displayName: string; + command: string; + argsPrefix: string[]; +} + +function killChildTree(child: import('child_process').ChildProcess): void { + if (process.platform === 'win32' && child.pid !== undefined) { + try { + execFileSync('taskkill', ['/T', '/F', '/PID', String(child.pid)], { + stdio: 'ignore', + windowsHide: true, + }); + return; + } catch { + // Process may have already exited — fall through to child.kill() + } + } + child.kill(); +} + +function isVerbose(): boolean { + return process.env.GITNEXUS_VERBOSE === '1'; +} + +function verboseLog(provider: LocalAgentProvider, ...args: unknown[]): void { + if (isVerbose()) { + logger.info({ provider, args }, '[local-cli]'); + } +} + +const cachedCommands = new Map(); + +export function detectLocalCLI(provider: LocalAgentProvider): string | null { + if (cachedCommands.has(provider)) return cachedCommands.get(provider)?.displayName ?? null; + const commandInfo = resolveLocalCommand(provider); + try { + execFileSync(commandInfo.command, [...commandInfo.argsPrefix, '--version'], { + stdio: 'ignore', + }); + cachedCommands.set(provider, commandInfo); + } catch (err: unknown) { + const isNotFound = + err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT'; + if (!isNotFound && err instanceof Error) { + logger.warn( + `${provider} CLI found but --version failed (exit ${(err as { status?: number }).status ?? '?'}). ` + + `Ensure it is authenticated: run \`${COMMANDS[provider]} --version\` manually.`, + ); + } + cachedCommands.set(provider, null); + } + return cachedCommands.get(provider)?.displayName ?? null; +} + +export function resolveLocalCLIConfig(overrides?: Partial): LocalCLIConfig { + return { + model: overrides?.model, + workingDirectory: overrides?.workingDirectory, + requestTimeoutMs: overrides?.requestTimeoutMs, + }; +} + +export async function callClaudeLLM( + prompt: string, + config: LocalCLIConfig, + systemPrompt?: string, + options?: CallLLMOptions, +): Promise { + const commandInfo = getDetectedCommand('claude'); + if (!commandInfo) { + throw new Error('Claude CLI not found. Install Claude Code and ensure `claude` is on PATH.'); + } + + const args = ['-p', '--output-format', 'text', '--no-session-persistence']; + if (config.model) { + args.push('--model', config.model); + } + const fullPrompt = systemPrompt ? `${systemPrompt}\n\n---\n\n${prompt}` : prompt; + + const response = await runLocalCLI('claude', commandInfo, args, config, fullPrompt, options); + if (!response.content) { + throw new Error('claude CLI returned empty output'); + } + return response; +} + +export async function callCodexLLM( + prompt: string, + config: LocalCLIConfig, + systemPrompt?: string, + options?: CallLLMOptions, +): Promise { + const commandInfo = getDetectedCommand('codex'); + if (!commandInfo) { + throw new Error('Codex CLI not found. Install Codex CLI and ensure `codex` is on PATH.'); + } + + const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-wiki-codex-')); + const outputPath = path.join(outputDir, 'last-message.txt'); + const workingDirectory = config.workingDirectory || process.cwd(); + const fullPrompt = systemPrompt ? `${systemPrompt}\n\n---\n\n${prompt}` : prompt; + const args = [ + 'exec', + '--cd', + workingDirectory, + '--sandbox', + 'read-only', + '-c', + 'approval_policy="never"', + '--color', + 'never', + '--output-last-message', + outputPath, + ]; + + if (config.model) { + args.push('--model', config.model); + } + args.push('-'); + + try { + const response = await runLocalCLI('codex', commandInfo, args, config, fullPrompt, options); + const lastMessage = await fs.readFile(outputPath, 'utf-8').catch(() => ''); + const content = (lastMessage || response.content).trim(); + if (!content) { + throw new Error('codex CLI returned empty output'); + } + return { content }; + } finally { + await fs.rm(outputDir, { recursive: true, force: true }).catch(() => undefined); + } +} + +function runLocalCLI( + provider: LocalAgentProvider, + commandInfo: LocalCommand, + args: string[], + config: LocalCLIConfig, + stdinText?: string, + options?: CallLLMOptions, +): Promise { + const finalArgs = [...commandInfo.argsPrefix, ...args]; + verboseLog( + provider, + 'Spawning:', + commandInfo.command, + maskPromptArgs(provider, finalArgs).join(' '), + ); + verboseLog(provider, 'Working directory:', config.workingDirectory || process.cwd()); + if (config.model) { + verboseLog(provider, 'Model:', config.model); + } else { + verboseLog(provider, 'Model: default'); + } + + const startTime = Date.now(); + + return new Promise((resolve, reject) => { + const child = spawn(commandInfo.command, finalArgs, { + cwd: config.workingDirectory || process.cwd(), + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + env: { + ...process.env, + CI: '1', + }, + }); + + verboseLog(provider, 'Process spawned with PID:', child.pid); + + let stdout = ''; + let stderr = ''; + const stdoutDecoder = new StringDecoder('utf8'); + const stderrDecoder = new StringDecoder('utf8'); + let stdinError: Error | undefined; + let settled = false; + let killTimer: ReturnType | undefined; + + const rejectOnce = (error: Error) => { + if (settled) return; + settled = true; + if (killTimer !== undefined) clearTimeout(killTimer); + reject(error); + }; + + const resolveOnce = (response: LLMResponse) => { + if (settled) return; + settled = true; + if (killTimer !== undefined) clearTimeout(killTimer); + resolve(response); + }; + + if (config.requestTimeoutMs !== undefined && config.requestTimeoutMs > 0) { + killTimer = setTimeout(() => { + killChildTree(child); + const duration = + config.requestTimeoutMs! >= 60_000 + ? `${Math.round(config.requestTimeoutMs! / 60_000)}m` + : `${Math.round(config.requestTimeoutMs! / 1_000)}s`; + rejectOnce( + new Error( + `${provider} CLI timed out after ${duration}. ` + + 'Increase --timeout or omit it to disable the request timeout.', + ), + ); + }, config.requestTimeoutMs); + } + + child.stdout.on('data', (chunk: Buffer) => { + const chunkStr = stdoutDecoder.write(chunk); + stdout += chunkStr; + verboseLog(provider, `[stdout] received ${chunkStr.length} chars, total: ${stdout.length}`); + options?.onChunk?.(stdout.length); + }); + + child.stderr.on('data', (chunk: Buffer) => { + const chunkStr = stderrDecoder.write(chunk); + stderr += chunkStr; + verboseLog(provider, '[stderr]', chunkStr.trim()); + }); + + child.stdin.on('error', (err: Error) => { + stdinError = err; + verboseLog(provider, 'stdin error:', err.message); + }); + + child.on('close', (code) => { + stdout += stdoutDecoder.end(); + stderr += stderrDecoder.end(); + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + verboseLog(provider, `Process exited with code ${code} after ${elapsed}s`); + + if (code !== 0) { + const details = stderr.trim() || stdinError?.message || stdout.trim(); + rejectOnce(new Error(`${provider} CLI exited with code ${code}: ${details}`)); + return; + } + if (stdinError) { + rejectOnce(new Error(`${provider} CLI stdin error: ${stdinError.message}`)); + return; + } + resolveOnce({ content: stdout.trim() }); + }); + + child.on('error', (err) => { + rejectOnce(new Error(`Failed to spawn ${provider} CLI: ${err.message}`)); + }); + + child.stdin.end(stdinText); + }); +} + +function maskPromptArgs(provider: LocalAgentProvider, args: string[]): string[] { + if (provider === 'codex') { + return args.map((arg) => (arg === '-' ? '[stdin prompt]' : arg)); + } + return args; +} + +function getDetectedCommand(provider: LocalAgentProvider): LocalCommand | null { + detectLocalCLI(provider); + return cachedCommands.get(provider) ?? null; +} + +function resolveLocalCommand(provider: LocalAgentProvider): LocalCommand { + const displayName = COMMANDS[provider]; + if (process.platform !== 'win32') { + return { displayName, command: displayName, argsPrefix: [] }; + } + + const npmBin = findWindowsCommand(`${displayName}.cmd`) || findWindowsCommand(displayName); + if (npmBin) { + const binDir = path.dirname(npmBin); + if (provider === 'claude') { + const exePath = path.join( + binDir, + 'node_modules', + '@anthropic-ai', + 'claude-code', + 'bin', + 'claude.exe', + ); + if (existsSync(exePath)) { + return { displayName, command: exePath, argsPrefix: [] }; + } + } + + if (provider === 'codex') { + const scriptPath = path.join(binDir, 'node_modules', '@openai', 'codex', 'bin', 'codex.js'); + if (existsSync(scriptPath)) { + return { displayName, command: process.execPath, argsPrefix: [scriptPath] }; + } + } + } + + // Last-resort fallback for non-npm Windows installations that only expose a + // .cmd shim. Prompts are passed via stdin, so repo content is not placed on + // the command line. + return { + displayName, + command: process.env.ComSpec || 'cmd.exe', + argsPrefix: ['/d', '/s', '/c', displayName], + }; +} + +function findWindowsCommand(command: string): string | null { + try { + const output = execFileSync('where.exe', [command], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + return ( + output + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? null + ); + } catch { + return null; + } +} diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index 44446e0ec..72be42733 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -933,8 +933,10 @@ export interface CLIConfig { apiKey?: string; model?: string; baseUrl?: string; - provider?: 'openai' | 'openrouter' | 'azure' | 'custom' | 'cursor'; + provider?: 'openai' | 'openrouter' | 'azure' | 'custom' | 'cursor' | 'claude' | 'codex'; cursorModel?: string; + claudeModel?: string; + codexModel?: string; /** Azure api-version query param (e.g. '2024-10-21'). Only used when provider is 'azure'. */ apiVersion?: string; /** Set true when the deployment is a reasoning model (o1, o3, o4-mini). Auto-detected for OpenAI; must be set for Azure deployments. */ diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-deep/caller.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-deep/caller.cpp new file mode 100644 index 000000000..00d4352ef --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-deep/caller.cpp @@ -0,0 +1,6 @@ +#include "lib.h" + +void run() { + Derived d; + d.g(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-deep/lib.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-deep/lib.h new file mode 100644 index 000000000..4a47d0a61 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-deep/lib.h @@ -0,0 +1,24 @@ +#pragma once + +namespace ns { + namespace a { namespace b { + template + struct Inner { + void f(); + }; + } } + + template + struct Derived : a::b::Inner { + void g() { + this->f(); + } + }; +} + +// Second Inner class at global scope forces multi-candidate path +// in populateCppDependentBases, exercising the namespace filter. +template +struct Inner { + void g2(); +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-inline/caller.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-inline/caller.cpp new file mode 100644 index 000000000..00d4352ef --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-inline/caller.cpp @@ -0,0 +1,6 @@ +#include "lib.h" + +void run() { + Derived d; + d.g(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-inline/lib.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-inline/lib.h new file mode 100644 index 000000000..a2aac25d9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-inline/lib.h @@ -0,0 +1,17 @@ +#pragma once + +namespace ns { + inline namespace v1 { + template + struct Base { + void f(); + }; + } + + template + struct Derived : Base { + void g() { + this->f(); + } + }; +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-neg/caller.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-neg/caller.cpp new file mode 100644 index 000000000..00d4352ef --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-neg/caller.cpp @@ -0,0 +1,6 @@ +#include "lib.h" + +void run() { + Derived d; + d.g(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-neg/lib.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-neg/lib.h new file mode 100644 index 000000000..fa0595620 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-neg/lib.h @@ -0,0 +1,14 @@ +#pragma once + +namespace ns::outer { + namespace inner { + // No Inner declared here + } + + template + struct Derived : inner::Inner { + void g() { + this->f(); + } + }; +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-pos/caller.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-pos/caller.cpp new file mode 100644 index 000000000..00d4352ef --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-pos/caller.cpp @@ -0,0 +1,6 @@ +#include "lib.h" + +void run() { + Derived d; + d.g(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-pos/lib.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-pos/lib.h new file mode 100644 index 000000000..af492b81d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-pos/lib.h @@ -0,0 +1,17 @@ +#pragma once + +namespace ns::outer { + namespace inner { + template + struct Inner { + void f(); + }; + } + + template + struct Derived : inner::Inner { + void g() { + this->f(); + } + }; +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-sibling-suppress/caller.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-sibling-suppress/caller.cpp new file mode 100644 index 000000000..00d4352ef --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-sibling-suppress/caller.cpp @@ -0,0 +1,6 @@ +#include "lib.h" + +void run() { + Derived d; + d.g(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-sibling-suppress/lib.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-sibling-suppress/lib.h new file mode 100644 index 000000000..e040b1c68 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-sibling-suppress/lib.h @@ -0,0 +1,23 @@ +#pragma once + +namespace mylib { + namespace detail { + template + struct Inner { + void f_a(); + }; + } + namespace public_api { + template + struct Inner { + void f_b(); + }; + } + + template + struct Derived : detail::Inner { + void g() { + this->f_a(); + } + }; +} diff --git a/gitnexus/test/integration/antigravity-hook-e2e.test.ts b/gitnexus/test/integration/antigravity-hook-e2e.test.ts new file mode 100644 index 000000000..5e8683471 --- /dev/null +++ b/gitnexus/test/integration/antigravity-hook-e2e.test.ts @@ -0,0 +1,430 @@ +/** + * Integration Tests: Antigravity hook adapter end-to-end + * + * Runs the FULL install + execute flow: invokes the real `setupCommand()` + * to lay down the Antigravity hook adapter + helpers + win-rm-list-json.ps1 + * into a temp HOME, then spawns the installed adapter as a real child + * process against a temp git repo + .gitnexus/ directory. + * + * Why install-then-spawn rather than spawning the source adapter directly: + * the source `hooks/antigravity/gitnexus-antigravity-hook.cjs` requires + * sibling .cjs helpers (`./hook-lock.cjs`, `./hook-db-lock-probe.cjs`) + * that only live in `hooks/claude/`. The adapter is designed to be COPIED + * to its install location alongside those helpers — running it from its + * source dir fails with MODULE_NOT_FOUND. Exercising the install pipeline + * verifies the complete contract documented at + * https://geminicli.com/docs/hooks/reference/. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawnSync } from 'child_process'; +import fs from 'fs'; +import fsp from 'fs/promises'; +import path from 'path'; +import os from 'os'; +import { runHook, parseHookOutput } from '../utils/hook-test-helpers.js'; +import { setupCommand } from '../../src/cli/setup.js'; + +let tempHome: string; +let installedHook: string; +let tmpDir: string; +let gitNexusDir: string; +const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; + +beforeAll(async () => { + // Stage a temp HOME with the Antigravity marker dir present so + // setupCommand installs the adapter + helpers. + tempHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'antigravity-hook-e2e-home-')); + process.env.HOME = tempHome; + process.env.USERPROFILE = tempHome; + await fsp.mkdir(path.join(tempHome, '.gemini', 'antigravity'), { recursive: true }); + + // Suppress setupCommand's console.log so test output stays readable. + const origLog = console.log; + console.log = () => {}; + try { + await setupCommand(); + } finally { + console.log = origLog; + } + + installedHook = path.join( + tempHome, + '.gemini', + 'config', + 'hooks', + 'gitnexus', + 'gitnexus-antigravity-hook.cjs', + ); + + // Sanity-check the install. If this fails every downstream test would + // produce noisy MODULE_NOT_FOUND output that obscures the real cause. + if (!fs.existsSync(installedHook)) { + throw new Error(`Antigravity adapter was not installed at ${installedHook}`); + } + for (const helper of ['hook-lock.cjs', 'hook-db-lock-probe.cjs', 'win-rm-list-json.ps1']) { + const helperPath = path.join(path.dirname(installedHook), helper); + if (!fs.existsSync(helperPath)) { + throw new Error(`Helper not installed: ${helperPath}`); + } + } + + // Set up a temp git repo with .gitnexus/ for staleness tests. + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'antigravity-hook-e2e-repo-')); + gitNexusDir = path.join(tmpDir, '.gitnexus'); + fs.mkdirSync(gitNexusDir, { recursive: true }); + spawnSync('git', ['init'], { cwd: tmpDir, stdio: 'pipe' }); + spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: tmpDir, stdio: 'pipe' }); + spawnSync('git', ['config', 'user.name', 'Test'], { cwd: tmpDir, stdio: 'pipe' }); + fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'hello'); + spawnSync('git', ['add', '.'], { cwd: tmpDir, stdio: 'pipe' }); + spawnSync('git', ['commit', '-m', 'init'], { cwd: tmpDir, stdio: 'pipe' }); +}); + +afterAll(async () => { + process.env.HOME = originalHome; + process.env.USERPROFILE = originalUserProfile; + if (tempHome) await fsp.rm(tempHome, { recursive: true, force: true }); + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('antigravity hook adapter e2e', () => { + describe('AfterTool — stale-index hint after git mutations', () => { + it('emits the hint via both additionalContext and stderr after a successful git commit', () => { + fs.writeFileSync( + path.join(gitNexusDir, 'meta.json'), + JSON.stringify({ lastCommit: 'a'.repeat(40), stats: {} }), + ); + + const result = runHook(installedHook, { + hook_event_name: 'AfterTool', + tool_name: 'run_shell_command', + tool_input: { command: 'git commit -m "test"' }, + tool_response: { llmContent: '[committed]' }, + cwd: tmpDir, + }); + + const output = parseHookOutput(result.stdout); + expect(output).not.toBeNull(); + expect(output!.hookEventName).toBe('AfterTool'); + expect(output!.additionalContext).toContain('index is stale'); + expect(output!.additionalContext).toContain('npx gitnexus analyze'); + + // Mirror to stderr so terminal users see the hint even when the agent + // discards additionalContext + expect(result.stderr).toContain('[GitNexus] index is stale'); + }); + + it('stays silent when meta.json lastCommit matches HEAD', () => { + const head = spawnSync('git', ['rev-parse', 'HEAD'], { + cwd: tmpDir, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }).stdout.trim(); + fs.writeFileSync( + path.join(gitNexusDir, 'meta.json'), + JSON.stringify({ lastCommit: head, stats: {} }), + ); + + const result = runHook(installedHook, { + hook_event_name: 'AfterTool', + tool_name: 'run_shell_command', + tool_input: { command: 'git commit -m "test"' }, + tool_response: { llmContent: '[committed]' }, + cwd: tmpDir, + }); + + expect(parseHookOutput(result.stdout)).toBeNull(); + expect(result.stderr).not.toContain('[GitNexus] index is stale'); + }); + + it('includes --embeddings flag when the previous index had embeddings', () => { + fs.writeFileSync( + path.join(gitNexusDir, 'meta.json'), + JSON.stringify({ + lastCommit: 'b'.repeat(40), + stats: { embeddings: 42 }, + }), + ); + + const result = runHook(installedHook, { + hook_event_name: 'AfterTool', + tool_name: 'run_shell_command', + tool_input: { command: 'git commit -m "x"' }, + tool_response: { llmContent: '[ok]' }, + cwd: tmpDir, + }); + + const output = parseHookOutput(result.stdout); + expect(output).not.toBeNull(); + expect(output!.additionalContext).toContain('--embeddings'); + }); + + it('treats missing meta.json as stale', () => { + const metaPath = path.join(gitNexusDir, 'meta.json'); + if (fs.existsSync(metaPath)) fs.unlinkSync(metaPath); + + const result = runHook(installedHook, { + hook_event_name: 'AfterTool', + tool_name: 'run_shell_command', + tool_input: { command: 'git commit -m "x"' }, + tool_response: { llmContent: '[ok]' }, + cwd: tmpDir, + }); + + const output = parseHookOutput(result.stdout); + expect(output).not.toBeNull(); + expect(output!.additionalContext).toContain('stale'); + }); + + it('skips augment + hint when tool_response carries an error', () => { + fs.writeFileSync( + path.join(gitNexusDir, 'meta.json'), + JSON.stringify({ lastCommit: 'c'.repeat(40), stats: {} }), + ); + + const result = runHook(installedHook, { + hook_event_name: 'AfterTool', + tool_name: 'run_shell_command', + tool_input: { command: 'git commit -m "x"' }, + tool_response: { error: 'boom' }, + cwd: tmpDir, + }); + + expect(parseHookOutput(result.stdout)).toBeNull(); + }); + + it('skips augment + hint when tool_response.exit_code !== 0', () => { + fs.writeFileSync( + path.join(gitNexusDir, 'meta.json'), + JSON.stringify({ lastCommit: 'd'.repeat(40), stats: {} }), + ); + + const result = runHook(installedHook, { + hook_event_name: 'AfterTool', + tool_name: 'run_shell_command', + tool_input: { command: 'git commit -m "x"' }, + tool_response: { llmContent: '...', exit_code: 1 }, + cwd: tmpDir, + }); + + expect(parseHookOutput(result.stdout)).toBeNull(); + }); + + it('detects all five documented git mutation types', () => { + fs.writeFileSync( + path.join(gitNexusDir, 'meta.json'), + JSON.stringify({ lastCommit: 'e'.repeat(40), stats: {} }), + ); + + const mutations = [ + 'git commit -m "x"', + 'git merge feature', + 'git rebase main', + 'git cherry-pick abc123', + 'git pull origin main', + ]; + for (const cmd of mutations) { + const result = runHook(installedHook, { + hook_event_name: 'AfterTool', + tool_name: 'run_shell_command', + tool_input: { command: cmd }, + tool_response: { llmContent: '[ok]' }, + cwd: tmpDir, + }); + const output = parseHookOutput(result.stdout); + expect(output, `mutation: ${cmd}`).not.toBeNull(); + expect(output!.additionalContext).toContain('stale'); + } + }); + + it('ignores non-mutation git commands', () => { + fs.writeFileSync( + path.join(gitNexusDir, 'meta.json'), + JSON.stringify({ lastCommit: 'f'.repeat(40), stats: {} }), + ); + + const nonMutations = ['git status', 'git log', 'git diff', 'git branch', 'git stash']; + for (const cmd of nonMutations) { + const result = runHook(installedHook, { + hook_event_name: 'AfterTool', + tool_name: 'run_shell_command', + tool_input: { command: cmd }, + tool_response: { llmContent: '...' }, + cwd: tmpDir, + }); + expect(parseHookOutput(result.stdout), `cmd: ${cmd}`).toBeNull(); + } + }); + }); + + describe('AfterTool — augment branch (silent without gitnexus CLI)', () => { + it('does not crash on search_file_content with a real pattern', () => { + fs.writeFileSync( + path.join(gitNexusDir, 'meta.json'), + JSON.stringify({ lastCommit: '1'.repeat(40), stats: {} }), + ); + + const result = runHook(installedHook, { + hook_event_name: 'AfterTool', + tool_name: 'search_file_content', + tool_input: { pattern: 'handleRequest' }, + tool_response: { llmContent: '...' }, + cwd: tmpDir, + }); + + // Either exits cleanly (no augment found) or gets killed by the 10s + // hook timeout when spawned gitnexus CLI hangs in CI. + expect(result.status === 0 || result.status === null).toBe(true); + }); + + it('ignores patterns shorter than 3 chars', () => { + const result = runHook(installedHook, { + hook_event_name: 'AfterTool', + tool_name: 'search_file_content', + tool_input: { pattern: 'ab' }, + tool_response: { llmContent: '...' }, + cwd: tmpDir, + }); + + expect(result.status).toBe(0); + expect(parseHookOutput(result.stdout)).toBeNull(); + }); + + it('ignores tool names not in the registered matcher', () => { + const result = runHook(installedHook, { + hook_event_name: 'AfterTool', + tool_name: 'read_file', + tool_input: { path: '/some/file.ts' }, + tool_response: { llmContent: '...' }, + cwd: tmpDir, + }); + + expect(result.status).toBe(0); + expect(parseHookOutput(result.stdout)).toBeNull(); + }); + }); + + describe('cwd validation', () => { + it('rejects relative cwd silently', () => { + const result = runHook(installedHook, { + hook_event_name: 'AfterTool', + tool_name: 'run_shell_command', + tool_input: { command: 'git commit -m "x"' }, + tool_response: { llmContent: '[ok]' }, + cwd: 'relative/path', + }); + + expect(parseHookOutput(result.stdout)).toBeNull(); + }); + }); + + describe('unhappy paths', () => { + it('handles corrupted meta.json without crashing', () => { + fs.writeFileSync(path.join(gitNexusDir, 'meta.json'), 'THIS IS NOT JSON {{{'); + + const result = runHook(installedHook, { + hook_event_name: 'AfterTool', + tool_name: 'run_shell_command', + tool_input: { command: 'git commit -m "x"' }, + tool_response: { llmContent: '[ok]' }, + cwd: tmpDir, + }); + + expect(result.status === 0 || result.status === null).toBe(true); + }); + + it('treats meta.json without lastCommit as stale', () => { + fs.writeFileSync(path.join(gitNexusDir, 'meta.json'), JSON.stringify({ stats: {} })); + + const result = runHook(installedHook, { + hook_event_name: 'AfterTool', + tool_name: 'run_shell_command', + tool_input: { command: 'git commit -m "x"' }, + tool_response: { llmContent: '[ok]' }, + cwd: tmpDir, + }); + + const output = parseHookOutput(result.stdout); + expect(output).not.toBeNull(); + expect(output!.additionalContext).toContain('stale'); + }); + + it('ignores unknown hook_event_name', () => { + // PreToolUse is the Claude hook event; the Antigravity adapter has no + // handler for it and should exit silently. + const result = runHook(installedHook, { + hook_event_name: 'PreToolUse', + tool_name: 'run_shell_command', + tool_input: { command: 'git commit -m "x"' }, + tool_response: { llmContent: '[ok]' }, + cwd: tmpDir, + }); + + expect(result.status).toBe(0); + expect(parseHookOutput(result.stdout)).toBeNull(); + }); + + it('does not crash on empty stdin', () => { + const result = spawnSync(process.execPath, [installedHook], { + input: '', + encoding: 'utf-8', + timeout: 10000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + expect(result.status).toBe(0); + }); + + it('does not crash on missing hook_event_name', () => { + const result = runHook(installedHook, { + tool_name: 'run_shell_command', + tool_input: { command: 'git commit -m "x"' }, + tool_response: { llmContent: '[ok]' }, + cwd: tmpDir, + }); + expect(result.status).toBe(0); + expect(parseHookOutput(result.stdout)).toBeNull(); + }); + }); + + describe('directory without .gitnexus', () => { + // Nest the test repo deeply at the filesystem root so parent traversal + // (5 levels) cannot accidentally pick up a .gitnexus from an ancestor. + let noGitNexusDir: string; + let cleanupRoot: string; + + beforeAll(() => { + const root = os.platform() === 'win32' ? 'C:\\' : '/tmp'; + cleanupRoot = path.join(root, `no-gitnexus-antigravity-${Date.now()}-${process.pid}`); + noGitNexusDir = path.join(cleanupRoot, 'a', 'b', 'c', 'd', 'e', 'f'); + fs.mkdirSync(noGitNexusDir, { recursive: true }); + spawnSync('git', ['init'], { cwd: noGitNexusDir, stdio: 'pipe' }); + }); + + afterAll(() => { + fs.rmSync(cleanupRoot, { recursive: true, force: true }); + }); + + it('ignores AfterTool when no .gitnexus exists in cwd or any ancestor', () => { + const result = runHook(installedHook, { + hook_event_name: 'AfterTool', + tool_name: 'run_shell_command', + tool_input: { command: 'git commit -m "x"' }, + tool_response: { llmContent: '[ok]' }, + cwd: noGitNexusDir, + }); + expect(parseHookOutput(result.stdout)).toBeNull(); + }); + + it('ignores AfterTool search_file_content when no .gitnexus exists', () => { + const result = runHook(installedHook, { + hook_event_name: 'AfterTool', + tool_name: 'search_file_content', + tool_input: { pattern: 'handleRequest' }, + tool_response: { llmContent: '...' }, + cwd: noGitNexusDir, + }); + expect(parseHookOutput(result.stdout)).toBeNull(); + }); + }); +}); diff --git a/gitnexus/test/integration/cli-e2e.test.ts b/gitnexus/test/integration/cli-e2e.test.ts index 932f5811f..e9d529643 100644 --- a/gitnexus/test/integration/cli-e2e.test.ts +++ b/gitnexus/test/integration/cli-e2e.test.ts @@ -1130,6 +1130,8 @@ describe('CLI end-to-end', () => { expect(result.status).toBe(0); expect(result.stdout).toContain('--provider '); + expect(result.stdout).toContain('claude'); + expect(result.stdout).toContain('codex'); expect(result.stdout).toContain('--review'); expect(result.stdout).toContain('-v, --verbose'); expect(result.stdout).toContain('--model '); @@ -1198,6 +1200,22 @@ describe('CLI end-to-end', () => { expect(combined).not.toMatch(/API key:/); }); + it('wiki --provider claude without API key does not prompt for key in non-TTY', () => { + const result = runCliRaw(['wiki', MINI_REPO, '--provider', 'claude'], repoRoot, 15000); + if (result.status === null) return; + + const combined = result.stdout + result.stderr; + expect(combined).not.toMatch(/API key:/); + }); + + it('wiki --provider codex without API key does not prompt for key in non-TTY', () => { + const result = runCliRaw(['wiki', MINI_REPO, '--provider', 'codex'], repoRoot, 15000); + if (result.status === null) return; + + const combined = result.stdout + result.stderr; + expect(combined).not.toMatch(/API key:/); + }); + it('wiki --help includes --verbose flag description', () => { const result = runCliRaw(['wiki', '--help'], repoRoot); if (result.status === null) return; diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 0d31b3f18..b00aa9632 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -2288,6 +2288,93 @@ describe('C++ two-phase template lookup — this-> name-hiding arity mismatch', }); }); +describe('C++ two-phase template lookup — dependent-base cross-namespace (nested ns)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-two-phase-dependent-base-cross-ns-pos'), + () => {}, + ); + }, 60000); + + it('Derived::g() -> this->f() resolves to inner::Inner::f when Inner is in a nested namespace (1 edge)', () => { + const calls = getRelationships(result, 'CALLS'); + const resolved = calls.filter((c) => c.source === 'g' && c.target === 'f'); + expect(resolved.length).toBe(1); + expect(resolved[0].targetFilePath).toContain('lib.h'); + }); +}); + +describe('C++ two-phase template lookup — dependent-base cross-namespace (negative)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-two-phase-dependent-base-cross-ns-neg'), + () => {}, + ); + }, 60000); + + it('Derived::g() -> this->f() emits zero CALLS when no Inner exists in the nested namespace', () => { + const calls = getRelationships(result, 'CALLS'); + const leaks = calls.filter((c) => c.source === 'g' && c.target === 'f'); + expect(leaks.length).toBe(0); + }); +}); + +describe('C++ two-phase template lookup — dependent-base inline-namespace variant', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-two-phase-dependent-base-cross-ns-inline'), + () => {}, + ); + }, 60000); + + it('Derived::g() -> this->f() resolves to v1::Base::f when Base is in an inline namespace (1 edge)', () => { + const calls = getRelationships(result, 'CALLS'); + const resolved = calls.filter((c) => c.source === 'g' && c.target === 'f'); + expect(resolved.length).toBe(1); + expect(resolved[0].targetFilePath).toContain('lib.h'); + }); +}); + +describe('C++ two-phase template lookup — dependent-base deep nesting suppression', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-two-phase-dependent-base-cross-ns-deep'), + () => {}, + ); + }, 60000); + + it('Derived::g() -> this->f() emits zero CALLS when Inner is two levels deep (ns.a.b) — one-level cap enforced', () => { + const calls = getRelationships(result, 'CALLS'); + const leaks = calls.filter((c) => c.source === 'g' && c.target === 'f'); + expect(leaks.length).toBe(0); + }); +}); + +describe('C++ two-phase template lookup — dependent-base sibling-namespace suppression', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-two-phase-dependent-base-cross-ns-sibling-suppress'), + () => {}, + ); + }, 60000); + + it('Derived::g() -> this->f_a() emits zero CALLS when detail::Inner and public_api::Inner are sibling namespaces (ambiguity suppressed)', () => { + const calls = getRelationships(result, 'CALLS'); + const suppressed = calls.filter((c) => c.source === 'g' && c.target === 'f_a'); + expect(suppressed.length).toBe(0); + }); +}); + // --------------------------------------------------------------------------- // U3 cross-file namespace variant: Base lives in a different file AND // inside a namespace. The fixture also contains a free function with the diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index 486449aa9..26c586ad1 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -358,6 +358,13 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly::g() -> this->f_a() emits zero CALLS when detail::Inner and public_api::Inner are sibling namespaces (ambiguity suppressed)', + // PR #1634: deep-nesting suppression. The scope-resolver enforces a + // one-level cap on namespace walking. The legacy DAG picks arbitrarily. + 'Derived::g() -> this->f() emits zero CALLS when Inner is two levels deep (ns.a.b) — one-level cap enforced', ]), }; diff --git a/gitnexus/test/integration/resolvers/rust-scope.test.ts b/gitnexus/test/integration/resolvers/rust-scope.test.ts new file mode 100644 index 000000000..43c0f4ee8 --- /dev/null +++ b/gitnexus/test/integration/resolvers/rust-scope.test.ts @@ -0,0 +1,1023 @@ +/** + * Rust scope-resolution integration tests (RFC #909 Ring 3). + * + * These tests run with REGISTRY_PRIMARY_RUST=true to exercise the + * scope-based resolution path. They validate the core deliverables: + * impl blocks, use statements, receiver binding, module resolution. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import path from 'path'; +import fs from 'node:fs'; +import os from 'node:os'; +import { + getRelationships, + getNodesByLabel, + runPipelineFromRepo, + type PipelineResult, +} from './helpers.js'; + +function writeFixtureRepo(root: string, files: Record): void { + for (const [rel, content] of Object.entries(files)) { + const abs = path.join(root, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, content, 'utf8'); + } +} + +let savedEnv: string | undefined; + +beforeAll(() => { + savedEnv = process.env['REGISTRY_PRIMARY_RUST']; + process.env['REGISTRY_PRIMARY_RUST'] = 'true'; +}); + +afterAll(() => { + if (savedEnv === undefined) delete process.env['REGISTRY_PRIMARY_RUST']; + else process.env['REGISTRY_PRIMARY_RUST'] = savedEnv; +}); + +// --------------------------------------------------------------------------- +// 1. Impl blocks: methods classified as Method, owned by struct +// --------------------------------------------------------------------------- + +describe('Rust scope: impl block method classification', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-impl-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +mod user; +use crate::user::User; + +fn main() { + let u = User { name: String::new() }; + u.save(); +} +`, + 'src/user.rs': ` +pub struct User { + pub name: String, +} + +impl User { + pub fn save(&self) -> bool { + true + } + + pub fn create(name: String) -> User { + User { name } + } +} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects User struct', () => { + expect(getNodesByLabel(result, 'Struct')).toContain('User'); + }); + + it('classifies save as a function owned by User (HAS_METHOD edge)', () => { + const edges = getRelationships(result, 'HAS_METHOD'); + const userSave = edges.find((e) => e.source === 'User' && e.target === 'save'); + expect(userSave).toBeDefined(); + }); + + it('emits HAS_METHOD edge from User to save', () => { + const edges = getRelationships(result, 'HAS_METHOD'); + const userSave = edges.find((e) => e.source === 'User' && e.target === 'save'); + expect(userSave).toBeDefined(); + }); + + it('resolves main → u.save() as CALLS edge to save in user.rs', () => { + const calls = getRelationships(result, 'CALLS'); + const mainSave = calls.find( + (c) => c.target === 'save' && c.source === 'main' && c.targetFilePath?.includes('user.rs'), + ); + expect(mainSave).toBeDefined(); + }); + + it('emits IMPORTS edge from main.rs to user.rs', () => { + const imports = getRelationships(result, 'IMPORTS'); + const imp = imports.find((e) => e.targetFilePath?.includes('user.rs')); + expect(imp).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 2. Trait implementations: IMPLEMENTS edges + method resolution +// --------------------------------------------------------------------------- + +describe('Rust scope: trait implementation', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-trait-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +mod shapes; +use crate::shapes::{Circle, Drawable}; + +fn main() { + let c = Circle { radius: 5.0 }; + c.draw(); +} +`, + 'src/shapes.rs': ` +pub trait Drawable { + fn draw(&self); +} + +pub struct Circle { + pub radius: f64, +} + +impl Drawable for Circle { + fn draw(&self) { + println!("Drawing circle"); + } +} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects Drawable trait and Circle struct', () => { + expect(getNodesByLabel(result, 'Trait')).toContain('Drawable'); + expect(getNodesByLabel(result, 'Struct')).toContain('Circle'); + }); + + it('emits IMPLEMENTS edge from Circle to Drawable', () => { + const impls = getRelationships(result, 'IMPLEMENTS'); + const circleDrawable = impls.find((e) => e.source === 'Circle' && e.target === 'Drawable'); + expect(circleDrawable).toBeDefined(); + }); + + it('resolves main → c.draw() to shapes.rs', () => { + const calls = getRelationships(result, 'CALLS'); + const drawCall = calls.find((c) => c.target === 'draw' && c.source === 'main'); + expect(drawCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 3. Grouped imports: use crate::models::{User, Config} +// --------------------------------------------------------------------------- + +describe('Rust scope: grouped imports', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-grouped-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +mod models; +use crate::models::{User, Config}; + +fn process() { + let u = User { name: String::new() }; + u.save(); + let c = Config { debug: true }; + c.validate(); +} + +fn main() {} +`, + 'src/models.rs': ` +pub struct User { + pub name: String, +} + +impl User { + pub fn save(&self) {} +} + +pub struct Config { + pub debug: bool, +} + +impl Config { + pub fn validate(&self) {} +} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('resolves grouped import to models.rs', () => { + const imports = getRelationships(result, 'IMPORTS'); + const modelsImport = imports.find((e) => e.targetFilePath?.includes('models.rs')); + expect(modelsImport).toBeDefined(); + }); + + it('resolves u.save() to User#save via grouped import binding', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find((c) => c.target === 'save' && c.source === 'process'); + expect(saveCall).toBeDefined(); + }); + + it('resolves c.validate() to Config#validate via grouped import binding', () => { + const calls = getRelationships(result, 'CALLS'); + const validateCall = calls.find((c) => c.target === 'validate' && c.source === 'process'); + expect(validateCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 4. Renamed imports: use Foo as Bar +// --------------------------------------------------------------------------- + +describe('Rust scope: renamed imports (use as)', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-alias-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +mod models; +use crate::models::User as U; + +fn process() { + let u = U { name: String::new() }; + u.save(); +} + +fn main() {} +`, + 'src/models.rs': ` +pub struct User { + pub name: String, +} + +impl User { + pub fn save(&self) {} +} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('emits IMPORTS edge to models.rs', () => { + const imports = getRelationships(result, 'IMPORTS'); + const imp = imports.find((e) => e.targetFilePath?.includes('models.rs')); + expect(imp).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 5. Module resolution: super:: and self:: +// --------------------------------------------------------------------------- + +describe('Rust scope: module resolution (crate/super/self)', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-modules-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +mod models; +mod services; + +fn main() {} +`, + 'src/models.rs': ` +pub struct User { + pub name: String, +} + +impl User { + pub fn save(&self) {} +} +`, + 'src/services.rs': ` +use crate::models::User; + +pub fn process() { + let u = User { name: String::new() }; + u.save(); +} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('resolves crate::models::User import from services.rs to models.rs', () => { + const imports = getRelationships(result, 'IMPORTS'); + const imp = imports.find( + (e) => e.targetFilePath?.includes('models.rs') && e.sourceFilePath?.includes('services.rs'), + ); + expect(imp).toBeDefined(); + }); + + it('resolves process → u.save() to models.rs', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => + c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('models.rs'), + ); + expect(saveCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 6. Receiver binding: &self, &mut self +// --------------------------------------------------------------------------- + +describe('Rust scope: receiver binding disambiguation', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-receiver-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +mod user; +mod repo; +use crate::user::User; +use crate::repo::Repo; + +fn process() { + let u = User { name: String::new() }; + u.save(); + let r = Repo { path: String::new() }; + r.save(); +} + +fn main() {} +`, + 'src/user.rs': ` +pub struct User { + pub name: String, +} + +impl User { + pub fn save(&self) {} +} +`, + 'src/repo.rs': ` +pub struct Repo { + pub path: String, +} + +impl Repo { + pub fn save(&mut self) {} +} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('resolves u.save() to User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find( + (c) => c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('user.rs'), + ); + expect(userSave).toBeDefined(); + }); + + it('resolves r.save() to Repo#save', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find( + (c) => c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('repo.rs'), + ); + expect(repoSave).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 7. Arity filtering: no Rust overloading +// --------------------------------------------------------------------------- + +describe('Rust scope: arity filtering', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-arity-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +mod onearg; +mod twoarg; + +fn main() { + onearg::write_audit(String::from("test")); +} +`, + 'src/onearg.rs': ` +pub fn write_audit(msg: String) {} +`, + 'src/twoarg.rs': ` +pub fn write_audit(msg: String, level: i32) {} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('resolves main → write_audit to onearg.rs (1-arg match)', () => { + const calls = getRelationships(result, 'CALLS'); + const call = calls.find( + (c) => c.target === 'write_audit' && c.targetFilePath?.includes('onearg.rs'), + ); + expect(call).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 8. Struct constructor inference: let x = Foo { ... } +// --------------------------------------------------------------------------- + +describe('Rust scope: struct literal constructor inference', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-ctor-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +mod user; +use crate::user::User; + +fn process() { + let u = User { name: String::new() }; + u.save(); +} + +fn main() {} +`, + 'src/user.rs': ` +pub struct User { + pub name: String, +} + +impl User { + pub fn save(&self) {} +} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('resolves u.save() to User#save via struct literal type inference', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('user.rs'), + ); + expect(saveCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 9. Return type inference: fn get_user() -> User +// --------------------------------------------------------------------------- + +describe('Rust scope: return type inference', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-rettype-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +mod models; +use crate::models::User; + +fn get_user() -> User { + User { name: String::new() } +} + +fn process() { + let u = get_user(); + u.save(); +} + +fn main() {} +`, + 'src/models.rs': ` +pub struct User { + pub name: String, +} + +impl User { + pub fn save(&self) {} +} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('resolves process → u.save() via return type of get_user()', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => + c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('models.rs'), + ); + expect(saveCall).toBeDefined(); + }); + + it('resolves process → get_user() free call', () => { + const calls = getRelationships(result, 'CALLS'); + const getUserCall = calls.find((c) => c.target === 'get_user' && c.source === 'process'); + expect(getUserCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 10. Scoped call: Type::method() +// --------------------------------------------------------------------------- + +describe('Rust scope: scoped/qualified calls (Foo::new())', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-qualified-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +mod user; +use crate::user::User; + +fn process() { + let u = User::new(String::from("test")); + u.save(); +} + +fn main() {} +`, + 'src/user.rs': ` +pub struct User { + pub name: String, +} + +impl User { + pub fn new(name: String) -> User { + User { name } + } + + pub fn save(&self) {} +} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('resolves process → User::new() free call', () => { + const calls = getRelationships(result, 'CALLS'); + const newCall = calls.find((c) => c.target === 'new' && c.source === 'process'); + expect(newCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 11. Struct field declarations captured as Property +// --------------------------------------------------------------------------- + +describe('Rust scope: struct field declarations', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-fields-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +struct Point { + x: i32, + y: i32, +} + +fn main() { + let p = Point { x: 1, y: 2 }; +} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects Point struct', () => { + expect(getNodesByLabel(result, 'Struct')).toContain('Point'); + }); + + it('captures x and y as Property nodes', () => { + const props = getNodesByLabel(result, 'Property'); + expect(props).toContain('x'); + expect(props).toContain('y'); + }); +}); + +// --------------------------------------------------------------------------- +// 12. Enum declarations +// --------------------------------------------------------------------------- + +describe('Rust scope: enum declarations', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-enum-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +enum Color { + Red, + Green, + Blue, +} + +fn main() {} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects Color enum', () => { + expect(getNodesByLabel(result, 'Enum')).toContain('Color'); + }); +}); + +// --------------------------------------------------------------------------- +// 13. Multiple impl blocks for same struct +// --------------------------------------------------------------------------- + +describe('Rust scope: multiple impl blocks', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-multi-impl-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +mod user; +use crate::user::User; + +fn process() { + let u = User { name: String::new() }; + u.save(); + u.display(); +} + +fn main() {} +`, + 'src/user.rs': ` +pub struct User { + pub name: String, +} + +impl User { + pub fn save(&self) {} +} + +impl User { + pub fn display(&self) {} +} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('resolves u.save() from first impl block', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find((c) => c.target === 'save' && c.source === 'process'); + expect(saveCall).toBeDefined(); + }); + + it('resolves u.display() from second impl block', () => { + const calls = getRelationships(result, 'CALLS'); + const displayCall = calls.find((c) => c.target === 'display' && c.source === 'process'); + expect(displayCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 14. Free function calls (non-member) +// --------------------------------------------------------------------------- + +describe('Rust scope: free function calls', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-freecall-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +mod utils; +use crate::utils::helper; + +fn main() { + helper(); +} +`, + 'src/utils.rs': ` +pub fn helper() { + println!("helping"); +} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('resolves main → helper() free call', () => { + const calls = getRelationships(result, 'CALLS'); + const helperCall = calls.find((c) => c.target === 'helper' && c.source === 'main'); + expect(helperCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 15. Variable type binding via let: type annotation +// --------------------------------------------------------------------------- + +describe('Rust scope: typed let binding', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-lettype-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +mod user; +use crate::user::User; + +fn process() { + let u: User = User { name: String::new() }; + u.save(); +} + +fn main() {} +`, + 'src/user.rs': ` +pub struct User { + pub name: String, +} + +impl User { + pub fn save(&self) {} +} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('resolves u.save() via typed let binding', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find((c) => c.target === 'save' && c.source === 'process'); + expect(saveCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 16. Re-export chain: pub use re-exports +// --------------------------------------------------------------------------- + +describe('Rust scope: pub use re-exports', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-reexport-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +mod models; +use crate::models::User; + +fn process() { + let u = User { name: String::new() }; + u.save(); +} + +fn main() {} +`, + 'src/models.rs': ` +mod user; +pub use self::user::User; +`, + 'src/models/user.rs': ` +pub struct User { + pub name: String, +} + +impl User { + pub fn save(&self) {} +} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('emits IMPORTS edges through re-export chain', () => { + const imports = getRelationships(result, 'IMPORTS'); + expect(imports.length).toBeGreaterThanOrEqual(1); + }); +}); + +// --------------------------------------------------------------------------- +// 17. Local shadow: inner variable shadows outer +// --------------------------------------------------------------------------- + +describe('Rust scope: local variable shadowing', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-shadow-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +mod user; +use crate::user::User; + +fn process() { + let x = 42; + let x = User { name: String::new() }; + x.save(); +} + +fn main() {} +`, + 'src/user.rs': ` +pub struct User { + pub name: String, +} + +impl User { + pub fn save(&self) {} +} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('resolves x.save() to User#save after shadow rebind', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find((c) => c.target === 'save' && c.source === 'process'); + expect(saveCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 18. Closure / nested function scope +// --------------------------------------------------------------------------- + +describe('Rust scope: closure scope isolation', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-closure-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +fn adder(x: i32) -> i32 { + let f = |y: i32| -> i32 { x + y }; + f(10) +} + +fn main() { + adder(5); +} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects adder function', () => { + const fns = getNodesByLabel(result, 'Function'); + expect(fns).toContain('adder'); + }); + + it('resolves main → adder() call', () => { + const calls = getRelationships(result, 'CALLS'); + const call = calls.find((c) => c.target === 'adder' && c.source === 'main'); + expect(call).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 19. Trait default method +// --------------------------------------------------------------------------- + +describe('Rust scope: trait default methods', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-default-method-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +trait Greeter { + fn name(&self) -> String; + fn greet(&self) -> String { + format!("Hello, {}!", self.name()) + } +} + +struct User { + username: String, +} + +impl Greeter for User { + fn name(&self) -> String { + self.username.clone() + } +} + +fn main() { + let u = User { username: String::from("alice") }; + u.greet(); +} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects Greeter trait', () => { + expect(getNodesByLabel(result, 'Trait')).toContain('Greeter'); + }); + + it('detects User struct', () => { + expect(getNodesByLabel(result, 'Struct')).toContain('User'); + }); + + it('emits IMPLEMENTS edge from User to Greeter', () => { + const impls = getRelationships(result, 'IMPLEMENTS'); + const edge = impls.find((e) => e.source === 'User' && e.target === 'Greeter'); + expect(edge).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/rust-pipeline-benchmark.test.ts b/gitnexus/test/integration/rust-pipeline-benchmark.test.ts new file mode 100644 index 000000000..5a3a53a7c --- /dev/null +++ b/gitnexus/test/integration/rust-pipeline-benchmark.test.ts @@ -0,0 +1,234 @@ +/** + * Rust ingestion pipeline benchmark. + * + * Generates synthetic Rust codebases at increasing scales and measures + * wall-clock time and peak heap through the full pipeline — parsing, + * scope extraction, impl/trait resolution, use decomposition, and + * call resolution. + * + * Run: GITNEXUS_BENCH=1 npx vitest run test/integration/rust-pipeline-benchmark.test.ts + */ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; + +const BENCH_ENABLED = process.env.GITNEXUS_BENCH === '1'; + +interface BenchResult { + fileCount: number; + structCount: number; + moduleCount: number; + elapsedMs: number; + peakHeapMB: number; + nodeCount: number; + edgeCount: number; +} + +function generateRustFixture( + fileCount: number, + modulesPerLevel: number, +): { dir: string; structCount: number; moduleCount: number } { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `rust-bench-${fileCount}-`)); + const srcDir = path.join(dir, 'src'); + fs.mkdirSync(srcDir, { recursive: true }); + + const modules: string[] = []; + for (let i = 0; i < modulesPerLevel; i++) { + for (let j = 0; j < modulesPerLevel; j++) { + modules.push(`mod${i}_sub${j}`); + } + } + + const moduleCount = modules.length; + const structCount = fileCount; + + const modDeclarations: string[] = []; + const createdModules = new Set(); + + for (let f = 0; f < fileCount; f++) { + const modName = modules[f % modules.length]; + const structName = `Item${f}`; + const traitName = `Process${f}`; + + if (!createdModules.has(modName)) { + const modDir = path.join(srcDir, modName); + fs.mkdirSync(modDir, { recursive: true }); + createdModules.add(modName); + modDeclarations.push(`pub mod ${modName};`); + } + + const siblingIdx = (f + 1) % fileCount; + const siblingStruct = `Item${siblingIdx}`; + const siblingMod = modules[siblingIdx % modules.length]; + + const crossIdx = (f + Math.floor(fileCount / 3)) % fileCount; + const crossStruct = `Item${crossIdx}`; + const crossMod = modules[crossIdx % modules.length]; + + const needsCrossImport = crossMod !== modName; + + const content = [ + needsCrossImport ? `use crate::${crossMod}::${crossStruct};` : '', + modName !== siblingMod ? `use crate::${siblingMod}::${siblingStruct};` : '', + '', + `pub trait ${traitName} {`, + ` fn process(&self) -> String;`, + ` fn default_method(&self) -> bool { true }`, + `}`, + '', + `pub struct ${structName} {`, + ` pub id: u64,`, + ` pub name: String,`, + ` pub value: f64,`, + `}`, + '', + `impl ${structName} {`, + ` pub fn new(id: u64, name: String) -> Self {`, + ` Self { id, name, value: 0.0 }`, + ` }`, + '', + ` pub fn get_id(&self) -> u64 {`, + ` self.id`, + ` }`, + '', + ` pub fn set_value(&mut self, v: f64) {`, + ` self.value = v;`, + ` }`, + '', + ` pub fn compute(&self) -> f64 {`, + ` self.value * self.id as f64`, + ` }`, + `}`, + '', + `impl ${traitName} for ${structName} {`, + ` fn process(&self) -> String {`, + ` format!("{}: {}", self.name, self.compute())`, + ` }`, + `}`, + '', + `pub fn create_${structName.toLowerCase()}(id: u64) -> ${structName} {`, + ` let mut item = ${structName}::new(id, String::from("test"));`, + ` item.set_value(42.0);`, + ` let _result = item.compute();`, + ` let _processed = item.process();`, + ` item`, + `}`, + '', + ].join('\n'); + + const modDir = path.join(srcDir, modName); + const existingMod = path.join(modDir, 'mod.rs'); + const fileBaseName = structName.toLowerCase(); + + fs.writeFileSync(path.join(modDir, `${fileBaseName}.rs`), content); + + const modEntry = `pub mod ${fileBaseName};\npub use ${fileBaseName}::*;\n`; + fs.appendFileSync(existingMod, modEntry); + } + + const libContent = modDeclarations.join('\n') + '\n'; + fs.writeFileSync(path.join(srcDir, 'lib.rs'), libContent); + + const cargoToml = [ + '[package]', + 'name = "bench-rust-pipeline"', + 'version = "0.1.0"', + 'edition = "2021"', + '', + ].join('\n'); + fs.writeFileSync(path.join(dir, 'Cargo.toml'), cargoToml); + + return { dir, structCount, moduleCount }; +} + +async function runBenchmark( + fileCount: number, + modLevels: number, + budgetMs: number, +): Promise { + const { dir, structCount, moduleCount } = generateRustFixture(fileCount, modLevels); + + let peakHeapMB = 0; + const heapSampler = setInterval(() => { + const heap = process.memoryUsage().heapUsed / 1024 / 1024; + if (heap > peakHeapMB) peakHeapMB = heap; + }, 50); + + try { + const start = Date.now(); + const result = await Promise.race([ + runPipelineFromRepo(dir, () => {}, { skipGraphPhases: true }), + new Promise((_, reject) => + setTimeout( + () => reject(new Error(`Pipeline exceeded ${budgetMs}ms at ${fileCount} files`)), + budgetMs, + ), + ), + ]); + const elapsedMs = Date.now() - start; + + return { + fileCount, + structCount, + moduleCount, + elapsedMs, + peakHeapMB: Math.round(peakHeapMB), + nodeCount: result.graph.nodeCount, + edgeCount: result.graph.relationshipCount, + }; + } finally { + clearInterval(heapSampler); + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +function printResults(label: string, results: BenchResult[]) { + console.log(`\n${label}`); + console.log('┌──────────┬─────────┬──────────┬───────────┬──────────┬───────┬───────┐'); + console.log('│ Files │ Structs │ Modules │ Time (ms) │ Heap MB │ Nodes │ Edges │'); + console.log('├──────────┼─────────┼──────────┼───────────┼──────────┼───────┼───────┤'); + for (const r of results) { + console.log( + `│ ${String(r.fileCount).padStart(8)} │ ${String(r.structCount).padStart(7)} │ ${String(r.moduleCount).padStart(8)} │ ${String(r.elapsedMs).padStart(9)} │ ${String(r.peakHeapMB).padStart(8)} │ ${String(r.nodeCount).padStart(5)} │ ${String(r.edgeCount).padStart(5)} │`, + ); + } + console.log('└──────────┴─────────┴──────────┴───────────┴──────────┴───────┴───────┘'); + + if (results.length >= 2) { + console.log('\nScaling ratios (time_ratio / file_ratio):'); + for (let i = 1; i < results.length; i++) { + const fileRatio = results[i].fileCount / results[i - 1].fileCount; + const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs; + const scaling = timeRatio / fileRatio; + console.log( + ` ${results[i - 1].fileCount} → ${results[i].fileCount}: ${scaling.toFixed(2)}x (${scaling < 1.5 ? 'linear' : scaling < 3 ? 'superlinear' : 'WARNING: quadratic'})`, + ); + } + } +} + +describe.skipIf(!BENCH_ENABLED)('Rust pipeline benchmark', () => { + it('scales with file count (workers enabled)', async () => { + const scales = [100, 250, 500]; + const results: BenchResult[] = []; + + for (const fileCount of scales) { + const modLevels = Math.max(2, Math.ceil(Math.sqrt(fileCount / 4))); + const result = await runBenchmark(fileCount, modLevels, 180_000); + results.push(result); + console.log( + ` ${fileCount} files: ${result.elapsedMs}ms, ${result.peakHeapMB}MB heap, ${result.nodeCount} nodes, ${result.edgeCount} edges`, + ); + } + + printResults('Rust Pipeline — Workers Enabled', results); + + for (let i = 1; i < results.length; i++) { + const fileRatio = results[i].fileCount / results[i - 1].fileCount; + const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs; + expect(timeRatio / fileRatio).toBeLessThan(3); + } + }, 300_000); +}); diff --git a/gitnexus/test/integration/setup-antigravity.test.ts b/gitnexus/test/integration/setup-antigravity.test.ts new file mode 100644 index 000000000..e134f2a21 --- /dev/null +++ b/gitnexus/test/integration/setup-antigravity.test.ts @@ -0,0 +1,262 @@ +/** + * Integration Tests: setupCommand — Antigravity end-to-end + * + * Exercises the real `setupCommand()` (no mocks) against a temp HOME with + * `~/.gemini/antigravity/` present and verifies the on-disk artifacts: MCP + * config, ~/.gemini/settings.json hooks entry, hook adapter + helpers + * (including win-rm-list-json.ps1), and installed skills. + * + * Complements the unit-level setup-antigravity test by running the actual + * setup pipeline end-to-end with real filesystem state rather than mocked + * spawn/spawnSync. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; +import { fileURLToPath } from 'url'; +import { setupCommand } from '../../src/cli/setup.js'; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const packageRoot = path.resolve(testDir, '..', '..'); +const adapterSource = path.join( + packageRoot, + 'hooks', + 'antigravity', + 'gitnexus-antigravity-hook.cjs', +); + +describe('setupCommand Antigravity integration', () => { + let tempHome: string; + const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; + let antigravityDir: string; + let geminiDir: string; + + beforeAll(async () => { + tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-antigravity-int-')); + process.env.HOME = tempHome; + // os.homedir() honors USERPROFILE on Windows + process.env.USERPROFILE = tempHome; + geminiDir = path.join(tempHome, '.gemini'); + antigravityDir = path.join(geminiDir, 'antigravity'); + }); + + afterAll(async () => { + process.env.HOME = originalHome; + process.env.USERPROFILE = originalUserProfile; + await fs.rm(tempHome, { recursive: true, force: true }); + }); + + beforeEach(async () => { + // Reset ~/.gemini between tests so each starts from a clean slate but + // keeps the antigravity/ marker dir present (so setupAntigravity runs). + // Tests that need to verify the "not installed" skip path remove the + // marker themselves and restore it at the end. + await fs.rm(geminiDir, { recursive: true, force: true }); + await fs.mkdir(antigravityDir, { recursive: true }); + }); + + it('writes mcp_config.json with a valid mcpServers.gitnexus entry', async () => { + await setupCommand(); + + const raw = await fs.readFile(path.join(antigravityDir, 'mcp_config.json'), 'utf-8'); + const config = JSON.parse(raw); + + expect(config.mcpServers).toBeDefined(); + expect(config.mcpServers.gitnexus).toBeDefined(); + expect(typeof config.mcpServers.gitnexus.command).toBe('string'); + expect(Array.isArray(config.mcpServers.gitnexus.args)).toBe(true); + // mcp is always the final positional regardless of which command shape + // (global binary, npx, or cmd /c npx wrapper) is chosen + expect(config.mcpServers.gitnexus.args).toContain('mcp'); + }); + + it('registers an AfterTool entry in ~/.gemini/settings.json with the canonical matcher', async () => { + await setupCommand(); + + const settingsPath = path.join(geminiDir, 'settings.json'); + const config = JSON.parse(await fs.readFile(settingsPath, 'utf-8')); + + expect(config.hooks).toBeDefined(); + expect(config.hooks.AfterTool).toBeInstanceOf(Array); + expect(config.hooks.AfterTool).toHaveLength(1); + + const entry = config.hooks.AfterTool[0]; + expect(entry.matcher).toBe('search_file_content|glob|run_shell_command'); + expect(Array.isArray(entry.hooks)).toBe(true); + expect(entry.hooks).toHaveLength(1); + + const hook = entry.hooks[0]; + expect(hook.type).toBe('command'); + expect(hook.name).toBe('gitnexus'); + expect(hook.command).toMatch(/gitnexus-antigravity-hook\.cjs/); + // ms — Gemini CLI uses milliseconds; 10000 ms = 10 s + expect(hook.timeout).toBe(10000); + }); + + it('copies the adapter and all required helpers (including win-rm-list-json.ps1) to ~/.gemini/config/hooks/gitnexus/', async () => { + await setupCommand(); + + const hooksDir = path.join(geminiDir, 'config', 'hooks', 'gitnexus'); + for (const file of [ + 'gitnexus-antigravity-hook.cjs', + 'hook-lock.cjs', + 'hook-db-lock-probe.cjs', + // Required by hook-db-lock-probe.cjs on Windows; without it the MCP + // server ownership probe silently fails open and the adapter can race + // the MCP server on the LadybugDB. + 'win-rm-list-json.ps1', + ]) { + await expect( + fs.access(path.join(hooksDir, file)), + `expected ${file} to be installed`, + ).resolves.toBeUndefined(); + } + }); + + it('rewrites the adapter cliPath to an absolute resolved path at install time', async () => { + await setupCommand(); + + const installed = await fs.readFile( + path.join(geminiDir, 'config', 'hooks', 'gitnexus', 'gitnexus-antigravity-hook.cjs'), + 'utf-8', + ); + const source = await fs.readFile(adapterSource, 'utf-8'); + + // The source default uses path.resolve(__dirname, '..', '..', 'dist', ...) + // which would resolve incorrectly when the adapter is installed outside + // the gitnexus package tree (issue #108 regression class). Setup must + // replace it with a JSON-string absolute literal pointing at the real CLI. + // Under vitest/tsx the resolved __dirname of setup.ts is src/cli/, so the + // rewrite resolves to src/cli/index.js; under a packaged install it + // resolves to dist/cli/index.js. Accept either. + expect(source).toMatch( + /path\.resolve\(__dirname, '\.\.', '\.\.', 'dist', 'cli', 'index\.js'\)/, + ); + expect(installed).not.toMatch( + /let cliPath = path\.resolve\(__dirname, '\.\.', '\.\.', 'dist', 'cli', 'index\.js'\)/, + ); + expect(installed).toMatch(/let cliPath = "[^"]*(?:src|dist)\/cli\/index\.js"/); + }); + + it('installs gitnexus skills into ~/.gemini/antigravity/skills//SKILL.md', async () => { + await setupCommand(); + + const skillsDir = path.join(antigravityDir, 'skills'); + const entries = await fs.readdir(skillsDir, { withFileTypes: true }); + const skillNames = entries.filter((e) => e.isDirectory()).map((e) => e.name); + + expect(skillNames).toContain('gitnexus-cli'); + + const cliSkill = await fs.readFile(path.join(skillsDir, 'gitnexus-cli', 'SKILL.md'), 'utf-8'); + expect(cliSkill).toMatch(/GitNexus/i); + }); + + it('preserves user hooks under BeforeTool and other AfterTool matchers (polite-neighbor merge)', async () => { + const settingsPath = path.join(geminiDir, 'settings.json'); + await fs.writeFile( + settingsPath, + JSON.stringify( + { + theme: 'dark', + hooks: { + BeforeTool: [ + { + matcher: 'write_file', + hooks: [{ type: 'command', command: 'echo before', name: 'user-fmt' }], + }, + ], + AfterTool: [ + { + matcher: 'write_file', + hooks: [{ type: 'command', command: 'echo after', name: 'user-fmt' }], + }, + ], + }, + }, + null, + 2, + ), + 'utf-8', + ); + + await setupCommand(); + + const config = JSON.parse(await fs.readFile(settingsPath, 'utf-8')); + + // Unrelated keys preserved + expect(config.theme).toBe('dark'); + + // User's BeforeTool entry untouched + expect(config.hooks.BeforeTool).toHaveLength(1); + expect(config.hooks.BeforeTool[0].hooks[0].command).toBe('echo before'); + + // Our AfterTool entry appended after the user's + expect(config.hooks.AfterTool).toHaveLength(2); + expect(config.hooks.AfterTool[0].hooks[0].command).toBe('echo after'); + expect(config.hooks.AfterTool[1].hooks[0].command).toMatch(/gitnexus-antigravity-hook/); + }); + + it('is idempotent — re-running setupCommand does not duplicate the AfterTool entry', async () => { + await setupCommand(); + await setupCommand(); + await setupCommand(); + + const config = JSON.parse(await fs.readFile(path.join(geminiDir, 'settings.json'), 'utf-8')); + expect(config.hooks.AfterTool).toHaveLength(1); + + const mcpConfig = JSON.parse( + await fs.readFile(path.join(antigravityDir, 'mcp_config.json'), 'utf-8'), + ); + // Re-running setup should also leave mcpServers.gitnexus as the single + // canonical entry, not duplicate it. + expect(Object.keys(mcpConfig.mcpServers)).toEqual(['gitnexus']); + }); + + it('skips Antigravity setup entirely when ~/.gemini/antigravity is absent', async () => { + await fs.rm(geminiDir, { recursive: true, force: true }); + + await setupCommand(); + + // Neither the MCP config nor the hooks settings should be created when + // Antigravity is not installed. + await expect(fs.access(path.join(geminiDir, 'settings.json'))).rejects.toThrow(); + await expect(fs.access(path.join(antigravityDir, 'mcp_config.json'))).rejects.toThrow(); + }); + + it('preserves existing keys and other servers when merging into mcp_config.json', async () => { + await fs.writeFile( + path.join(antigravityDir, 'mcp_config.json'), + JSON.stringify( + { + existingKey: 'keep-me', + mcpServers: { other: { command: 'foo', args: ['bar'] } }, + }, + null, + 2, + ), + 'utf-8', + ); + + await setupCommand(); + + const config = JSON.parse( + await fs.readFile(path.join(antigravityDir, 'mcp_config.json'), 'utf-8'), + ); + expect(config.existingKey).toBe('keep-me'); + expect(config.mcpServers.other).toEqual({ command: 'foo', args: ['bar'] }); + expect(config.mcpServers.gitnexus).toBeDefined(); + }); + + it('leaves a corrupt mcp_config.json untouched rather than overwriting user data', async () => { + const mcpPath = path.join(antigravityDir, 'mcp_config.json'); + const corrupt = '{ definitely not json !!!'; + await fs.writeFile(mcpPath, corrupt, 'utf-8'); + + await setupCommand(); + + const raw = await fs.readFile(mcpPath, 'utf-8'); + expect(raw).toBe(corrupt); + }); +}); diff --git a/gitnexus/test/unit/cli-index-help.test.ts b/gitnexus/test/unit/cli-index-help.test.ts index 939a6a686..a50937ba7 100644 --- a/gitnexus/test/unit/cli-index-help.test.ts +++ b/gitnexus/test/unit/cli-index-help.test.ts @@ -220,6 +220,8 @@ describe('CLI help surface', () => { expect(result.status).toBe(0); expect(result.stdout).toContain('--provider '); + expect(result.stdout).toContain('claude'); + expect(result.stdout).toContain('codex'); expect(result.stdout).toContain('--review'); expect(result.stdout).toContain('-v, --verbose'); expect(result.stdout).toContain('--model '); diff --git a/gitnexus/test/unit/hooks.test.ts b/gitnexus/test/unit/hooks.test.ts index 141519c4d..892a19483 100644 --- a/gitnexus/test/unit/hooks.test.ts +++ b/gitnexus/test/unit/hooks.test.ts @@ -254,6 +254,10 @@ describe('windowsHide regression', () => { // Hook-layer files. Adding a new hook file MUST be reflected here. const HOOK_FILES: Array = [ ['gitnexus/hooks/claude/gitnexus-hook.cjs', CJS_HOOK], + [ + 'gitnexus/hooks/antigravity/gitnexus-antigravity-hook.cjs', + path.resolve(__dirname, '..', '..', 'hooks', 'antigravity', 'gitnexus-antigravity-hook.cjs'), + ], [ 'gitnexus/hooks/claude/hook-db-lock-probe.cjs', path.resolve(__dirname, '..', '..', 'hooks', 'claude', 'hook-db-lock-probe.cjs'), diff --git a/gitnexus/test/unit/local-cli-subprocess.test.ts b/gitnexus/test/unit/local-cli-subprocess.test.ts new file mode 100644 index 000000000..ce0e0a2f8 --- /dev/null +++ b/gitnexus/test/unit/local-cli-subprocess.test.ts @@ -0,0 +1,625 @@ +/** + * Integration-level tests for local CLI subprocess contracts. + * + * Validates the actual argv, stdin content, spawn options, and exit + * behavior for Claude and Codex providers — the layer that + * wiki-flags.test.ts mocks out. Uses a fake spawn that captures + * args and emits controlled events. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EventEmitter } from 'events'; + +function makeFakeChild(opts?: { + exitCode?: number; + stdout?: string; + stderr?: string; + stdinEndBehavior?: 'normal' | 'epipe'; +}) { + const child = new EventEmitter() as any; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = new EventEmitter() as any; + child.pid = 12345; + child.kill = vi.fn(); + + let stdinContent = ''; + child.stdin.end = vi.fn((data?: string) => { + if (data) stdinContent += data; + queueMicrotask(() => { + if (opts?.stdinEndBehavior === 'epipe') { + child.stdin.emit('error', new Error('write EPIPE')); + } + if (opts?.stdout) { + child.stdout.emit('data', Buffer.from(opts.stdout)); + } + if (opts?.stderr) { + child.stderr.emit('data', Buffer.from(opts.stderr)); + } + child.emit('close', opts?.exitCode ?? 0); + }); + }); + + return { child, getStdin: () => stdinContent }; +} + +// ─── Claude CLI argv contract ───────────────────────────────────────── + +describe('Claude CLI subprocess contract', () => { + let spawnSpy: ReturnType; + let fakeChild: ReturnType; + + beforeEach(() => { + vi.resetModules(); + fakeChild = makeFakeChild({ stdout: 'Claude response text' }); + spawnSpy = vi.fn(() => fakeChild.child); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('passes correct flags: -p --output-format text --no-session-persistence', async () => { + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('claude 1.0.0'), + spawn: spawnSpy, + })); + const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + await callClaudeLLM('user prompt', {}); + + const args = spawnSpy.mock.calls[0][1] as string[]; + expect(args).toContain('-p'); + expect(args).toContain('--output-format'); + expect(args).toContain('text'); + expect(args).toContain('--no-session-persistence'); + }); + + it('appends --model only when model is set', async () => { + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('claude 1.0.0'), + spawn: spawnSpy, + })); + const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + await callClaudeLLM('prompt', { model: 'claude-sonnet-4-20250514' }); + + const args = spawnSpy.mock.calls[0][1] as string[]; + expect(args).toContain('--model'); + expect(args).toContain('claude-sonnet-4-20250514'); + }); + + it('does not include --model when model is empty', async () => { + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('claude 1.0.0'), + spawn: spawnSpy, + })); + const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + await callClaudeLLM('prompt', {}); + + const args = spawnSpy.mock.calls[0][1] as string[]; + expect(args).not.toContain('--model'); + }); + + it('sends full prompt (system + separator + user) via stdin', async () => { + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('claude 1.0.0'), + spawn: spawnSpy, + })); + const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + await callClaudeLLM('user prompt', {}, 'system prompt'); + + const stdinText = fakeChild.getStdin(); + expect(stdinText).toBe('system prompt\n\n---\n\nuser prompt'); + }); + + it('sends only user prompt when no system prompt', async () => { + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('claude 1.0.0'), + spawn: spawnSpy, + })); + const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + await callClaudeLLM('just the user prompt', {}); + + expect(fakeChild.getStdin()).toBe('just the user prompt'); + }); + + it('sets CI=1 and windowsHide=true in spawn options', async () => { + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('claude 1.0.0'), + spawn: spawnSpy, + })); + const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + await callClaudeLLM('prompt', {}); + + const spawnOpts = spawnSpy.mock.calls[0][2]; + expect(spawnOpts.env.CI).toBe('1'); + expect(spawnOpts.windowsHide).toBe(true); + }); + + it('rejects with exit code and stderr on non-zero exit', async () => { + fakeChild = makeFakeChild({ exitCode: 1, stderr: 'auth required' }); + spawnSpy = vi.fn(() => fakeChild.child); + + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('claude 1.0.0'), + spawn: spawnSpy, + })); + const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + await expect(callClaudeLLM('prompt', {})).rejects.toThrow( + 'claude CLI exited with code 1: auth required', + ); + }); + + it('rejects with actionable error on empty stdout', async () => { + fakeChild = makeFakeChild({ stdout: '' }); + spawnSpy = vi.fn(() => fakeChild.child); + + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('claude 1.0.0'), + spawn: spawnSpy, + })); + const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + await expect(callClaudeLLM('prompt', {})).rejects.toThrow('claude CLI returned empty output'); + }); +}); + +// ─── Codex CLI argv contract ────────────────────────────────────────── + +describe('Codex CLI subprocess contract', () => { + let spawnSpy: ReturnType; + let fakeChild: ReturnType; + + beforeEach(() => { + vi.resetModules(); + fakeChild = makeFakeChild({ stdout: 'codex response' }); + spawnSpy = vi.fn(() => fakeChild.child); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('passes correct subcommand and flags: exec --sandbox read-only -c approval_policy', async () => { + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('codex 0.1.0'), + spawn: spawnSpy, + })); + const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + await callCodexLLM('prompt', { workingDirectory: '/repo' }); + + const args = spawnSpy.mock.calls[0][1] as string[]; + expect(args).toContain('exec'); + expect(args).toContain('--sandbox'); + expect(args).toContain('read-only'); + expect(args).toContain('-c'); + expect(args).toContain('approval_policy="never"'); + expect(args).toContain('--color'); + expect(args).toContain('never'); + }); + + it('includes --output-last-message with a temp file path', async () => { + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('codex 0.1.0'), + spawn: spawnSpy, + })); + const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + await callCodexLLM('prompt', { workingDirectory: '/repo' }); + + const args = spawnSpy.mock.calls[0][1] as string[]; + const outputIdx = args.indexOf('--output-last-message'); + expect(outputIdx).toBeGreaterThan(-1); + const outputPath = args[outputIdx + 1]; + expect(outputPath).toContain('gitnexus-wiki-codex-'); + expect(outputPath).toContain('last-message.txt'); + }); + + it('passes --cd with the working directory', async () => { + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('codex 0.1.0'), + spawn: spawnSpy, + })); + const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + await callCodexLLM('prompt', { workingDirectory: '/my/repo' }); + + const args = spawnSpy.mock.calls[0][1] as string[]; + const cdIdx = args.indexOf('--cd'); + expect(cdIdx).toBeGreaterThan(-1); + expect(args[cdIdx + 1]).toBe('/my/repo'); + }); + + it('ends args with - (stdin marker)', async () => { + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('codex 0.1.0'), + spawn: spawnSpy, + })); + const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + await callCodexLLM('prompt', { workingDirectory: '/repo' }); + + const args = spawnSpy.mock.calls[0][1] as string[]; + expect(args[args.length - 1]).toBe('-'); + }); + + it('sends full prompt via stdin', async () => { + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('codex 0.1.0'), + spawn: spawnSpy, + })); + const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + await callCodexLLM('user msg', { workingDirectory: '/repo' }, 'sys msg'); + + expect(fakeChild.getStdin()).toBe('sys msg\n\n---\n\nuser msg'); + }); + + it('appends --model only when set', async () => { + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('codex 0.1.0'), + spawn: spawnSpy, + })); + const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + await callCodexLLM('prompt', { workingDirectory: '/repo', model: 'o3-pro' }); + + const args = spawnSpy.mock.calls[0][1] as string[]; + expect(args).toContain('--model'); + expect(args).toContain('o3-pro'); + const modelIdx = args.indexOf('--model'); + const stdinIdx = args.indexOf('-'); + expect(modelIdx).toBeLessThan(stdinIdx); + }); +}); + +// ─── Timeout behavior ───────────────────────────────────────────────── + +describe('local CLI timeout', () => { + beforeEach(() => { + vi.resetModules(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('kills child process after requestTimeoutMs and rejects with timeout error', async () => { + const child = new EventEmitter() as any; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = new EventEmitter() as any; + child.pid = 99; + child.kill = vi.fn(); + child.stdin.end = vi.fn(); + + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('claude 1.0.0'), + spawn: vi.fn(() => child), + })); + const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + const promise = callClaudeLLM('prompt', { requestTimeoutMs: 5000 }); + + vi.advanceTimersByTime(5000); + child.emit('close', null); + await expect(promise).rejects.toThrow('claude CLI timed out after 5s'); + }); + + it('uses taskkill /T /F /PID on Windows for process-tree kill', async () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + + try { + const child = new EventEmitter() as any; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = new EventEmitter() as any; + child.pid = 42; + child.kill = vi.fn(); + child.stdin.end = vi.fn(); + + const execFileSyncSpy = vi.fn().mockImplementation((cmd: string, args: string[]) => { + if (cmd !== 'taskkill') return 'claude 1.0.0'; + return ''; + }); + + vi.doMock('child_process', () => ({ + execFileSync: execFileSyncSpy, + spawn: vi.fn(() => child), + })); + const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + const promise = callClaudeLLM('prompt', { requestTimeoutMs: 3000 }); + vi.advanceTimersByTime(3000); + // Timeout fires, taskkill runs, but child hasn't emitted close yet. + // Emit close now to settle the promise. + child.emit('close', null); + await expect(promise).rejects.toThrow('claude CLI timed out after 3s'); + + const taskkillCalls = execFileSyncSpy.mock.calls.filter( + (c: unknown[]) => c[0] === 'taskkill', + ); + expect(taskkillCalls.length).toBe(1); + expect(taskkillCalls[0][1]).toEqual(['/T', '/F', '/PID', '42']); + expect(child.kill).not.toHaveBeenCalled(); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + } + }); + + it('falls back to child.kill() when taskkill fails on Windows', async () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + + try { + const child = new EventEmitter() as any; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = new EventEmitter() as any; + child.pid = 42; + child.kill = vi.fn(); + child.stdin.end = vi.fn(); + + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockImplementation((cmd: string) => { + if (cmd === 'taskkill') throw new Error('taskkill: process not found'); + return 'claude 1.0.0'; + }), + spawn: vi.fn(() => child), + })); + const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + const promise = callClaudeLLM('prompt', { requestTimeoutMs: 2000 }); + vi.advanceTimersByTime(2000); + child.emit('close', null); + await expect(promise).rejects.toThrow('claude CLI timed out after 2s'); + expect(child.kill).toHaveBeenCalled(); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + } + }); + + it('does not set a kill timer when requestTimeoutMs is undefined', async () => { + const child = new EventEmitter() as any; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = new EventEmitter() as any; + child.pid = 99; + child.kill = vi.fn(); + child.stdin.end = vi.fn(() => { + queueMicrotask(() => { + child.stdout.emit('data', Buffer.from('response')); + child.emit('close', 0); + }); + }); + + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('claude 1.0.0'), + spawn: vi.fn(() => child), + })); + const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + const response = await callClaudeLLM('prompt', {}); + expect(response.content).toBe('response'); + expect(child.kill).not.toHaveBeenCalled(); + }); +}); + +// ─── Codex output file fallback ─────────────────────────────────────── + +describe('Codex output file fallback', () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('uses stdout when output file is missing', async () => { + const fakeChild = makeFakeChild({ stdout: 'stdout content' }); + + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('codex 0.1.0'), + spawn: vi.fn(() => fakeChild.child), + })); + const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + const result = await callCodexLLM('prompt', { workingDirectory: '/repo' }); + expect(result.content).toBe('stdout content'); + }); + + it('rejects when both stdout and output file are empty', async () => { + const fakeChild = makeFakeChild({ stdout: '' }); + + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('codex 0.1.0'), + spawn: vi.fn(() => fakeChild.child), + })); + const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + await expect(callCodexLLM('prompt', { workingDirectory: '/repo' })).rejects.toThrow( + 'codex CLI returned empty output', + ); + }); +}); + +// ─── detectLocalCLI diagnostics ─────────────────────────────────────── + +describe('detectLocalCLI diagnostics', () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns null and warns when CLI exists but --version fails (non-ENOENT)', async () => { + const warnSpy = vi.fn(); + vi.doMock('../../src/core/logger.js', () => ({ + logger: { info: vi.fn(), warn: warnSpy }, + })); + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockImplementation(() => { + const err = new Error('exit code 1') as any; + err.status = 1; + throw err; + }), + spawn: vi.fn(), + })); + + const { detectLocalCLI } = await import('../../src/core/wiki/local-cli-client.js'); + + const result = detectLocalCLI('claude'); + expect(result).toBeNull(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('--version failed')); + }); + + it('returns null silently when CLI is truly not found (ENOENT)', async () => { + const warnSpy = vi.fn(); + vi.doMock('../../src/core/logger.js', () => ({ + logger: { info: vi.fn(), warn: warnSpy }, + })); + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockImplementation(() => { + const err = new Error('ENOENT') as any; + err.code = 'ENOENT'; + throw err; + }), + spawn: vi.fn(), + })); + + const { detectLocalCLI } = await import('../../src/core/wiki/local-cli-client.js'); + + const result = detectLocalCLI('claude'); + expect(result).toBeNull(); + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); + +// ─── onChunk progress callback ──────────────────────────────────────── + +describe('local CLI onChunk callback', () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('fires onChunk with cumulative stdout byte count', async () => { + const child = new EventEmitter() as any; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = new EventEmitter() as any; + child.pid = 1; + child.stdin.end = vi.fn(() => { + queueMicrotask(() => { + child.stdout.emit('data', Buffer.from('chunk1')); + child.stdout.emit('data', Buffer.from('chunk2')); + child.emit('close', 0); + }); + }); + + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('claude 1.0.0'), + spawn: vi.fn(() => child), + })); + const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + const chunks: number[] = []; + await callClaudeLLM('prompt', {}, undefined, { onChunk: (n) => chunks.push(n) }); + + expect(chunks).toEqual([6, 12]); + }); +}); + +// ─── Codex CLI flag contract snapshot ───────────────────────────────── + +describe('Codex CLI flag contract snapshot', () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('spawn args match the exact expected contract (flag rename = test failure)', async () => { + const fakeChild = makeFakeChild({ stdout: 'codex output' }); + const spawnSpy = vi.fn(() => fakeChild.child); + + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('codex 0.1.0'), + spawn: spawnSpy, + })); + const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + await callCodexLLM('prompt', { workingDirectory: '/repo', model: 'o3' }); + + const args = spawnSpy.mock.calls[0][1] as string[]; + + // The contract flags start at 'exec' — skip any platform argsPrefix + // (e.g., ['/d', '/s', '/c', 'codex'] on Windows cmd.exe fallback) + const execIdx = args.indexOf('exec'); + expect(execIdx).toBeGreaterThanOrEqual(0); + const contractArgs = args.slice(execIdx); + + // Strip the dynamic temp path for comparison + const outputMsgIdx = contractArgs.indexOf('--output-last-message'); + const normalized = [...contractArgs]; + if (outputMsgIdx !== -1) { + normalized[outputMsgIdx + 1] = ''; + } + + expect(normalized).toEqual([ + 'exec', + '--cd', + '/repo', + '--sandbox', + 'read-only', + '-c', + 'approval_policy="never"', + '--color', + 'never', + '--output-last-message', + '', + '--model', + 'o3', + '-', + ]); + }); + + it('--model appears before - (stdin marker) and after --output-last-message', async () => { + const fakeChild = makeFakeChild({ stdout: 'codex output' }); + const spawnSpy = vi.fn(() => fakeChild.child); + + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('codex 0.1.0'), + spawn: spawnSpy, + })); + const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + await callCodexLLM('prompt', { workingDirectory: '/repo', model: 'test-model' }); + + const args = spawnSpy.mock.calls[0][1] as string[]; + const outputIdx = args.indexOf('--output-last-message'); + const modelIdx = args.indexOf('--model'); + const stdinIdx = args.lastIndexOf('-'); + + expect(outputIdx).toBeLessThan(modelIdx); + expect(modelIdx).toBeLessThan(stdinIdx); + expect(args[args.length - 1]).toBe('-'); + }); +}); diff --git a/gitnexus/test/unit/rate-limit.test.ts b/gitnexus/test/unit/rate-limit.test.ts index e7b6ada81..6a6d83a25 100644 --- a/gitnexus/test/unit/rate-limit.test.ts +++ b/gitnexus/test/unit/rate-limit.test.ts @@ -256,6 +256,11 @@ describe('production routes — rate-limit middleware wiring', () => { expect(apiSource).toMatch(/app\.get\('\/api\/health',\s*\(_req,\s*res\)\s*=>/); }); + it('does not register a bare wildcard OPTIONS route under Express 5', () => { + expect(apiSource).not.toContain("app.options('*'"); + expect(apiSource).not.toMatch(/app\.options\(\s*'\/\*'/); + }); + it('createServer wires trust proxy to loopback/linklocal/uniquelocal', () => { expect(apiSource).toMatch( /app\.set\(\s*'trust proxy'\s*,\s*'loopback,\s*linklocal,\s*uniquelocal'\s*\)/, diff --git a/gitnexus/test/unit/setup-antigravity.test.ts b/gitnexus/test/unit/setup-antigravity.test.ts new file mode 100644 index 000000000..9a7c6c7dc --- /dev/null +++ b/gitnexus/test/unit/setup-antigravity.test.ts @@ -0,0 +1,479 @@ +/** + * Regression Tests: Antigravity setup + hook adapter + * + * Covers: + * - setupAntigravity: detection of ~/.gemini/antigravity, MCP write, preserve + * existing keys, corrupt-file handling, skips when not installed. + * - installAntigravityHooks: writes ~/.gemini/settings.json with an + * AfterTool entry under the canonical Gemini CLI / Antigravity 2.0 layout + * (https://geminicli.com/docs/hooks/reference/); copies the adapter and + * lock helpers to ~/.gemini/config/hooks/gitnexus/; idempotent across + * re-runs; preserves existing user hooks ("polite neighbor"). + * - installAntigravitySkills: lays out skills under ~/.gemini/antigravity/skills/. + * - hook adapter: AfterTool emits `{hookSpecificOutput.additionalContext}` + * with graph context after search-like tools; emits a stale-index hint + * after a successful `git commit/merge/rebase/cherry-pick/pull`; ignores + * unrelated tools silently. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { spawnSync } from 'child_process'; +import { createRequire } from 'module'; + +const PKG_VERSION = (createRequire(import.meta.url)('../../package.json') as { version: string }) + .version; +const NPX_REF = `gitnexus@${PKG_VERSION}`; + +// vi.hoisted lets the mock factory below (which is hoisted by Vitest) see +// these vi.fn instances. Plain top-level consts would be unreachable at +// hoist time, hence the error this pattern avoids. +const mocks = vi.hoisted(() => ({ + execFileMock: vi.fn((...args: any[]) => { + const callback = args.at(-1); + if (typeof callback === 'function') callback(null, '', ''); + }), + execFileSyncMock: vi.fn(() => { + throw new Error('not found'); + }), +})); + +vi.mock('child_process', async () => { + // Partial mock: real spawnSync is needed for the hook-adapter tests below + // to actually invoke the .cjs script as a child process. + const actual = await vi.importActual('child_process'); + return { + ...actual, + execFile: mocks.execFileMock, + execFileSync: mocks.execFileSyncMock, + }; +}); + +describe('setupAntigravity', () => { + 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-antigravity-setup-')); + process.env.HOME = tempHome; + process.env.USERPROFILE = tempHome; + + // Only create ~/.gemini/antigravity — no other editor dirs so their + // setup branches skip and don't pollute assertions. + await fs.mkdir(path.join(tempHome, '.gemini', 'antigravity'), { recursive: true }); + + platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + // Default to a non-win32 platform so the MCP entry shape is deterministic + // across CI runners. Tests that need win32 behavior override this. + setPlatform('darwin'); + + 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('writes MCP config to ~/.gemini/antigravity/mcp_config.json', async () => { + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + const raw = await fs.readFile( + path.join(tempHome, '.gemini', 'antigravity', 'mcp_config.json'), + 'utf-8', + ); + const config = JSON.parse(raw); + + expect(config.mcpServers.gitnexus).toEqual({ + command: 'npx', + args: ['-y', NPX_REF, 'mcp'], + }); + }); + + it('writes win32 MCP entry with cmd wrapper', async () => { + setPlatform('win32'); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + const raw = await fs.readFile( + path.join(tempHome, '.gemini', 'antigravity', 'mcp_config.json'), + 'utf-8', + ); + const config = JSON.parse(raw); + + expect(config.mcpServers.gitnexus).toEqual({ + command: 'cmd', + args: ['/c', 'npx', '-y', NPX_REF, 'mcp'], + }); + }); + + it('skips when ~/.gemini/antigravity does not exist', async () => { + await fs.rm(path.join(tempHome, '.gemini'), { recursive: true, force: true }); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + await expect( + fs.access(path.join(tempHome, '.gemini', 'antigravity', 'mcp_config.json')), + ).rejects.toThrow(); + await expect(fs.access(path.join(tempHome, '.gemini', 'settings.json'))).rejects.toThrow(); + }); + + it('preserves existing keys in mcp_config.json', async () => { + const mcpPath = path.join(tempHome, '.gemini', 'antigravity', 'mcp_config.json'); + await fs.writeFile( + mcpPath, + JSON.stringify({ existingKey: 'keep-me', mcpServers: { other: { command: 'foo' } } }), + 'utf-8', + ); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + const raw = await fs.readFile(mcpPath, 'utf-8'); + const config = JSON.parse(raw); + + expect(config.existingKey).toBe('keep-me'); + expect(config.mcpServers.other).toEqual({ command: 'foo' }); + expect(config.mcpServers.gitnexus).toBeDefined(); + }); + + it('leaves a corrupt mcp_config.json untouched', async () => { + const mcpPath = path.join(tempHome, '.gemini', 'antigravity', 'mcp_config.json'); + const corrupt = '{ definitely not json !!!'; + await fs.writeFile(mcpPath, corrupt, 'utf-8'); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + const raw = await fs.readFile(mcpPath, 'utf-8'); + expect(raw).toBe(corrupt); + }); + + it('writes ~/.gemini/settings.json with an AfterTool entry under hooks', async () => { + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + const raw = await fs.readFile(path.join(tempHome, '.gemini', 'settings.json'), 'utf-8'); + const config = JSON.parse(raw); + + expect(config.hooks.AfterTool).toBeInstanceOf(Array); + expect(config.hooks.AfterTool[0].matcher).toBe('search_file_content|glob|run_shell_command'); + expect(config.hooks.AfterTool[0].hooks[0].command).toMatch(/gitnexus-antigravity-hook\.cjs/); + expect(config.hooks.AfterTool[0].hooks[0].timeout).toBe(10000); + expect(config.hooks.AfterTool[0].hooks[0].name).toBe('gitnexus'); + }); + + it('is idempotent — re-running setup does not duplicate hook entries', async () => { + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + await setupCommand(); + + const raw = await fs.readFile(path.join(tempHome, '.gemini', 'settings.json'), 'utf-8'); + const config = JSON.parse(raw); + + expect(config.hooks.AfterTool).toHaveLength(1); + }); + + it('preserves existing user hooks in settings.json (polite-neighbor merge)', async () => { + const settingsPath = path.join(tempHome, '.gemini', 'settings.json'); + await fs.writeFile( + settingsPath, + JSON.stringify({ + theme: 'dark', + hooks: { + AfterTool: [ + { + matcher: 'write_file', + hooks: [{ type: 'command', command: 'echo "user-hook"', name: 'user-formatter' }], + }, + ], + }, + }), + 'utf-8', + ); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + const raw = await fs.readFile(settingsPath, 'utf-8'); + const config = JSON.parse(raw); + + // Unrelated keys preserved + expect(config.theme).toBe('dark'); + + // User's hook still present + expect(config.hooks.AfterTool).toHaveLength(2); + expect(config.hooks.AfterTool[0].hooks[0].command).toBe('echo "user-hook"'); + // Our entry appended after, not replacing + expect(config.hooks.AfterTool[1].hooks[0].command).toMatch(/gitnexus-antigravity-hook\.cjs/); + }); + + it('copies adapter + lock helpers to ~/.gemini/config/hooks/gitnexus/', async () => { + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + const destDir = path.join(tempHome, '.gemini', 'config', 'hooks', 'gitnexus'); + await expect( + fs.access(path.join(destDir, 'gitnexus-antigravity-hook.cjs')), + ).resolves.toBeUndefined(); + await expect(fs.access(path.join(destDir, 'hook-lock.cjs'))).resolves.toBeUndefined(); + await expect(fs.access(path.join(destDir, 'hook-db-lock-probe.cjs'))).resolves.toBeUndefined(); + // Required by hook-db-lock-probe.cjs on Windows; without it the MCP + // server ownership probe silently fails open. + await expect(fs.access(path.join(destDir, 'win-rm-list-json.ps1'))).resolves.toBeUndefined(); + }); + + it('installs skills under ~/.gemini/antigravity/skills//SKILL.md', async () => { + // Stage a fixture skills tree so the assertion does not depend on + // installSkillsTo's __dirname resolution (which is brittle under + // Vitest on Windows). Production reads the real gitnexus/skills/ dir. + const fixtureSkillsRoot = path.join(tempHome, 'fixture-skills'); + await fs.mkdir(fixtureSkillsRoot, { recursive: true }); + await fs.writeFile( + path.join(fixtureSkillsRoot, 'gitnexus-test.md'), + '---\nname: gitnexus-test\ndescription: fixture\n---\nbody\n', + 'utf-8', + ); + process.env.GITNEXUS_TEST_SKILLS_ROOT = fixtureSkillsRoot; + + try { + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + const skillsDir = path.join(tempHome, '.gemini', 'antigravity', 'skills'); + const entries = await fs.readdir(skillsDir, { withFileTypes: true }); + const skillDirs = entries.filter((e) => e.isDirectory()).map((e) => e.name); + + expect(skillDirs).toContain('gitnexus-test'); + await expect( + fs.access(path.join(skillsDir, 'gitnexus-test', 'SKILL.md')), + ).resolves.toBeUndefined(); + } finally { + delete process.env.GITNEXUS_TEST_SKILLS_ROOT; + } + }); +}); + +// ─── Hook adapter smoke tests ────────────────────────────────────── +// +// The adapter relies on sibling helpers (hook-lock.cjs, hook-db-lock-probe.cjs). +// For tests we lay out a self-contained copy in a temp dir and spawn it. + +const PROJECT_ROOT = path.resolve(__dirname, '..', '..'); +const ADAPTER_SRC = path.join( + PROJECT_ROOT, + 'hooks', + 'antigravity', + 'gitnexus-antigravity-hook.cjs', +); +const LOCK_SRC = path.join(PROJECT_ROOT, 'hooks', 'claude', 'hook-lock.cjs'); +const PROBE_SRC = path.join(PROJECT_ROOT, 'hooks', 'claude', 'hook-db-lock-probe.cjs'); +const WIN_RM_SRC = path.join(PROJECT_ROOT, 'hooks', 'claude', 'win-rm-list-json.ps1'); + +async function stageAdapter(): Promise { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-antigravity-adapter-')); + await fs.copyFile(ADAPTER_SRC, path.join(tmp, 'gitnexus-antigravity-hook.cjs')); + await fs.copyFile(LOCK_SRC, path.join(tmp, 'hook-lock.cjs')); + await fs.copyFile(PROBE_SRC, path.join(tmp, 'hook-db-lock-probe.cjs')); + // hook-db-lock-probe.cjs loads this PowerShell script on Windows; without it, + // the lock probe silently fails open and the adapter's Windows DB-lock path + // would be untested in child-process smoke tests. + await fs.copyFile(WIN_RM_SRC, path.join(tmp, 'win-rm-list-json.ps1')); + return path.join(tmp, 'gitnexus-antigravity-hook.cjs'); +} + +function runAdapter( + hookPath: string, + input: Record, + cwd?: string, +): { stdout: string; stderr: string; status: number | null } { + const result = spawnSync(process.execPath, [hookPath], { + input: JSON.stringify(input), + encoding: 'utf-8', + timeout: 10000, + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + }); + return { stdout: result.stdout || '', stderr: result.stderr || '', status: result.status }; +} + +describe('gitnexus-antigravity-hook adapter', () => { + let adapter: string; + let workdir: string; + + beforeEach(async () => { + adapter = await stageAdapter(); + workdir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-antigravity-work-')); + }); + + afterEach(async () => { + await fs.rm(path.dirname(adapter), { recursive: true, force: true }); + await fs.rm(workdir, { recursive: true, force: true }); + }); + + it('AfterTool with no .gitnexus/ produces no stdout', async () => { + const { stdout } = runAdapter( + adapter, + { + hook_event_name: 'AfterTool', + tool_name: 'search_file_content', + tool_input: { pattern: 'someSymbol' }, + tool_response: { llmContent: '...' }, + cwd: workdir, + }, + workdir, + ); + expect(stdout.trim()).toBe(''); + }); + + it('AfterTool ignores unrelated tools silently', async () => { + const { stdout, stderr } = runAdapter( + adapter, + { + hook_event_name: 'AfterTool', + tool_name: 'read_file', + tool_input: { path: 'README.md' }, + tool_response: { llmContent: '...' }, + cwd: workdir, + }, + workdir, + ); + expect(stdout.trim()).toBe(''); + expect(stderr).not.toMatch(/\[GitNexus\]/); + }); + + it('AfterTool ignores non-git run_shell_command silently', async () => { + const gnDir = path.join(workdir, '.gitnexus'); + await fs.mkdir(gnDir, { recursive: true }); + await fs.writeFile( + path.join(gnDir, 'meta.json'), + JSON.stringify({ lastCommit: 'deadbeef', stats: {} }), + 'utf-8', + ); + + const { stdout, stderr } = runAdapter( + adapter, + { + hook_event_name: 'AfterTool', + tool_name: 'run_shell_command', + tool_input: { command: 'npm test' }, + tool_response: { llmContent: '...' }, + cwd: workdir, + }, + workdir, + ); + expect(stdout.trim()).toBe(''); + expect(stderr).not.toMatch(/\[GitNexus\]/); + }); + + it('AfterTool emits stale-index hint after a successful git commit', async () => { + // Initialize a git repo and a stale .gitnexus/meta.json. + spawnSync('git', ['init', '-q'], { cwd: workdir }); + spawnSync('git', ['config', 'user.email', 'test@example.com'], { cwd: workdir }); + spawnSync('git', ['config', 'user.name', 'Test'], { cwd: workdir }); + await fs.writeFile(path.join(workdir, 'a.txt'), 'hello', 'utf-8'); + spawnSync('git', ['add', '.'], { cwd: workdir }); + spawnSync('git', ['commit', '-q', '-m', 'init'], { cwd: workdir }); + + const gnDir = path.join(workdir, '.gitnexus'); + await fs.mkdir(gnDir, { recursive: true }); + await fs.writeFile( + path.join(gnDir, 'meta.json'), + JSON.stringify({ lastCommit: '0000000000000000000000000000000000000000', stats: {} }), + 'utf-8', + ); + + const { stdout, stderr } = runAdapter( + adapter, + { + hook_event_name: 'AfterTool', + tool_name: 'run_shell_command', + tool_input: { command: 'git commit -m "x"' }, + tool_response: { llmContent: '[committed]' }, + cwd: workdir, + }, + workdir, + ); + + // Hint surfaces both via the agent-visible channel and stderr (terminal). + expect(stderr).toMatch(/\[GitNexus\] index is stale/); + expect(stderr).toMatch(/gitnexus analyze/); + + const parsed = JSON.parse(stdout); + expect(parsed.hookSpecificOutput.hookEventName).toBe('AfterTool'); + expect(parsed.hookSpecificOutput.additionalContext).toMatch(/index is stale/); + }); + + it('AfterTool skips augment when the tool failed', async () => { + const gnDir = path.join(workdir, '.gitnexus'); + await fs.mkdir(gnDir, { recursive: true }); + await fs.writeFile( + path.join(gnDir, 'meta.json'), + JSON.stringify({ lastCommit: 'deadbeef', stats: {} }), + 'utf-8', + ); + + const { stdout } = runAdapter( + adapter, + { + hook_event_name: 'AfterTool', + tool_name: 'search_file_content', + tool_input: { pattern: 'someSymbol' }, + tool_response: { error: 'boom' }, + cwd: workdir, + }, + workdir, + ); + expect(stdout.trim()).toBe(''); + }); + + it('ignores unknown tool names without crashing', async () => { + const { status } = runAdapter( + adapter, + { + hook_event_name: 'AfterTool', + tool_name: 'unknown_tool', + tool_input: {}, + tool_response: { llmContent: '' }, + cwd: workdir, + }, + workdir, + ); + expect(status).toBe(0); + }); + + it('does not crash on empty stdin', () => { + const result = spawnSync(process.execPath, [adapter], { + input: '', + encoding: 'utf-8', + timeout: 10000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + expect(result.status).toBe(0); + }); +}); diff --git a/gitnexus/test/unit/wiki-flags.test.ts b/gitnexus/test/unit/wiki-flags.test.ts index 1ee4a2b6c..47d929bc4 100644 --- a/gitnexus/test/unit/wiki-flags.test.ts +++ b/gitnexus/test/unit/wiki-flags.test.ts @@ -1,10 +1,11 @@ /** - * Unit tests for wiki CLI flags: --provider cursor, --review, --verbose + * Unit tests for wiki CLI flags: --provider cursor/claude/codex, --review, --verbose * * Tests the new wiki provider infrastructure without requiring an actual - * Cursor CLI binary or LLM API key. All external dependencies are mocked. + * local agent CLI binary or LLM API key. All external dependencies are mocked. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EventEmitter } from 'events'; import os from 'os'; import path from 'path'; import fs from 'fs/promises'; @@ -81,6 +82,51 @@ describe('resolveCursorConfig', () => { }); }); +// ─── local agent CLI detection ─────────────────────────────────────── + +describe('detectLocalCLI', () => { + let execFileSyncSpy: ReturnType; + + beforeEach(() => { + vi.resetModules(); + execFileSyncSpy = vi.fn(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('detects and caches Claude CLI', async () => { + vi.doMock('child_process', () => ({ + execFileSync: execFileSyncSpy, + spawn: vi.fn(), + })); + const { detectLocalCLI } = await import('../../src/core/wiki/local-cli-client.js'); + + execFileSyncSpy.mockImplementation(() => 'claude 1.0.0'); + + expect(detectLocalCLI('claude')).toBe('claude'); + const callsAfterFirstDetection = execFileSyncSpy.mock.calls.length; + expect(detectLocalCLI('claude')).toBe('claude'); + expect(execFileSyncSpy).toHaveBeenCalledTimes(callsAfterFirstDetection); + }); + + it('caches null when Codex CLI is not found', async () => { + vi.doMock('child_process', () => ({ + execFileSync: execFileSyncSpy.mockImplementation(() => { + throw new Error('not found'); + }), + spawn: vi.fn(), + })); + const { detectLocalCLI } = await import('../../src/core/wiki/local-cli-client.js'); + + expect(detectLocalCLI('codex')).toBeNull(); + const callsAfterFirstDetection = execFileSyncSpy.mock.calls.length; + expect(detectLocalCLI('codex')).toBeNull(); + expect(execFileSyncSpy).toHaveBeenCalledTimes(callsAfterFirstDetection); + }); +}); + // ─── resolveLLMConfig provider routing ─────────────────────────────── describe('resolveLLMConfig', () => { @@ -116,6 +162,51 @@ describe('resolveLLMConfig', () => { expect(config.model).toBe('claude-4.5-opus-high'); }); + it('uses claudeModel when provider is claude', async () => { + vi.doMock('../../src/storage/repo-manager.js', () => ({ + loadCLIConfig: vi.fn().mockResolvedValue({ + provider: 'claude', + claudeModel: 'claude-sonnet-4-6', + }), + })); + + const { resolveLLMConfig } = await import('../../src/core/wiki/llm-client.js'); + const config = await resolveLLMConfig({ provider: 'claude' }); + + expect(config.provider).toBe('claude'); + expect(config.model).toBe('claude-sonnet-4-6'); + }); + + it('uses codexModel when provider is codex', async () => { + vi.doMock('../../src/storage/repo-manager.js', () => ({ + loadCLIConfig: vi.fn().mockResolvedValue({ + provider: 'codex', + codexModel: 'gpt-5.4', + }), + })); + + const { resolveLLMConfig } = await import('../../src/core/wiki/llm-client.js'); + const config = await resolveLLMConfig({ provider: 'codex' }); + + expect(config.provider).toBe('codex'); + expect(config.model).toBe('gpt-5.4'); + }); + + it('does not inherit HTTP model defaults for local CLI providers', async () => { + vi.doMock('../../src/storage/repo-manager.js', () => ({ + loadCLIConfig: vi.fn().mockResolvedValue({ + provider: 'openai', + model: 'minimax/minimax-m2.5', + }), + })); + + const { resolveLLMConfig } = await import('../../src/core/wiki/llm-client.js'); + const config = await resolveLLMConfig({ provider: 'claude' }); + + expect(config.provider).toBe('claude'); + expect(config.model).toBe(''); + }); + it('uses default OpenRouter model for openai provider', async () => { vi.doMock('../../src/storage/repo-manager.js', () => ({ loadCLIConfig: vi.fn().mockResolvedValue({}), @@ -720,11 +811,15 @@ describe('WikiGenerator invokeLLM routing', () => { it('routes to callCursorLLM when provider is cursor', async () => { const cursorClient = await import('../../src/core/wiki/cursor-client.js'); + const localClient = await import('../../src/core/wiki/local-cli-client.js'); const llmClient = await import('../../src/core/wiki/llm-client.js'); const cursorSpy = vi .spyOn(cursorClient, 'callCursorLLM') .mockResolvedValue({ content: 'cursor response' }); + const claudeSpy = vi + .spyOn(localClient, 'callClaudeLLM') + .mockResolvedValue({ content: 'claude response' }); const openaiSpy = vi .spyOn(llmClient, 'callLLM') .mockResolvedValue({ content: 'openai response' }); @@ -751,17 +846,112 @@ describe('WikiGenerator invokeLLM routing', () => { const result = await (generator as any).invokeLLM('test prompt', 'system prompt'); expect(cursorSpy).toHaveBeenCalledTimes(1); + expect(claudeSpy).not.toHaveBeenCalled(); expect(openaiSpy).not.toHaveBeenCalled(); expect(result.content).toBe('cursor response'); }); - it('routes to callLLM when provider is openai', async () => { + it('routes to callClaudeLLM when provider is claude', async () => { const cursorClient = await import('../../src/core/wiki/cursor-client.js'); + const localClient = await import('../../src/core/wiki/local-cli-client.js'); const llmClient = await import('../../src/core/wiki/llm-client.js'); const cursorSpy = vi .spyOn(cursorClient, 'callCursorLLM') .mockResolvedValue({ content: 'cursor response' }); + const claudeSpy = vi + .spyOn(localClient, 'callClaudeLLM') + .mockResolvedValue({ content: 'claude response' }); + const codexSpy = vi + .spyOn(localClient, 'callCodexLLM') + .mockResolvedValue({ content: 'codex response' }); + const openaiSpy = vi + .spyOn(llmClient, 'callLLM') + .mockResolvedValue({ content: 'openai response' }); + + const { WikiGenerator } = await import('../../src/core/wiki/generator.js'); + + const storagePath = path.join(tmpDir, 'storage'); + const wikiDir = path.join(storagePath, 'wiki'); + await fs.mkdir(wikiDir, { recursive: true }); + + const repoPath = path.join(tmpDir, 'repo'); + await fs.mkdir(repoPath, { recursive: true }); + + const generator = new WikiGenerator(repoPath, storagePath, path.join(storagePath, 'lbug'), { + apiKey: '', + baseUrl: '', + model: 'claude-sonnet-4-6', + maxTokens: 1000, + temperature: 0, + provider: 'claude', + }); + + const result = await (generator as any).invokeLLM('test prompt', 'system prompt'); + + expect(claudeSpy).toHaveBeenCalledTimes(1); + expect(codexSpy).not.toHaveBeenCalled(); + expect(cursorSpy).not.toHaveBeenCalled(); + expect(openaiSpy).not.toHaveBeenCalled(); + expect(result.content).toBe('claude response'); + }); + + it('routes to callCodexLLM when provider is codex', async () => { + const cursorClient = await import('../../src/core/wiki/cursor-client.js'); + const localClient = await import('../../src/core/wiki/local-cli-client.js'); + const llmClient = await import('../../src/core/wiki/llm-client.js'); + + const cursorSpy = vi + .spyOn(cursorClient, 'callCursorLLM') + .mockResolvedValue({ content: 'cursor response' }); + const claudeSpy = vi + .spyOn(localClient, 'callClaudeLLM') + .mockResolvedValue({ content: 'claude response' }); + const codexSpy = vi + .spyOn(localClient, 'callCodexLLM') + .mockResolvedValue({ content: 'codex response' }); + const openaiSpy = vi + .spyOn(llmClient, 'callLLM') + .mockResolvedValue({ content: 'openai response' }); + + const { WikiGenerator } = await import('../../src/core/wiki/generator.js'); + + const storagePath = path.join(tmpDir, 'storage'); + const wikiDir = path.join(storagePath, 'wiki'); + await fs.mkdir(wikiDir, { recursive: true }); + + const repoPath = path.join(tmpDir, 'repo'); + await fs.mkdir(repoPath, { recursive: true }); + + const generator = new WikiGenerator(repoPath, storagePath, path.join(storagePath, 'lbug'), { + apiKey: '', + baseUrl: '', + model: 'gpt-5.4', + maxTokens: 1000, + temperature: 0, + provider: 'codex', + }); + + const result = await (generator as any).invokeLLM('test prompt', 'system prompt'); + + expect(codexSpy).toHaveBeenCalledTimes(1); + expect(claudeSpy).not.toHaveBeenCalled(); + expect(cursorSpy).not.toHaveBeenCalled(); + expect(openaiSpy).not.toHaveBeenCalled(); + expect(result.content).toBe('codex response'); + }); + + it('routes to callLLM when provider is openai', async () => { + const cursorClient = await import('../../src/core/wiki/cursor-client.js'); + const localClient = await import('../../src/core/wiki/local-cli-client.js'); + const llmClient = await import('../../src/core/wiki/llm-client.js'); + + const cursorSpy = vi + .spyOn(cursorClient, 'callCursorLLM') + .mockResolvedValue({ content: 'cursor response' }); + const codexSpy = vi + .spyOn(localClient, 'callCodexLLM') + .mockResolvedValue({ content: 'codex response' }); const openaiSpy = vi .spyOn(llmClient, 'callLLM') .mockResolvedValue({ content: 'openai response' }); @@ -788,6 +978,7 @@ describe('WikiGenerator invokeLLM routing', () => { expect(openaiSpy).toHaveBeenCalledTimes(1); expect(cursorSpy).not.toHaveBeenCalled(); + expect(codexSpy).not.toHaveBeenCalled(); expect(result.content).toBe('openai response'); }); }); @@ -817,6 +1008,98 @@ describe('callCursorLLM', () => { }); }); +// ─── local CLI errors when binaries are not found ──────────────────── + +describe('local agent CLI calls', () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('throws when Claude CLI is not in PATH', async () => { + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockImplementation(() => { + throw new Error('not found'); + }), + spawn: vi.fn(), + })); + + const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + await expect(callClaudeLLM('hello', {})).rejects.toThrow('Claude CLI not found'); + }); + + it('throws when Codex CLI is not in PATH', async () => { + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockImplementation(() => { + throw new Error('not found'); + }), + spawn: vi.fn(), + })); + + const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + await expect(callCodexLLM('hello', {})).rejects.toThrow('Codex CLI not found'); + }); + + it('uses Codex config overrides instead of removed approval flags', async () => { + const child = new EventEmitter() as any; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = new EventEmitter() as any; + child.stdin.end = vi.fn(() => { + queueMicrotask(() => { + child.stdout.emit('data', Buffer.from('codex response')); + child.emit('close', 0); + }); + }); + + const spawnSpy = vi.fn(() => child); + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('codex-cli 0.132.0'), + spawn: spawnSpy, + })); + + const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + const response = await callCodexLLM('hello', { workingDirectory: process.cwd() }); + + expect(response.content).toBe('codex response'); + const args = spawnSpy.mock.calls[0][1] as string[]; + expect(args).toContain('-c'); + expect(args).toContain('approval_policy="never"'); + expect(args).not.toContain('--ask-for-approval'); + }); + + it('reports Codex stderr when the process closes before stdin is fully written', async () => { + const child = new EventEmitter() as any; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = new EventEmitter() as any; + child.stdin.end = vi.fn(() => { + queueMicrotask(() => { + child.stderr.emit('data', Buffer.from("error: unexpected argument '--old-flag' found")); + child.stdin.emit('error', new Error('write EOF')); + child.emit('close', 2); + }); + }); + + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('codex-cli 0.132.0'), + spawn: vi.fn(() => child), + })); + + const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + await expect(callCodexLLM('hello', { workingDirectory: process.cwd() })).rejects.toThrow( + "codex CLI exited with code 2: error: unexpected argument '--old-flag' found", + ); + }); +}); + // ─── estimateTokens ───────────────────────────────────────────────── describe('estimateTokens', () => {