diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5f6ccf404..8ab8342d6 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -48,7 +48,7 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 with: languages: ${{ matrix.language }} queries: security-and-quality @@ -69,6 +69,6 @@ jobs: - '**/test/fixtures/**' - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 with: category: '/language:${{ matrix.language }}' diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 5d564db79..5c24cfa29 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -53,6 +53,6 @@ jobs: retention-days: 5 - name: Upload to Security tab - uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 with: sarif_file: results.sarif diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index 6ac2c4d08..76f995c2a 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -76,7 +76,7 @@ jobs: exit-code: '0' - name: Upload to Security tab - uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 with: sarif_file: trivy-${{ matrix.image.name }}.sarif category: trivy-${{ matrix.image.name }} diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml index 42effcdf7..8b121b7c1 100644 --- a/.github/workflows/workflow-lint.yml +++ b/.github/workflows/workflow-lint.yml @@ -76,7 +76,7 @@ jobs: continue-on-error: true - name: Upload SARIF - uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 with: sarif_file: zizmor.sarif category: zizmor diff --git a/README.md b/README.md index 44f56edd0..39b09b7d2 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 @@ -639,7 +658,7 @@ UPSTREAM (what depends on this): authRouter [IMPORTS] -> src/routes/auth.ts ``` -Options: `maxDepth`, `minConfidence`, `relationTypes` (`CALLS`, `IMPORTS`, `EXTENDS`, `IMPLEMENTS`), `includeTests` +Options: `maxDepth`, `minConfidence`, `relationTypes` (`CALLS`, `IMPORTS`, `EXTENDS`, `IMPLEMENTS`), `includeTests`, `limit` (max symbols per depth, default 100), `offset` (pagination start per depth), `summaryOnly` (counts and risk only, omits symbol list) ### Process-Grouped Search diff --git a/gitnexus-shared/src/scope-resolution/symbol-definition.ts b/gitnexus-shared/src/scope-resolution/symbol-definition.ts index e27605ac4..d1b30abcc 100644 --- a/gitnexus-shared/src/scope-resolution/symbol-definition.ts +++ b/gitnexus-shared/src/scope-resolution/symbol-definition.ts @@ -53,6 +53,10 @@ export interface SymbolDefinition { * `ScopeResolver.constraintCompatibility` hook during overload narrowing. * Absent for symbols that have no constraints (the common case). */ templateConstraints?: unknown; + /** True when the producing language marked this callable as explicit. + * Currently used by C++ overload ranking to exclude explicit constructors + * from implicit user-defined conversion candidates. */ + isExplicit?: boolean; /** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */ ownerId?: string; } diff --git a/gitnexus-web/e2e/tree-view.spec.ts b/gitnexus-web/e2e/tree-view.spec.ts new file mode 100644 index 000000000..61c964d5b --- /dev/null +++ b/gitnexus-web/e2e/tree-view.spec.ts @@ -0,0 +1,117 @@ +import { test, expect } from '@playwright/test'; + +/** + * E2E tests for graph layout mode switching (Sequential / Radial layouts). + * + * Requires: + * - gitnexus serve running on localhost:4747 with at least one indexed repo + * - gitnexus-web dev server running on localhost:5173 + * + * Skipped when servers aren't available (CI without services, etc.). + * Set E2E=1 to force-run even without the availability check. + */ + +const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:4747'; +const FRONTEND_URL = process.env.FRONTEND_URL ?? 'http://localhost:5173'; + +test.beforeAll(async () => { + if (process.env.E2E) return; + try { + const [backendRes, frontendRes] = await Promise.allSettled([ + fetch(`${BACKEND_URL}/api/repos`), + fetch(FRONTEND_URL), + ]); + if ( + backendRes.status === 'rejected' || + (backendRes.status === 'fulfilled' && !backendRes.value.ok) + ) { + test.skip(true, 'gitnexus serve not available on :4747'); + return; + } + if ( + frontendRes.status === 'rejected' || + (frontendRes.status === 'fulfilled' && !frontendRes.value.ok) + ) { + test.skip(true, 'Vite dev server not available on :5173'); + return; + } + if (backendRes.status === 'fulfilled') { + const repos = await backendRes.value.json(); + if (!repos.length) { + test.skip(true, 'No indexed repos — run gitnexus analyze first'); + return; + } + } + } catch { + test.skip(true, 'servers not available'); + } +}); + +async function waitForGraphLoaded(page: import('@playwright/test').Page) { + await page.goto(`${FRONTEND_URL}?lng=en`); + + // The app starts on the landing/onboarding screen. Pick the first repo card + // (preferring a known repo name) and click it to load the graph. + const landingCards = page.locator('[data-testid="landing-repo-card"]'); + const preferredCard = landingCards.filter({ hasText: /GitNexus|local-integration/ }).first(); + try { + await landingCards.first().waitFor({ state: 'visible', timeout: 15_000 }); + const card = (await preferredCard.count()) > 0 ? preferredCard : landingCards.first(); + await card.click(); + } catch { + // Landing screen may not appear (e.g. when ?server auto-connects) + } + + // Wait until the status bar confirms the graph is ready. + const statusBar = page.getByRole('contentinfo'); + await expect(statusBar.getByText('Ready', { exact: true })).toBeVisible({ timeout: 45_000 }); + await expect(statusBar).toContainText(/nodes/, { timeout: 20_000 }); + + // Finally confirm the sigma canvas is present. + await page.waitForSelector('.sigma-container', { timeout: 10_000 }); +} + +test.describe('Graph Layout Modes', () => { + test.beforeEach(async ({ page }) => { + await waitForGraphLoaded(page); + }); + + test('should switch between force, sequential, and radial layouts', async ({ page }) => { + const forceTab = page.locator('button:has-text("Force Graph")'); + const sequentialTab = page.locator('button:has-text("Sequential Layout")'); + const radialTab = page.locator('button:has-text("Radial Layout")'); + + // Force Graph is the default active tab + await expect(forceTab).toHaveClass(/bg-accent/); + await expect(sequentialTab).not.toHaveClass(/bg-accent/); + + // Switch to Sequential Layout + await sequentialTab.click(); + await expect(sequentialTab).toHaveClass(/bg-accent/, { timeout: 5_000 }); + await expect(forceTab).not.toHaveClass(/bg-accent/); + + // All three layout tabs should be present in the tab bar + await expect(radialTab).toBeVisible(); + + // Switch back to Force Graph + await forceTab.click(); + await expect(forceTab).toHaveClass(/bg-accent/, { timeout: 5_000 }); + await expect(sequentialTab).not.toHaveClass(/bg-accent/); + }); + + test('should interact with nodes in sequential layout', async ({ page }) => { + await page.locator('button:has-text("Sequential Layout")').click(); + + // Click the first file-tree item in the sidebar (more reliable than a + // blind canvas click, which may land on empty space). The FileTreePanel + // renders node names as . + // Clicking any of them calls setSelectedNode, which shows the selection + // bar with the "Clear" button — the same mechanism used in + // server-connect.spec.ts's "Turn Off All Highlights" test. + const firstTreeItem = page.locator('span.truncate.font-mono').first(); + await firstTreeItem.waitFor({ state: 'visible', timeout: 10_000 }); + await firstTreeItem.click(); + + await expect(page.locator('text=Clear')).toBeVisible({ timeout: 5_000 }); + }); +}); diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index f31a7ef5e..9b6d9ae53 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -11,12 +11,12 @@ "@langchain/anthropic": "^1.3.29", "@langchain/core": "^1.1.44", "@langchain/google-genai": "^2.1.30", - "@langchain/langgraph": "^1.2.9", + "@langchain/langgraph": "^1.3.2", "@langchain/ollama": "^1.2.6", "@langchain/openai": "^1.4.5", "@sigma/edge-curve": "^3.1.0", "@tailwindcss/vite": "^4.3.0", - "axios": "^1.16.0", + "axios": "^1.16.1", "d3": "^7.9.0", "dompurify": "^3.4.3", "gitnexus-shared": "file:../gitnexus-shared", @@ -53,7 +53,7 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/dompurify": "^3.2.0", - "@types/node": "^25.6.0", + "@types/node": "^25.9.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/react-syntax-highlighter": "^15.5.13", @@ -1393,13 +1393,14 @@ } }, "node_modules/@langchain/langgraph": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.2.9.tgz", - "integrity": "sha512-3c7BtGycHC2v9p6w/Hv8L7kEl1YnZYOQTDJtmAp3knk6JOedO7d2bYP3y0SRyhv5orUEGf/KGvx8ZsB/ideP7g==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.3.2.tgz", + "integrity": "sha512-SL7Ktsr681R7da+1b2MVOWEbaCoFJOXEJPTGOjg4JIG4C7quWbTYC8DzxhcCxte6D/8cGp0rYDBnbKLXEpNqlA==", "license": "MIT", "dependencies": { - "@langchain/langgraph-checkpoint": "^1.0.1", - "@langchain/langgraph-sdk": "~1.8.9", + "@langchain/langgraph-checkpoint": "^1.0.2", + "@langchain/langgraph-sdk": "~1.9.4", + "@langchain/protocol": "^0.0.15", "@standard-schema/spec": "1.1.0", "uuid": "^10.0.0" }, @@ -1407,7 +1408,7 @@ "node": ">=18" }, "peerDependencies": { - "@langchain/core": "^1.1.40", + "@langchain/core": "^1.1.44", "zod": "^3.25.32 || ^4.2.0", "zod-to-json-schema": "^3.x" }, @@ -1418,9 +1419,9 @@ } }, "node_modules/@langchain/langgraph-checkpoint": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.0.1.tgz", - "integrity": "sha512-HM0cJLRpIsSlWBQ/xuDC67l52SqZ62Bh2Y61DX+Xorqwoh5e1KxYvfCD7GnSTbWWhjBOutvnR0vPhu4orFkZfw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.0.2.tgz", + "integrity": "sha512-F4E5Tr0nt8FGghgdscJtHw+ABzChOHeI80R7Y1pjIHdiJom6c2ieo76vL+FWiny80JmoGqhrVAEIWrw0cXKPxg==", "license": "MIT", "dependencies": { "uuid": "^10.0.0" @@ -1429,7 +1430,7 @@ "node": ">=18" }, "peerDependencies": { - "@langchain/core": "^1.0.1" + "@langchain/core": "^1.1.44" } }, "node_modules/@langchain/langgraph-checkpoint/node_modules/uuid": { @@ -1446,27 +1447,25 @@ } }, "node_modules/@langchain/langgraph-sdk": { - "version": "1.8.10", - "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.8.10.tgz", - "integrity": "sha512-wrB3rkRw5KAmsqezwvKP3midT4qJrV6Hj9XJMYo+cbvXC4HYpSAmyY/VriSyeTFRbLG/OP/pY2Yz+9Z54nSaXQ==", + "version": "1.9.9", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.9.9.tgz", + "integrity": "sha512-aiWHbmqxWj5sAMwFsaB3eSGQvKpMbUKTlt9zbAC0T7IiFqDYUWi9gJUGsTdvJutAfB3P/NzC4s8ETUtUQEUlYg==", "license": "MIT", "dependencies": { + "@langchain/protocol": "^0.0.15", "@types/json-schema": "^7.0.15", "p-queue": "^9.0.1", "p-retry": "^7.1.1", "uuid": "^13.0.0" }, "peerDependencies": { - "@langchain/core": "^1.1.16", + "@langchain/core": "^1.1.44", "react": "^18 || ^19", "react-dom": "^18 || ^19", "svelte": "^4.0.0 || ^5.0.0", "vue": "^3.0.0" }, "peerDependenciesMeta": { - "@langchain/core": { - "optional": true - }, "react": { "optional": true }, @@ -1488,9 +1487,9 @@ "license": "MIT" }, "node_modules/@langchain/langgraph-sdk/node_modules/p-queue": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.2.0.tgz", - "integrity": "sha512-dWgLE8AH0HjQ9fe74pUkKkvzzYT18Inp4zra3lKHnnwqGvcfcUBrvF2EAVX+envufDNBOzpPq/IBUONDbI7+3g==", + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.0.tgz", + "integrity": "sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang==", "license": "MIT", "dependencies": { "eventemitter3": "^5.0.4", @@ -1516,9 +1515,9 @@ } }, "node_modules/@langchain/langgraph-sdk/node_modules/uuid": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.1.tgz", - "integrity": "sha512-9ezox2roIft6ExBVTVqibSd5dc5/47Sw/uY6b4SjQUT2TzQ0tltNquWA46y4xPQmdZYqvnio22SgWd41M86+jw==", + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", + "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -1587,6 +1586,12 @@ "@langchain/core": "^1.1.42" } }, + "node_modules/@langchain/protocol": { + "version": "0.0.15", + "resolved": "https://registry.npmjs.org/@langchain/protocol/-/protocol-0.0.15.tgz", + "integrity": "sha512-MllvbpMjqHevUm+v94M422mH7XKN+wGCvJRBVROTWBotEDOATYB4Ktk2UheYP859y9o2LlhtPek5t1T9eyfAbQ==", + "license": "MIT" + }, "node_modules/@mapbox/node-pre-gyp": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-2.0.3.tgz", @@ -2854,13 +2859,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.6.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", - "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", "devOptional": true, "license": "MIT", "dependencies": { - "undici-types": "~7.19.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@types/prismjs": { @@ -3414,16 +3419,42 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", - "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -5444,9 +5475,9 @@ } }, "node_modules/is-network-error": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.1.tgz", - "integrity": "sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.2.tgz", + "integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==", "license": "MIT", "engines": { "node": ">=16" @@ -8567,9 +8598,9 @@ } }, "node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "devOptional": true, "license": "MIT" }, diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index 318624e3e..d059c6d57 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -21,12 +21,12 @@ "@langchain/anthropic": "^1.3.29", "@langchain/core": "^1.1.44", "@langchain/google-genai": "^2.1.30", - "@langchain/langgraph": "^1.2.9", + "@langchain/langgraph": "^1.3.2", "@langchain/ollama": "^1.2.6", "@langchain/openai": "^1.4.5", "@sigma/edge-curve": "^3.1.0", "@tailwindcss/vite": "^4.3.0", - "axios": "^1.16.0", + "axios": "^1.16.1", "d3": "^7.9.0", "dompurify": "^3.4.3", "gitnexus-shared": "file:../gitnexus-shared", @@ -63,7 +63,7 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/dompurify": "^3.2.0", - "@types/node": "^25.6.0", + "@types/node": "^25.9.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/react-syntax-highlighter": "^15.5.13", diff --git a/gitnexus-web/src/components/FileTreePanel.tsx b/gitnexus-web/src/components/FileTreePanel.tsx index 6bab30c6f..a3c7487a9 100644 --- a/gitnexus-web/src/components/FileTreePanel.tsx +++ b/gitnexus-web/src/components/FileTreePanel.tsx @@ -201,7 +201,10 @@ const getNodeTypeIcon = (label: NodeLabel) => { case 'Import': return FileCode; case 'Variable': + case 'Property': return Variable; + case 'Const': + return Target; default: return Variable; } diff --git a/gitnexus-web/src/components/GraphCanvas.tsx b/gitnexus-web/src/components/GraphCanvas.tsx index cdf00c3bb..d0880dbe1 100644 --- a/gitnexus-web/src/components/GraphCanvas.tsx +++ b/gitnexus-web/src/components/GraphCanvas.tsx @@ -9,11 +9,16 @@ import { Pause, Lightbulb, LightbulbOff, + Network, + GitBranch, + Target, } from '@/lib/lucide-icons'; import { useSigma } from '../hooks/useSigma'; import { useAppState } from '../hooks/useAppState'; import { knowledgeGraphToGraphology, + knowledgeGraphToTreeGraphology, + knowledgeGraphToCirclesGraphology, filterGraphByDepth, SigmaNodeAttributes, SigmaEdgeAttributes, @@ -48,6 +53,8 @@ export const GraphCanvas = forwardRef((_, ref) => { clearAICitationHighlights, clearBlastRadius, animatedNodes, + graphViewMode, + setGraphViewMode, } = useAppState(); const [hoveredNodeName, setHoveredNodeName] = useState(null); @@ -149,8 +156,22 @@ export const GraphCanvas = forwardRef((_, ref) => { blastRadiusNodeIds: effectiveBlastRadiusNodeIds, animatedNodes: effectiveAnimatedNodes, visibleEdgeTypes, + layoutMode: graphViewMode, }); + const handleViewModeChange = useCallback( + (mode: 'force' | 'tree' | 'circles') => { + if (mode === graphViewMode) return; + setSelectedNode(null); + setSigmaSelectedNode(null); + setHoveredNodeName(null); + setGraphViewMode(mode); + // Reset zoom when switching views + resetZoom(); + }, + [graphViewMode, resetZoom, setGraphViewMode, setSelectedNode, setSigmaSelectedNode], + ); + // Expose focusNode to parent via ref useImperativeHandle( ref, @@ -174,25 +195,30 @@ export const GraphCanvas = forwardRef((_, ref) => { useEffect(() => { if (!graph) return; - // Build communityMemberships map from MEMBER_OF relationships - // MEMBER_OF edges: nodeId -> communityId (stored as targetId) - const communityMemberships = new Map(); - graph.relationships.forEach((rel) => { - if (rel.type === 'MEMBER_OF') { - // Find the community node to get its index - const communityNode = nodeById.get(rel.targetId); - if (communityNode && communityNode.label === 'Community') { - // Extract community index from id (e.g., "comm_5" -> 5) - const numericPart = rel.targetId.replace('comm_', ''); - const communityIdx = /^\d+$/.test(numericPart) ? parseInt(numericPart, 10) : 0; - communityMemberships.set(rel.sourceId, communityIdx); - } - } - }); + let sigmaGraph: Graph; + + if (graphViewMode === 'tree') { + sigmaGraph = knowledgeGraphToTreeGraphology(graph); + } else if (graphViewMode === 'circles') { + sigmaGraph = knowledgeGraphToCirclesGraphology(graph); + } else { + // Build community memberships map from MEMBER_OF relationships + const communityMemberships = new Map(); + graph.relationships.forEach((rel) => { + if (rel.type === 'MEMBER_OF') { + const communityNode = nodeById.get(rel.targetId); + if (communityNode && communityNode.label === 'Community') { + const numericPart = rel.targetId.replace('comm_', ''); + const communityIdx = /^\d+$/.test(numericPart) ? parseInt(numericPart, 10) : 0; + communityMemberships.set(rel.sourceId, communityIdx); + } + } + }); + sigmaGraph = knowledgeGraphToGraphology(graph, communityMemberships); + } - const sigmaGraph = knowledgeGraphToGraphology(graph, communityMemberships); setSigmaGraph(sigmaGraph); - }, [graph, nodeById, setSigmaGraph]); + }, [graph, nodeById, setSigmaGraph, graphViewMode]); // Update node visibility when filters change useEffect(() => { @@ -205,7 +231,7 @@ export const GraphCanvas = forwardRef((_, ref) => { filterGraphByDepth(sigmaGraph, appSelectedNode?.id || null, depthFilter, visibleLabels); sigma.refresh(); // eslint-disable-next-line react-hooks/exhaustive-deps -- sigmaRef identity never changes - }, [visibleLabels, depthFilter, appSelectedNode]); + }, [graph, graphViewMode, visibleLabels, depthFilter, appSelectedNode]); // Sync app selected node with sigma useEffect(() => { @@ -245,6 +271,53 @@ export const GraphCanvas = forwardRef((_, ref) => { /> + {/* View Mode Tabs */} +
+ + + +
+ {/* Sigma container */}
{ bgColor: 'bg-rose-500/10', borderColor: 'border-rose-500/30', }; + case 'stopped': + return { + icon: , + color: 'text-amber-300', + bgColor: 'bg-amber-500/10', + borderColor: 'border-amber-500/30', + }; default: return { icon: , diff --git a/gitnexus-web/src/core/llm/agent.ts b/gitnexus-web/src/core/llm/agent.ts index 5153e35fb..4f79d1c95 100644 --- a/gitnexus-web/src/core/llm/agent.ts +++ b/gitnexus-web/src/core/llm/agent.ts @@ -363,8 +363,17 @@ export type AgentMessage = { role: 'user'; content: string } | AgentHistoryMessa export interface AgentRuntimeOptions { /** Capture assistant/tool messages for providers that require exact transcript replay. */ captureHistory?: boolean; + /** When aborted (e.g. user clicked Stop), the stream ends with a `cancelled` chunk. */ + signal?: AbortSignal; } +const isAbortError = (error: unknown, signal?: AbortSignal): boolean => { + if (error instanceof DOMException && error.name === 'AbortError') return true; + if (error instanceof Error && error.name === 'AbortError') return true; + if (signal?.aborted) return true; + return false; +}; + export const buildLangChainMessages = (messages: AgentMessage[]): BaseMessage[] => messages.map((message) => { if (message.role === 'user') { @@ -438,6 +447,7 @@ export async function* streamAgentResponse( streamMode: ['values', 'messages'] as any, // Allow longer tool/reasoning loops (more Cursor-like persistence) recursionLimit: 50, + signal: options.signal, } as any); // Track what we've yielded to avoid duplicates @@ -455,6 +465,10 @@ export async function* streamAgentResponse( let lastStepMessages: any[] | null = null; for await (const event of stream) { + if (options.signal?.aborted) { + break; + } + // Events come as [streamMode, data] tuples when using multiple modes // or just data when using single mode let mode: string; @@ -511,10 +525,11 @@ export async function* streamAgentResponse( // - After all tools are done: treat as final content const isReasoning = !hasSeenToolCallThisTurn || toolCalls.length > 0 || pendingToolCalls > 0; - yield { - type: isReasoning ? 'reasoning' : 'content', - [isReasoning ? 'reasoning' : 'content']: content, - }; + if (isReasoning) { + yield { type: 'reasoning', reasoning: content }; + } else { + yield { type: 'content', content }; + } } // Track tool calls from message chunks @@ -627,6 +642,11 @@ export async function* streamAgentResponse( } } + if (options.signal?.aborted) { + yield { type: 'cancelled' }; + return; + } + // DEBUG: Stream completed normally if (import.meta.env.DEV) { console.log('✅ Stream completed normally, yielding done'); @@ -640,6 +660,10 @@ export async function* streamAgentResponse( : undefined, }; } catch (error) { + if (isAbortError(error, options.signal)) { + yield { type: 'cancelled' }; + return; + } const message = error instanceof Error ? error.message : String(error); // DEBUG: Stream error if (import.meta.env.DEV) { diff --git a/gitnexus-web/src/core/llm/types.ts b/gitnexus-web/src/core/llm/types.ts index c568c7d93..b7727da10 100644 --- a/gitnexus-web/src/core/llm/types.ts +++ b/gitnexus-web/src/core/llm/types.ts @@ -254,7 +254,7 @@ export interface ToolCallInfo { name: string; args: Record; result?: string; - status: 'pending' | 'running' | 'completed' | 'error'; + status: 'pending' | 'running' | 'completed' | 'error' | 'stopped'; } /** @@ -286,22 +286,17 @@ export type AgentHistoryMessage = }; /** - * Streaming chunk from agent - * Now supports step-based streaming where each step is a distinct message + * Streaming chunk from agent (discriminated union). + * Each variant carries only its relevant fields, enabling exhaustive switch handling. */ -export interface AgentStreamChunk { - type: 'reasoning' | 'tool_call' | 'tool_result' | 'content' | 'error' | 'done'; - /** LLM's reasoning/thinking text (shown as a step) */ - reasoning?: string; - /** Final answer content (streamed token by token) */ - content?: string; - /** Hidden raw transcript for reconstructing future agent turns */ - historyMessages?: AgentHistoryMessage[]; - /** Tool call information */ - toolCall?: ToolCallInfo; - /** Error message */ - error?: string; -} +export type AgentStreamChunk = + | { type: 'reasoning'; reasoning: string } + | { type: 'tool_call'; toolCall: ToolCallInfo } + | { type: 'tool_result'; toolCall: ToolCallInfo } + | { type: 'content'; content: string } + | { type: 'error'; error: string } + | { type: 'done'; historyMessages?: AgentHistoryMessage[] } + | { type: 'cancelled' }; /** * A single step in the agent's execution diff --git a/gitnexus-web/src/hooks/app-state/graph.test.tsx b/gitnexus-web/src/hooks/app-state/graph.test.tsx new file mode 100644 index 000000000..12d946ee2 --- /dev/null +++ b/gitnexus-web/src/hooks/app-state/graph.test.tsx @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { GraphStateProvider, useGraphState } from './graph'; + +function wrapper({ children }: { children: React.ReactNode }) { + return {children}; +} + +describe('GraphState', () => { + it('should have default graphViewMode as "force"', () => { + const { result } = renderHook(() => useGraphState(), { wrapper }); + expect(result.current.graphViewMode).toBe('force'); + }); + + it('should toggle graphViewMode', () => { + const { result } = renderHook(() => useGraphState(), { wrapper }); + act(() => { + result.current.setGraphViewMode('tree'); + }); + expect(result.current.graphViewMode).toBe('tree'); + }); +}); diff --git a/gitnexus-web/src/hooks/app-state/graph.tsx b/gitnexus-web/src/hooks/app-state/graph.tsx index 4aa41db01..aa6524a5f 100644 --- a/gitnexus-web/src/hooks/app-state/graph.tsx +++ b/gitnexus-web/src/hooks/app-state/graph.tsx @@ -16,6 +16,8 @@ interface GraphStateContextValue { setDepthFilter: (depth: number | null) => void; highlightedNodeIds: Set; setHighlightedNodeIds: (ids: Set) => void; + graphViewMode: 'force' | 'tree' | 'circles'; + setGraphViewMode: (mode: 'force' | 'tree' | 'circles') => void; } const GraphStateContext = createContext(null); @@ -27,6 +29,7 @@ export const GraphStateProvider = ({ children }: { children: ReactNode }) => { const [visibleEdgeTypes, setVisibleEdgeTypes] = useState(DEFAULT_VISIBLE_EDGES); const [depthFilter, setDepthFilter] = useState(null); const [highlightedNodeIds, setHighlightedNodeIds] = useState>(new Set()); + const [graphViewMode, setGraphViewMode] = useState<'force' | 'tree' | 'circles'>('force'); const toggleLabelVisibility = useCallback((label: NodeLabel) => { setVisibleLabels((prev) => @@ -54,8 +57,18 @@ export const GraphStateProvider = ({ children }: { children: ReactNode }) => { setDepthFilter, highlightedNodeIds, setHighlightedNodeIds, + graphViewMode, + setGraphViewMode, }), - [graph, selectedNode, visibleLabels, visibleEdgeTypes, depthFilter, highlightedNodeIds], + [ + graph, + selectedNode, + visibleLabels, + visibleEdgeTypes, + depthFilter, + highlightedNodeIds, + graphViewMode, + ], ); return {children}; diff --git a/gitnexus-web/src/hooks/useAppState.tsx b/gitnexus-web/src/hooks/useAppState.tsx index 5a7e85457..5c118450b 100644 --- a/gitnexus-web/src/hooks/useAppState.tsx +++ b/gitnexus-web/src/hooks/useAppState.tsx @@ -123,6 +123,10 @@ interface AppState { depthFilter: number | null; setDepthFilter: (depth: number | null) => void; + // Graph view mode + graphViewMode: 'force' | 'tree' | 'circles'; + setGraphViewMode: (mode: 'force' | 'tree' | 'circles') => void; + // Query state highlightedNodeIds: Set; setHighlightedNodeIds: (ids: Set) => void; @@ -232,6 +236,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { setDepthFilter, highlightedNodeIds, setHighlightedNodeIds, + graphViewMode, + setGraphViewMode, } = useGraphState(); // Right Panel @@ -582,6 +588,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { // Agent state — agent runs on main thread now (I/O-bound, not CPU-bound) const agentRef = useRef(null); + const chatAbortRef = useRef(null); + const chatStateRef = useRef<'idle' | 'streaming' | 'aborting'>('idle'); const initializeAgent = useCallback( async (overrideProjectName?: string): Promise => { @@ -640,7 +648,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { const sendChatMessage = useCallback( async (message: string): Promise => { - if (isChatLoading) return; + if (chatStateRef.current !== 'idle') return; // Refresh Code panel for the new question: keep user-pinned refs, clear old AI citations clearAICodeReferences(); @@ -679,8 +687,13 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { } setIsChatLoading(true); + chatStateRef.current = 'streaming'; setCurrentToolCalls([]); + chatAbortRef.current?.abort(); + const chatAbortController = new AbortController(); + chatAbortRef.current = chatAbortController; + const providerCapabilities = getProviderCapabilities(llmSettings.activeProvider); // Prepare message history for agent (convert our format to AgentMessage format) @@ -736,11 +749,13 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { }); }; let pendingUpdate = false; + let rafHandle: number | null = null; const scheduleMessageUpdate = () => { if (pendingUpdate) return; pendingUpdate = true; - requestAnimationFrame(() => { + rafHandle = requestAnimationFrame(() => { pendingUpdate = false; + rafHandle = null; updateMessage(); }); }; @@ -887,7 +902,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { if (idx < 0) { idx = toolCallsForMessage.findIndex((t) => t.name === tc.name && !t.result); } - if (idx >= 0) { + if (idx >= 0 && toolCallsForMessage[idx].status !== 'stopped') { toolCallsForMessage[idx] = { ...toolCallsForMessage[idx], result: tc.result, @@ -903,7 +918,11 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { (s.toolCall.id === tc.id || (s.toolCall.name === tc.name && s.toolCall.status === 'running')), ); - if (stepIdx >= 0 && stepsForMessage[stepIdx].toolCall) { + if ( + stepIdx >= 0 && + stepsForMessage[stepIdx].toolCall && + stepsForMessage[stepIdx].toolCall!.status !== 'stopped' + ) { stepsForMessage[stepIdx] = { ...stepsForMessage[stepIdx], toolCall: { @@ -924,6 +943,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { targetIdx = prev.findIndex((t) => t.name === tc.name && !t.result); } if (targetIdx >= 0) { + const target = prev[targetIdx]; + if (target.status === 'stopped') return prev; return prev.map((t, i) => i === targetIdx ? { ...t, result: tc.result, status: 'completed' } : t, ); @@ -1022,13 +1043,24 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { const { streamAgentResponse } = await import('../core/llm/agent'); for await (const chunk of streamAgentResponse(agent, history, { captureHistory: providerCapabilities.preserveAssistantTranscript, + signal: chatAbortController.signal, })) { + if (chunk.type === 'cancelled') { + break; + } onChunk(chunk); } } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setAgentError(message); + if (!chatAbortController.signal.aborted) { + const message = error instanceof Error ? error.message : String(error); + setAgentError(message); + } } finally { + if (rafHandle != null) { + cancelAnimationFrame(rafHandle); + rafHandle = null; + } + chatStateRef.current = 'idle'; setIsChatLoading(false); setCurrentToolCalls([]); } @@ -1044,22 +1076,56 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { clearAIToolHighlights, graph, embeddingStatus, - isChatLoading, ], ); const stopChatResponse = useCallback(() => { - if (isChatLoading) { - // Agent streaming will be interrupted by the AbortController in sendChatMessage - setIsChatLoading(false); - setCurrentToolCalls([]); - } - }, [isChatLoading]); + if (!chatAbortRef.current) return; + + chatStateRef.current = 'aborting'; + chatAbortRef.current.abort(); + chatAbortRef.current = null; + + const stoppedLabel = i18n.t('chat:stopped'); + const markStoppedToolCall = (tc: ToolCallInfo): ToolCallInfo => + tc.status === 'running' || tc.status === 'pending' + ? { ...tc, status: 'stopped', result: stoppedLabel } + : tc; + + setCurrentToolCalls((prev) => prev.map(markStoppedToolCall)); + + setChatMessages((prev) => { + const lastAssistantIdx = [...prev] + .map((m, i) => (m.role === 'assistant' ? i : -1)) + .filter((i) => i >= 0) + .pop(); + if (lastAssistantIdx === undefined) return prev; + + const message = prev[lastAssistantIdx]; + + const updated: ChatMessage = { + ...message, + toolCalls: message.toolCalls?.map(markStoppedToolCall), + steps: message.steps?.map((step) => + step.type === 'tool_call' && step.toolCall + ? { ...step, toolCall: markStoppedToolCall(step.toolCall) } + : step, + ), + }; + return prev.map((m, i) => (i === lastAssistantIdx ? updated : m)); + }); + + setIsChatLoading(false); + }, []); const clearChat = useCallback(() => { + chatAbortRef.current?.abort(); + chatAbortRef.current = null; + chatStateRef.current = 'idle'; setChatMessages([]); setCurrentToolCalls([]); setAgentError(null); + setIsChatLoading(false); }, []); // Switch to a different repo on the connected server @@ -1266,6 +1332,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { toggleEdgeVisibility, depthFilter, setDepthFilter, + graphViewMode, + setGraphViewMode, highlightedNodeIds, setHighlightedNodeIds, aiCitationHighlightedNodeIds, diff --git a/gitnexus-web/src/hooks/useSigma.ts b/gitnexus-web/src/hooks/useSigma.ts index 461de0f5a..a2121dad4 100644 --- a/gitnexus-web/src/hooks/useSigma.ts +++ b/gitnexus-web/src/hooks/useSigma.ts @@ -62,6 +62,7 @@ interface UseSigmaOptions { blastRadiusNodeIds?: Set; animatedNodes?: Map; visibleEdgeTypes?: EdgeType[]; + layoutMode?: 'force' | 'tree' | 'circles'; } interface UseSigmaReturn { @@ -128,6 +129,105 @@ const getLayoutDuration = (nodeCount: number): number => { return 20000; // 20s for small graphs }; +const TREE_MAX_X = 540; +const TREE_REPULSION_RANGE = 130; +const TREE_LAYOUT_MAX_DURATION = 18000; +const TREE_LAYOUT_STABILITY_FRAMES = 24; +const TREE_TARGET_FRAME_MS = 32; +const TREE_LAYOUT_MIN_DURATION = 1500; +const TREE_FORCE_DEADZONE = 0.005; +const TREE_VELOCITY_DEADZONE = 0.01; +// Y is free within each layer's band; gravity + boundary resistance keep layers separate. +// Band half kept at 55px so nodes don't drift far past the initial camera-fit viewport. +const TREE_LAYER_GRAVITY = 0.06; // stronger gravity keeps nodes near their layer center +const TREE_LAYER_BAND_HALF = 55; // ±55px from layer center Y +const TREE_LAYER_BOUNDARY_RESISTANCE = 10; // progressive resistance near band edges +// Spread force: fine-tune density within each layer during physics. +// Kept deliberately weak (0.003) because the initial proportional layout already +// distributes nodes near their ideal positions — aggressive spread would fight +// the hierarchy springs and push edge-parented children away from their parents. +const TREE_SPREAD_STRENGTH = 0.003; + +// --------------------------------------------------------------------------- +// Circles View constants +// --------------------------------------------------------------------------- + +/** Target radius for each ring — must match CIRCLES_RING_RADII in circles-layout.ts */ +const CIRCLES_RING_RADII = [90, 240, 420, 620] as const; +const CIRCLES_RING_COUNT = CIRCLES_RING_RADII.length; + +/** + * Half-width of the allowed radial band. Must match CIRCLES_BAND_HALF in + * circles-layout.ts. Keep it small enough that adjacent ring bands never + * overlap: current ring gaps are 150/180/200 px, so 45 px leaves 60-110 px + * of clear air between rings. + * + * Nodes distribute within this band driven by repulsion (outward) and + * soft-wall gravity (inward, growing cubically near the edge). + * No hard clamp — nodes float freely inside the band. + */ +const CIRCLES_BAND_HALF = 45; + +/** + * Base radial gravity rate. Effective gravity grows cubically near the band + * edge via CIRCLES_RADIAL_BOUNDARY_RESISTANCE: + * + * rOffset = 0 px → k = k_base × 1 (almost no pull) + * rOffset = 22 px → k ≈ k_base × 4.2 (moderate) + * rOffset = 40 px → k ≈ k_base × 16 (strong) + * rOffset = 45 px → k ≈ k_base × 21 (very strong — prevents crossing) + */ +const CIRCLES_RADIAL_GRAVITY = 0.06; + +/** + * Cubic-growth multiplier near the band edge. + * Effective k = CIRCLES_RADIAL_GRAVITY × (1 + normR³ × this). + */ +const CIRCLES_RADIAL_BOUNDARY_RESISTANCE = 22; + +/** + * Angular spread force: kept very weak — edge springs are the primary + * mechanism for angular positioning. A too-strong spread competes with + * springs and keeps connected nodes far apart. + */ +const CIRCLES_ANGULAR_SPREAD = 0.002; + +/** Repulsion range — same as tree view so nodes from dense rings don't clump. */ +const CIRCLES_REPULSION_RANGE = 130; + +const CIRCLES_LAYOUT_MAX_DURATION = 24000; +const CIRCLES_LAYOUT_STABILITY_FRAMES = 24; +const CIRCLES_LAYOUT_MIN_DURATION = 1500; +const CIRCLES_FORCE_DEADZONE = 0.005; +const CIRCLES_VELOCITY_DEADZONE = 0.01; + +const CIRCLES_EDGE_WEIGHTS: Record = { + // Hierarchy edges: moderate — angular alignment without fighting radial gravity + // (rest length is now set to ring-gap distance, not zero). + CONTAINS: 0.18, + DEFINES: 0.22, + // Cross edges: stronger so same-ring connected nodes cluster angularly. + IMPORTS: 0.2, + CALLS: 0.24, + EXTENDS: 0.2, + IMPLEMENTS: 0.2, +}; + +// --------------------------------------------------------------------------- + +const TREE_EDGE_WEIGHTS: Record = { + CONTAINS: 0.09, + DEFINES: 0.12, + IMPORTS: 0.14, + CALLS: 0.18, + EXTENDS: 0.13, + IMPLEMENTS: 0.13, +}; + +const clamp = (value: number, min: number, max: number): number => { + return Math.min(max, Math.max(min, value)); +}; + export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => { const containerRef = useRef(null); const sigmaRef = useRef(null); @@ -138,8 +238,33 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => { const blastRadiusRef = useRef>(new Set()); const animatedNodesRef = useRef>(new Map()); const visibleEdgeTypesRef = useRef(null); + + // Keep callback refs fresh so the one-time sigma event handlers always + // call the latest version (avoids stale-closure bugs when graph loads). + const onNodeClickRef = useRef(options.onNodeClick); + const onNodeHoverRef = useRef(options.onNodeHover); + const onStageClickRef = useRef(options.onStageClick); + onNodeClickRef.current = options.onNodeClick; + onNodeHoverRef.current = options.onNodeHover; + onStageClickRef.current = options.onStageClick; const layoutTimeoutRef = useRef | null>(null); - const animationFrameRef = useRef(null); + const effectsAnimationFrameRef = useRef(null); + const treeLayoutFrameRef = useRef(null); + const treeVelocityRef = useRef>(new Map()); // vx per node + const treeVelocityYRef = useRef>(new Map()); // vy per node + const treeLastTickRef = useRef(null); + const treeAccumulatorRef = useRef(0); + const treeLayoutStartRef = useRef(null); + const treeStableFramesRef = useRef(0); + + // Circles layout state (mirrors tree layout state) + const circlesLayoutFrameRef = useRef(null); + const circlesVelocityXRef = useRef>(new Map()); + const circlesVelocityYRef = useRef>(new Map()); + const circlesLastTickRef = useRef(null); + const circlesAccumulatorRef = useRef(0); + const circlesLayoutStartRef = useRef(null); + const circlesStableFramesRef = useRef(0); const [isLayoutRunning, setIsLayoutRunning] = useState(false); const [selectedNode, setSelectedNodeState] = useState(null); @@ -159,24 +284,24 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => { // Animation loop for node effects useEffect(() => { if (!options.animatedNodes || options.animatedNodes.size === 0) { - if (animationFrameRef.current) { - cancelAnimationFrame(animationFrameRef.current); - animationFrameRef.current = null; + if (effectsAnimationFrameRef.current) { + cancelAnimationFrame(effectsAnimationFrameRef.current); + effectsAnimationFrameRef.current = null; } return; } const animate = () => { sigmaRef.current?.refresh(); - animationFrameRef.current = requestAnimationFrame(animate); + effectsAnimationFrameRef.current = requestAnimationFrame(animate); }; animate(); return () => { - if (animationFrameRef.current) { - cancelAnimationFrame(animationFrameRef.current); - animationFrameRef.current = null; + if (effectsAnimationFrameRef.current) { + cancelAnimationFrame(effectsAnimationFrameRef.current); + effectsAnimationFrameRef.current = null; } }; }, [options.animatedNodes]); @@ -197,6 +322,74 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => { sigma.refresh(); }, []); + const stopTreeLayout = useCallback((refresh: boolean = false) => { + if (treeLayoutFrameRef.current) { + cancelAnimationFrame(treeLayoutFrameRef.current); + treeLayoutFrameRef.current = null; + } + treeLastTickRef.current = null; + treeAccumulatorRef.current = 0; + treeLayoutStartRef.current = null; + treeStableFramesRef.current = 0; + treeVelocityRef.current.clear(); + treeVelocityYRef.current.clear(); + setIsLayoutRunning(false); + + if (refresh) { + sigmaRef.current?.refresh(); + // Re-fit camera to the actual settled positions — nodes may have drifted + // from their initial anchors during simulation (especially small/leaf nodes). + sigmaRef.current?.getCamera().animatedReset({ duration: 600 }); + } + }, []); + + const stopCirclesLayout = useCallback((refresh: boolean = false) => { + if (circlesLayoutFrameRef.current) { + cancelAnimationFrame(circlesLayoutFrameRef.current); + circlesLayoutFrameRef.current = null; + } + circlesLastTickRef.current = null; + circlesAccumulatorRef.current = 0; + circlesLayoutStartRef.current = null; + circlesStableFramesRef.current = 0; + circlesVelocityXRef.current.clear(); + circlesVelocityYRef.current.clear(); + setIsLayoutRunning(false); + + if (refresh) { + sigmaRef.current?.refresh(); + sigmaRef.current?.getCamera().animatedReset({ duration: 600 }); + } + }, []); + + const stopAllLayouts = useCallback( + (refresh: boolean = false) => { + if (layoutTimeoutRef.current) { + clearTimeout(layoutTimeoutRef.current); + layoutTimeoutRef.current = null; + } + + if (layoutRef.current) { + layoutRef.current.stop(); + layoutRef.current.kill(); + layoutRef.current = null; + + const graph = graphRef.current; + if (graph && options.layoutMode !== 'tree' && options.layoutMode !== 'circles') { + noverlap.assign(graph, NOVERLAP_SETTINGS); + } + } + + stopTreeLayout(false); + stopCirclesLayout(false); + + if (refresh) { + sigmaRef.current?.refresh(); + } + }, + [options.layoutMode, stopTreeLayout, stopCirclesLayout], + ); + // Initialize Sigma ONCE useEffect(() => { if (!containerRef.current) return; @@ -393,15 +586,38 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => { edgeReducer: (edge, data) => { const res = { ...data }; - // Check edge type visibility first + // Check edge type visibility first. + // HAS_METHOD / HAS_PROPERTY are Kotlin/Java hierarchy edges not in the + // EdgeType union — normalize them so they follow DEFINES / CONTAINS + // visibility instead of being silently hidden. const visibleTypes = visibleEdgeTypesRef.current; if (visibleTypes && data.relationType) { - if (!visibleTypes.includes(data.relationType as EdgeType)) { + const normalizedType = + data.relationType === 'HAS_METHOD' + ? 'DEFINES' + : data.relationType === 'HAS_PROPERTY' + ? 'CONTAINS' + : data.relationType; + if (!visibleTypes.includes(normalizedType as EdgeType)) { res.hidden = true; return res; } } + // Tree view: hierarchy edges are subtle, cross-cutting edges are more visible + const isHierarchyEdge = (data as any).isHierarchyEdge; + if (isHierarchyEdge !== undefined) { + if (isHierarchyEdge) { + // Subtle hierarchy edges in tree view + res.color = dimColor(data.color, 0.5); + res.size = Math.max(0.3, (data.size || 1) * 0.5); + } else { + // Cross-cutting edges are more visible + res.color = brightenColor(data.color, 1.2); + res.size = Math.max(1, (data.size || 1) * 1.2); + } + } + const currentSelected = selectedNodeRef.current; const highlighted = highlightedRef.current; const blastRadius = blastRadiusRef.current; @@ -467,29 +683,41 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => { sigma.on('clickNode', ({ node }) => { setSelectedNode(node); - options.onNodeClick?.(node); + onNodeClickRef.current?.(node); }); sigma.on('clickStage', () => { setSelectedNode(null); - options.onStageClick?.(); + onStageClickRef.current?.(); }); sigma.on('enterNode', ({ node }) => { - options.onNodeHover?.(node); + onNodeHoverRef.current?.(node); if (containerRef.current) { containerRef.current.style.cursor = 'pointer'; } }); sigma.on('leaveNode', () => { - options.onNodeHover?.(null); + onNodeHoverRef.current?.(null); if (containerRef.current) { containerRef.current.style.cursor = 'grab'; } }); return () => { + if (treeLayoutFrameRef.current) { + cancelAnimationFrame(treeLayoutFrameRef.current); + treeLayoutFrameRef.current = null; + } + treeVelocityRef.current.clear(); + treeVelocityYRef.current.clear(); + if (circlesLayoutFrameRef.current) { + cancelAnimationFrame(circlesLayoutFrameRef.current); + circlesLayoutFrameRef.current = null; + } + circlesVelocityXRef.current.clear(); + circlesVelocityYRef.current.clear(); if (layoutTimeoutRef.current) { clearTimeout(layoutTimeoutRef.current); } @@ -500,70 +728,779 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => { }; }, []); - // Run ForceAtlas2 layout - const runLayout = useCallback((graph: Graph) => { - const nodeCount = graph.order; - if (nodeCount === 0) return; + const runTreeLayout = useCallback( + (graph: Graph) => { + if (graph.order === 0) return; - // Kill existing - if (layoutRef.current) { - layoutRef.current.kill(); - layoutRef.current = null; - } - if (layoutTimeoutRef.current) { - clearTimeout(layoutTimeoutRef.current); - layoutTimeoutRef.current = null; - } + stopAllLayouts(false); - // Get settings - const inferredSettings = forceAtlas2.inferSettings(graph); - const customSettings = getFA2Settings(nodeCount); - const settings = { ...inferredSettings, ...customSettings }; + // Compute each layer's Y center from initial anchor positions + const layerYSum = new Map(); + const layerYCount = new Map(); - const layout = new FA2Layout(graph, { settings }); + graph.forEachNode((nodeId, attrs) => { + const layer = attrs.treeLayer ?? 0; + const ay = attrs.treeAnchorY ?? attrs.y; + layerYSum.set(layer, (layerYSum.get(layer) ?? 0) + ay); + layerYCount.set(layer, (layerYCount.get(layer) ?? 0) + 1); + treeVelocityRef.current.set(nodeId, 0); + treeVelocityYRef.current.set(nodeId, 0); + graph.setNodeAttribute(nodeId, 'x', attrs.treeAnchorX ?? attrs.x); + graph.setNodeAttribute(nodeId, 'y', ay); + }); - layoutRef.current = layout; - layout.start(); - setIsLayoutRunning(true); + const layerCenterY = new Map(); + for (const [layer, sum] of layerYSum) { + layerCenterY.set(layer, sum / (layerYCount.get(layer) ?? 1)); + } - const duration = getLayoutDuration(nodeCount); + // Compute each node's preferred Y position within its layer band. + // + // A node in Layer L that connects upward (to Layer L-1, which has higher Y) + // should sit near the TOP of the band — it shortens those vertical edges. + // A node connecting only downward (to Layer L+1) should sit at the BOTTOM. + // A node that connects in both directions, or only within its own layer, + // goes to the center — freeing the edges of the band for directional nodes. + // + // bias ∈ [-1, +1]: +1 = top of band (higher Y, toward layer above), + // -1 = bottom of band (lower Y, toward layer below), + // 0 = layer center. + const nodeYBias = new Map(); + graph.forEachNode((nodeId, attrs) => { + const layer = attrs.treeLayer ?? 0; + let aboveCount = 0; + let belowCount = 0; + graph.forEachNeighbor(nodeId, (_, nAttrs) => { + const nLayer = nAttrs.treeLayer ?? 0; + if (nLayer < layer) aboveCount++; + if (nLayer > layer) belowCount++; + }); + // Weighted ratio: (above − below) / total, scaled to ±0.55 of band half. + const total = aboveCount + belowCount; + nodeYBias.set(nodeId, total > 0 ? ((aboveCount - belowCount) / total) * 0.55 : 0); + }); - layoutTimeoutRef.current = setTimeout(() => { - if (layoutRef.current) { - layoutRef.current.stop(); - layoutRef.current = null; + // Pre-position nodes at their preferred Y to reduce physics convergence time. + graph.forEachNode((nodeId, attrs) => { + const layer = attrs.treeLayer ?? 0; + const cy = layerCenterY.get(layer) ?? attrs.y; + const bias = nodeYBias.get(nodeId) ?? 0; + graph.setNodeAttribute(nodeId, 'y', cy + bias * TREE_LAYER_BAND_HALF * 0.6); + }); + + setIsLayoutRunning(true); + + // Adaptive tuning — mirrors the circles layout strategy. + // The repulsion pass is O(N × k) after sorting; for large graphs k + // can be thousands, making each frame multi-hundred ms → apparent freeze. + const treeNodeCount = graph.order; + const treeIsLarge = treeNodeCount > 5000; + const treeIsMedium = treeNodeCount > 1500; + const treeUseRepulsion = !treeIsLarge; // skip O(N×k) repulsion for large graphs + const treeUseSpread = !treeIsLarge; // skip O(N log N) spread sort for large graphs + const treeDamping = treeIsLarge ? 0.58 : 0.62; + const treeVelocityCapX = treeIsLarge ? 12 : treeIsMedium ? 6 : 3; + const treeVelocityCapY = treeIsLarge ? 6 : treeIsMedium ? 3 : 2; + const treeMaxSimSteps = treeIsLarge ? 1 : 2; + const treeEffectiveMaxDuration = treeIsLarge + ? 30000 + : treeIsMedium + ? 24000 + : TREE_LAYOUT_MAX_DURATION; + const treeStopMaxVelocity = treeIsLarge ? 0.05 : 0.022; + const treeStopAvgVelocity = treeIsLarge ? 0.03 : 0.016; + const treeStopActiveNodeFraction = treeIsLarge ? 0.02 : 0.008; + const treeStopStabilityFrames = treeIsLarge ? 20 : TREE_LAYOUT_STABILITY_FRAMES; + + const step = (timestamp: number) => { + if (!graphRef.current || graphRef.current !== graph) { + stopTreeLayout(false); + return; + } + + if (treeLayoutStartRef.current === null) { + treeLayoutStartRef.current = timestamp; + } + + const frameDelta = + treeLastTickRef.current === null + ? TREE_TARGET_FRAME_MS + : clamp(timestamp - treeLastTickRef.current, 8, 64); + treeLastTickRef.current = timestamp; + treeAccumulatorRef.current = Math.min( + TREE_TARGET_FRAME_MS * 3, + treeAccumulatorRef.current + frameDelta, + ); + + if (treeAccumulatorRef.current < TREE_TARGET_FRAME_MS) { + treeLayoutFrameRef.current = requestAnimationFrame(step); + return; + } + + const simulationSteps = Math.min( + treeMaxSimSteps, + Math.floor(treeAccumulatorRef.current / TREE_TARGET_FRAME_MS), + ); + treeAccumulatorRef.current -= simulationSteps * TREE_TARGET_FRAME_MS; + const dtScale = 0.6; + + // --- Apply forces: velocity integration with boundary resistance --- + // Forces are recomputed from current node positions each sub-step so that + // slow frames (simulationSteps > 1) integrate correctly and don't double-apply. + let totalVelocity = 0; + let maxVelocity = 0; + let activeNodes = 0; + + for (let simulationStep = 0; simulationStep < simulationSteps; simulationStep++) { + // --- Accumulate forces (recomputed each sub-step from current positions) --- + const forceX = new Map(); + const forceY = new Map(); + + // 1. Layer gravity: soft pull toward each node's preferred Y within its band. + // Directional nodes (above-only or below-only connections) are pulled to the + // top or bottom of the band; bidirectional / same-layer-only nodes go to + // the center. This leaves band edges free for nodes that actually use them. + graph.forEachNode((nodeId, attrs) => { + const layer = attrs.treeLayer ?? 0; + const centerY = layerCenterY.get(layer) ?? attrs.y; + const bias = nodeYBias.get(nodeId) ?? 0; + const targetY = centerY + bias * TREE_LAYER_BAND_HALF; + forceX.set(nodeId, 0); + forceY.set(nodeId, (targetY - attrs.y) * TREE_LAYER_GRAVITY * dtScale); + }); + + // 2. Edge springs — X and Y handled separately. + // + // Root cause of long horizontal edges: the previous 2D spring projected + // force through (dx/distance, dy/distance). When the Y layer gap + // dominates (|dy|≈200, |dx|≈30) the X component shrinks to ~15% of + // the total spring force, too weak to overcome sibling repulsion. + // + // Fix: compute X spring from |dx| alone. This keeps full strength + // regardless of how far apart two nodes are in Y. + graph.forEachEdge((edge, edgeAttrs, source, target, sourceAttrs, targetAttrs) => { + const dx = targetAttrs.x - sourceAttrs.x; + const rawWeight = TREE_EDGE_WEIGHTS[edgeAttrs.relationType] ?? 0.18; + + // 2a. Pure X spring. + // Hierarchy edges: zero rest length so children want to sit directly + // under their parent (repulsion then spreads siblings out naturally). + // Cross edges: 60 px rest so far-spanning CALLS/IMPORTS edges only + // pull when really stretched, and their weight is capped so they + // don't override the hierarchy structure. + const xRestLength = edgeAttrs.isHierarchyEdge ? 0 : 60; + const xStretch = Math.abs(dx) - xRestLength; + if (xStretch > 0) { + const xWeight = edgeAttrs.isHierarchyEdge ? rawWeight : Math.min(rawWeight, 0.1); + const fxX = Math.sign(dx) * xStretch * xWeight * 0.3 * dtScale; + forceX.set(source, (forceX.get(source) ?? 0) + fxX); + forceX.set(target, (forceX.get(target) ?? 0) - fxX); + } + + // 2b. Weak Y spring — layer gravity handles most vertical placement; + // this just prevents extreme cross-layer stretching. + const dy = targetAttrs.y - sourceAttrs.y; + const distance = Math.sqrt(dx * dx + dy * dy) || 1; + const layerGap = Math.abs((targetAttrs.treeLayer ?? 0) - (sourceAttrs.treeLayer ?? 0)); + const yRestLength = + (edgeAttrs.isHierarchyEdge ? 70 : 95) + + layerGap * (edgeAttrs.isHierarchyEdge ? 28 : 36); + const yStretch = distance - yRestLength; + if (yStretch > 0) { + const fy = (dy / distance) * yStretch * rawWeight * 0.008 * dtScale; + forceY.set(source, (forceY.get(source) ?? 0) + fy); + forceY.set(target, (forceY.get(target) ?? 0) - fy); + } + }); + + // 3. Node repulsion in 2D: all pairs within range (cross-layer included) + // Sort by X for O(n·k) early-exit: once dx > range, all further pairs are too far. + // + // Skipped for large graphs (N > 5 000) — sorting + pair comparisons make each + // frame take hundreds of ms, leaving the canvas apparently frozen. Layer gravity + // and edge springs provide sufficient structure without repulsion. + if (treeUseRepulsion) { + const nodeList = graph.nodes().map((id) => { + const a = graph.getNodeAttributes(id); + return { id, x: a.x, y: a.y, size: a.size ?? 6, layer: a.treeLayer ?? 0 }; + }); + nodeList.sort((a, b) => a.x - b.x); + + for (let i = 0; i < nodeList.length; i++) { + const nodeA = nodeList[i]; + for (let j = i + 1; j < nodeList.length; j++) { + const nodeB = nodeList[j]; + const dx = nodeB.x - nodeA.x; + if (dx > TREE_REPULSION_RANGE) break; // X-sorted: all further pairs are also too far + + const dy = nodeB.y - nodeA.y; + const dist = Math.sqrt(dx * dx + dy * dy) || 1; + if (dist > TREE_REPULSION_RANGE) continue; + + const sameLayer = nodeA.layer === nodeB.layer; + // Same-layer repulsion reduced from 160→100 so the stronger X spring + // (0.30) can now overcome collective repulsion from 3-4 nearby nodes. + // Cross-layer kept low (28) so intermediate-layer nodes don't block + // parent-child X alignment. + const repulsionStrength = sameLayer ? 100 : 28; + const minGap = Math.max(28, (nodeA.size + nodeB.size) * 1.8); + let repulsion = + (1 / (dist + 8) - 1 / (TREE_REPULSION_RANGE + 8)) * repulsionStrength * dtScale; + if (dist < minGap && sameLayer) { + repulsion += (minGap - dist) * 0.1 * dtScale; + } + if (repulsion <= 0) continue; + + const fx = (dx / dist) * repulsion; + const fy = (dy / dist) * repulsion; + + forceX.set(nodeA.id, (forceX.get(nodeA.id) ?? 0) - fx); + forceY.set(nodeA.id, (forceY.get(nodeA.id) ?? 0) - fy); + forceX.set(nodeB.id, (forceX.get(nodeB.id) ?? 0) + fx); + forceY.set(nodeB.id, (forceY.get(nodeB.id) ?? 0) + fy); + } + } + } + + // 4. Spread force: equalize node density within each layer. + // + // For each layer, rank nodes by current X, compute where they would sit + // in a perfectly even distribution, then add a weak force toward that + // ideal position. Nodes that are held by strong hierarchy springs + // (force ≈ 1–2 units) resist and stay clustered; nodes without a + // strong spring anchor (isolated or same-layer-only) drift to fill gaps. + // Net effect: dense centre spreads outward, sparse edges fill in. + // Skipped for large graphs — per-layer sort is O(N log N) per frame. + if (treeUseSpread) { + const spreadByLayer = new Map>(); + graph.forEachNode((nodeId, attrs) => { + const layer = attrs.treeLayer ?? 0; + if (!spreadByLayer.has(layer)) spreadByLayer.set(layer, []); + spreadByLayer.get(layer)!.push({ id: nodeId, x: attrs.x }); + }); + for (const [, layerNodes] of spreadByLayer) { + if (layerNodes.length < 2) continue; + layerNodes.sort((a, b) => a.x - b.x); + const count = layerNodes.length; + const spacing = (TREE_MAX_X * 2) / count; + for (let i = 0; i < count; i++) { + const { id, x } = layerNodes[i]; + const idealX = -TREE_MAX_X + (i + 0.5) * spacing; + forceX.set( + id, + (forceX.get(id) ?? 0) + (idealX - x) * TREE_SPREAD_STRENGTH * dtScale, + ); + } + } + } + + totalVelocity = 0; + maxVelocity = 0; + activeNodes = 0; + + graph.forEachNode((nodeId, attrs) => { + const fx = forceX.get(nodeId) ?? 0; + const fy = forceY.get(nodeId) ?? 0; + const vx0 = treeVelocityRef.current.get(nodeId) ?? 0; + const vy0 = treeVelocityYRef.current.get(nodeId) ?? 0; + + // X boundary resistance: grows as node approaches canvas edge + const normX = Math.min(1, Math.abs(attrs.x) / TREE_MAX_X); + const resistX = 1 + normX * normX * 4; + + // Y boundary resistance: grows as node drifts from its layer band center + const layer = attrs.treeLayer ?? 0; + const centerY = layerCenterY.get(layer) ?? attrs.y; + const yOffset = attrs.y - centerY; + const normY = Math.min(1, Math.abs(yOffset) / TREE_LAYER_BAND_HALF); + const resistY = 1 + normY * normY * TREE_LAYER_BOUNDARY_RESISTANCE; + + const rawVx = (vx0 + fx / resistX) * treeDamping; + const rawVy = (vy0 + fy / resistY) * treeDamping; + const newVx = + Math.abs(fx) < TREE_FORCE_DEADZONE && Math.abs(rawVx) < TREE_VELOCITY_DEADZONE + ? 0 + : clamp(rawVx, -treeVelocityCapX, treeVelocityCapX); + const newVy = + Math.abs(fy) < TREE_FORCE_DEADZONE && Math.abs(rawVy) < TREE_VELOCITY_DEADZONE + ? 0 + : clamp(rawVy, -treeVelocityCapY, treeVelocityCapY); + + treeVelocityRef.current.set(nodeId, newVx); + treeVelocityYRef.current.set(nodeId, newVy); + + const speed = Math.sqrt(newVx * newVx + newVy * newVy); + totalVelocity += speed; + maxVelocity = Math.max(maxVelocity, speed); + if ( + speed > TREE_VELOCITY_DEADZONE || + Math.abs(fx) > TREE_FORCE_DEADZONE || + Math.abs(fy) > TREE_FORCE_DEADZONE + ) { + activeNodes += 1; + } + + graph.setNodeAttribute(nodeId, 'x', clamp(attrs.x + newVx, -TREE_MAX_X, TREE_MAX_X)); + graph.setNodeAttribute( + nodeId, + 'y', + clamp( + attrs.y + newVy, + centerY - TREE_LAYER_BAND_HALF, + centerY + TREE_LAYER_BAND_HALF, + ), + ); + }); + } - // Light noverlap cleanup - noverlap.assign(graph, NOVERLAP_SETTINGS); sigmaRef.current?.refresh(); - setIsLayoutRunning(false); - } - }, duration); - }, []); + const averageVelocity = totalVelocity / Math.max(1, graph.order); + const elapsed = timestamp - (treeLayoutStartRef.current ?? timestamp); + + if ( + elapsed >= TREE_LAYOUT_MIN_DURATION && + maxVelocity < treeStopMaxVelocity && + activeNodes <= Math.max(2, Math.floor(graph.order * treeStopActiveNodeFraction)) && + averageVelocity < treeStopAvgVelocity + ) { + treeStableFramesRef.current += 1; + } else { + treeStableFramesRef.current = 0; + } + + if ( + treeStableFramesRef.current >= treeStopStabilityFrames || + elapsed >= treeEffectiveMaxDuration + ) { + stopTreeLayout(true); + return; + } + + treeLayoutFrameRef.current = requestAnimationFrame(step); + }; + + treeLayoutFrameRef.current = requestAnimationFrame(step); + }, + [stopAllLayouts, stopTreeLayout], + ); + + const runCirclesLayout = useCallback( + (graph: Graph) => { + if (graph.order === 0) return; + + stopAllLayouts(false); + + // Compute ring target radii and centre Y (all rings are centred at 0,0) + const ringTargetR = CIRCLES_RING_RADII as unknown as number[]; + + // --------------------------------------------------------------------------- + // Adaptive physics parameters — scale to graph size. + // + // For large graphs the two most expensive passes are: + // • Repulsion: O(n × k) where k = neighbours in the sweep window + // (can be hundreds when nodes are dense on a ring arc). + // • Angular spread: O(k log k) per ring — O(n log n) total. + // + // Neither is needed for layout correctness: gravity pulls nodes to their + // ring, edge springs cluster connected nodes angularly. Repulsion and + // spread are purely cosmetic polish — worth skipping at large n. + // --------------------------------------------------------------------------- + const nodeCount = graph.order; + const isLargeGraph = nodeCount > 5000; + const isMediumGraph = nodeCount > 1500; + + // Repulsion range — 0 means skip the pass entirely. + const effectiveRepulsionRange = isLargeGraph + ? 0 + : isMediumGraph + ? 70 + : CIRCLES_REPULSION_RANGE; + + // Damping: moderate for large graphs so nodes don't overshoot but still + // settle within the time budget. Very aggressive damping (0.48) causes + // nodes to stop mid-path before reaching equilibrium. + const dampingFactor = isLargeGraph ? 0.58 : isMediumGraph ? 0.58 : 0.62; + + // Higher velocity cap → each frame moves nodes further (faster convergence). + const velocityCap = isLargeGraph ? 10 : 5; + + // Fewer simulation sub-steps per rAF tick to keep frames fast for large graphs. + const maxSimSteps = isLargeGraph ? 1 : 2; + + // Tighter per-frame budget for repulsion sweep when range > 0. + const useAngularSpread = !isLargeGraph; + + // Max wall-clock budget. Large graphs skip the expensive passes so each + // frame is fast (full 60 fps); 30 s × 60 fps = 1 800 frames is enough to + // converge 20 k+ node layouts with only gravity + edge springs. + const effectiveMaxDuration = isLargeGraph + ? 30000 + : isMediumGraph + ? 18000 + : CIRCLES_LAYOUT_MAX_DURATION; + + // Early-stop velocity thresholds. + const stopMaxVelocity = isLargeGraph ? 0.05 : 0.022; + const stopAvgVelocity = isLargeGraph ? 0.03 : 0.016; + const stopActiveNodeFraction = isLargeGraph ? 0.02 : 0.008; + const stopStabilityFrames = isLargeGraph ? 20 : CIRCLES_LAYOUT_STABILITY_FRAMES; + + // Pre-position nodes at their anchor and initialise velocities + graph.forEachNode((nodeId, attrs) => { + const ax = attrs.circlesAnchorX ?? attrs.x; + const ay = attrs.circlesAnchorY ?? attrs.y; + graph.setNodeAttribute(nodeId, 'x', ax); + graph.setNodeAttribute(nodeId, 'y', ay); + circlesVelocityXRef.current.set(nodeId, 0); + circlesVelocityYRef.current.set(nodeId, 0); + }); + + setIsLayoutRunning(true); + + const step = (timestamp: number) => { + if (!graphRef.current || graphRef.current !== graph) { + stopCirclesLayout(false); + return; + } + + if (circlesLayoutStartRef.current === null) { + circlesLayoutStartRef.current = timestamp; + } + + const frameDelta = + circlesLastTickRef.current === null + ? TREE_TARGET_FRAME_MS + : clamp(timestamp - circlesLastTickRef.current, 8, 64); + circlesLastTickRef.current = timestamp; + circlesAccumulatorRef.current = Math.min( + TREE_TARGET_FRAME_MS * 3, + circlesAccumulatorRef.current + frameDelta, + ); + + if (circlesAccumulatorRef.current < TREE_TARGET_FRAME_MS) { + circlesLayoutFrameRef.current = requestAnimationFrame(step); + return; + } + + const simulationSteps = Math.min( + maxSimSteps, + Math.floor(circlesAccumulatorRef.current / TREE_TARGET_FRAME_MS), + ); + circlesAccumulatorRef.current -= simulationSteps * TREE_TARGET_FRAME_MS; + const dtScale = 0.6; + + // --- Apply forces with radial boundary resistance --- + let totalVelocity = 0; + let maxVelocity = 0; + let activeNodes = 0; + + for (let _step = 0; _step < simulationSteps; _step++) { + totalVelocity = 0; + maxVelocity = 0; + activeNodes = 0; + + // --- Accumulate forces (recomputed each sub-step from current positions) --- + const forceX = new Map(); + const forceY = new Map(); + + // 1. Radial gravity with soft wall. + // + // Base gravity is weak, allowing repulsion to spread nodes radially + // within the band. The effective rate grows cubically as the node + // approaches the band edge so nodes never cross into adjacent rings. + // This replaces the previous hard position clamp, which caused nodes + // to pile against the boundary instead of distributing within the band. + graph.forEachNode((nodeId, attrs) => { + const ring = attrs.circlesRing ?? 0; + const targetR = ringTargetR[Math.min(ring, CIRCLES_RING_COUNT - 1)]; + const x = attrs.x; + const y = attrs.y; + const r = Math.sqrt(x * x + y * y) || 1; + const stretch = targetR - r; // positive = node inside ring, negative = outside + const normR = Math.min(1, Math.abs(stretch) / CIRCLES_BAND_HALF); + const k = + CIRCLES_RADIAL_GRAVITY * + (1 + normR * normR * normR * CIRCLES_RADIAL_BOUNDARY_RESISTANCE); + forceX.set(nodeId, (x / r) * stretch * k * dtScale); + forceY.set(nodeId, (y / r) * stretch * k * dtScale); + }); + + // 2. Edge springs — radial and tangential components. + // + // Rest length strategy: + // Hierarchy edges (cross-ring): use the radial gap between the two + // ring centres as rest length. This means the spring only activates + // when nodes are angularly misaligned — it does NOT fight radial + // gravity (which was the main cause of long edges in previous builds). + // Cross edges (same or different ring): rest length = 30 px so the + // spring activates sooner and pulls connected nodes closer. + // + // Weight cap removed: all edges use their full weight so cross-ring + // CALLS/IMPORTS springs are strong enough to pull nodes into position. + graph.forEachEdge((edge, edgeAttrs, source, target, sourceAttrs, targetAttrs) => { + const dx = targetAttrs.x - sourceAttrs.x; + const dy = targetAttrs.y - sourceAttrs.y; + const dist = Math.sqrt(dx * dx + dy * dy) || 1; + + const rawWeight = CIRCLES_EDGE_WEIGHTS[edgeAttrs.relationType] ?? 0.2; + + const sourceRing = sourceAttrs.circlesRing ?? 0; + const targetRing = targetAttrs.circlesRing ?? 0; + const restLength = edgeAttrs.isHierarchyEdge + ? Math.abs( + ringTargetR[Math.min(sourceRing, CIRCLES_RING_COUNT - 1)] - + ringTargetR[Math.min(targetRing, CIRCLES_RING_COUNT - 1)], + ) + : 30; + + const stretch = dist - restLength; + if (stretch > 0) { + const f = stretch * rawWeight * 0.55 * dtScale; + const fx = (dx / dist) * f; + const fy = (dy / dist) * f; + forceX.set(source, (forceX.get(source) ?? 0) + fx); + forceY.set(source, (forceY.get(source) ?? 0) + fy); + forceX.set(target, (forceX.get(target) ?? 0) - fx); + forceY.set(target, (forceY.get(target) ?? 0) - fy); + } + }); + + // 3. 2D repulsion — skipped for large graphs (effectiveRepulsionRange = 0). + // For large graphs, gravity + edge springs are sufficient; the O(n×k) + // repulsion sweep is the dominant per-frame cost and not worth the + // quality gain when nodes are already tiny. + if (effectiveRepulsionRange > 0) { + const nodeList = graph.nodes().map((id) => { + const a = graph.getNodeAttributes(id); + return { id, x: a.x, y: a.y, size: a.size ?? 6, ring: a.circlesRing ?? 0 }; + }); + nodeList.sort((a, b) => a.x - b.x); + + for (let i = 0; i < nodeList.length; i++) { + const nodeA = nodeList[i]; + for (let j = i + 1; j < nodeList.length; j++) { + const nodeB = nodeList[j]; + const dx = nodeB.x - nodeA.x; + if (dx > effectiveRepulsionRange) break; + + const dy = nodeB.y - nodeA.y; + const dist2 = dx * dx + dy * dy; + const distVal = Math.sqrt(dist2) || 1; + if (distVal > effectiveRepulsionRange) continue; + + const sameRing = nodeA.ring === nodeB.ring; + const repulsionStrength = sameRing ? 100 : 28; + const minGap = Math.max(28, (nodeA.size + nodeB.size) * 1.8); + let repulsion = + (1 / (distVal + 8) - 1 / (effectiveRepulsionRange + 8)) * + repulsionStrength * + dtScale; + if (distVal < minGap && sameRing) repulsion += (minGap - distVal) * 0.1 * dtScale; + if (repulsion <= 0) continue; + + const fx = (dx / distVal) * repulsion; + const fy = (dy / distVal) * repulsion; + forceX.set(nodeA.id, (forceX.get(nodeA.id) ?? 0) - fx); + forceY.set(nodeA.id, (forceY.get(nodeA.id) ?? 0) - fy); + forceX.set(nodeB.id, (forceX.get(nodeB.id) ?? 0) + fx); + forceY.set(nodeB.id, (forceY.get(nodeB.id) ?? 0) + fy); + } + } + } + + // 4. Angular spread — skipped for large graphs. + // Sorting each ring's nodes every frame is O(k log k); for ring 3 + // with 15k+ nodes this costs several ms/frame. For large graphs + // edge springs already provide angular clustering. + if (useAngularSpread) { + const spreadByRing = new Map< + number, + Array<{ id: string; angle: number; x: number; y: number }> + >(); + graph.forEachNode((nodeId, attrs) => { + const ring = attrs.circlesRing ?? 0; + if (!spreadByRing.has(ring)) spreadByRing.set(ring, []); + spreadByRing.get(ring)!.push({ + id: nodeId, + angle: Math.atan2(attrs.y, attrs.x), + x: attrs.x, + y: attrs.y, + }); + }); + + for (const [, ringNodes] of spreadByRing) { + if (ringNodes.length < 2) continue; + ringNodes.sort((a, b) => a.angle - b.angle); + const count = ringNodes.length; + for (let i = 0; i < count; i++) { + const { id, angle, x, y } = ringNodes[i]; + const idealAngle = ((i + 0.5) / count) * Math.PI * 2 - Math.PI; + let dAngle = idealAngle - angle; + while (dAngle > Math.PI) dAngle -= Math.PI * 2; + while (dAngle < -Math.PI) dAngle += Math.PI * 2; + const r = Math.sqrt(x * x + y * y) || 1; + // Tangential unit vector: (-y/r, x/r) + const tx = -y / r; + const ty = x / r; + const fMag = dAngle * CIRCLES_ANGULAR_SPREAD * dtScale; + forceX.set(id, (forceX.get(id) ?? 0) + tx * fMag); + forceY.set(id, (forceY.get(id) ?? 0) + ty * fMag); + } + } + } + + graph.forEachNode((nodeId, attrs) => { + const fx = forceX.get(nodeId) ?? 0; + const fy = forceY.get(nodeId) ?? 0; + const vx0 = circlesVelocityXRef.current.get(nodeId) ?? 0; + const vy0 = circlesVelocityYRef.current.get(nodeId) ?? 0; + + const ring = attrs.circlesRing ?? 0; + const targetR = ringTargetR[Math.min(ring, CIRCLES_RING_COUNT - 1)]; + const x = attrs.x; + const y = attrs.y; + + // Soft-wall gravity (force 1) already handles radial boundary + // enforcement — no separate resistance decomposition needed. + const rawVx = (vx0 + fx) * dampingFactor; + const rawVy = (vy0 + fy) * dampingFactor; + const newVx = + Math.abs(fx) < CIRCLES_FORCE_DEADZONE && Math.abs(rawVx) < CIRCLES_VELOCITY_DEADZONE + ? 0 + : clamp(rawVx, -velocityCap, velocityCap); + const newVy = + Math.abs(fy) < CIRCLES_FORCE_DEADZONE && Math.abs(rawVy) < CIRCLES_VELOCITY_DEADZONE + ? 0 + : clamp(rawVy, -velocityCap, velocityCap); + + circlesVelocityXRef.current.set(nodeId, newVx); + circlesVelocityYRef.current.set(nodeId, newVy); + + const speed = Math.sqrt(newVx * newVx + newVy * newVy); + totalVelocity += speed; + maxVelocity = Math.max(maxVelocity, speed); + if ( + speed > CIRCLES_VELOCITY_DEADZONE || + Math.abs(fx) > CIRCLES_FORCE_DEADZONE || + Math.abs(fy) > CIRCLES_FORCE_DEADZONE + ) { + activeNodes += 1; + } + + const newX = x + newVx; + const newY = y + newVy; + // Wide safety clamp (1.5 × band_half): the soft-wall gravity keeps + // nodes inside [targetR ± BAND_HALF] naturally. This catches only + // extreme numerical edge cases (e.g. very large forces on first frame). + const newR = Math.sqrt(newX * newX + newY * newY) || 1; + const safeMin = Math.max(1, targetR - CIRCLES_BAND_HALF * 1.5); + const safeMax = targetR + CIRCLES_BAND_HALF * 1.5; + const safeR = clamp(newR, safeMin, safeMax); + const safeScale = safeR / newR; + graph.setNodeAttribute(nodeId, 'x', newX * safeScale); + graph.setNodeAttribute(nodeId, 'y', newY * safeScale); + }); + } + + sigmaRef.current?.refresh(); + + const averageVelocity = totalVelocity / Math.max(1, graph.order); + const elapsed = timestamp - (circlesLayoutStartRef.current ?? timestamp); + + if ( + elapsed >= CIRCLES_LAYOUT_MIN_DURATION && + maxVelocity < stopMaxVelocity && + activeNodes <= Math.max(2, Math.floor(graph.order * stopActiveNodeFraction)) && + averageVelocity < stopAvgVelocity + ) { + circlesStableFramesRef.current += 1; + } else { + circlesStableFramesRef.current = 0; + } + + if ( + circlesStableFramesRef.current >= stopStabilityFrames || + elapsed >= effectiveMaxDuration + ) { + stopCirclesLayout(true); + return; + } + + circlesLayoutFrameRef.current = requestAnimationFrame(step); + }; + + circlesLayoutFrameRef.current = requestAnimationFrame(step); + }, + [stopAllLayouts, stopCirclesLayout], + ); + + // Run ForceAtlas2 layout + const runLayout = useCallback( + (graph: Graph) => { + const nodeCount = graph.order; + if (nodeCount === 0) return; + + stopAllLayouts(false); + + // Get settings + const inferredSettings = forceAtlas2.inferSettings(graph); + const customSettings = getFA2Settings(nodeCount); + const settings = { ...inferredSettings, ...customSettings }; + + const layout = new FA2Layout(graph, { settings }); + + layoutRef.current = layout; + layout.start(); + setIsLayoutRunning(true); + + const duration = getLayoutDuration(nodeCount); + + layoutTimeoutRef.current = setTimeout(() => { + if (layoutRef.current) { + layoutRef.current.stop(); + layoutRef.current = null; + + // Light noverlap cleanup + noverlap.assign(graph, NOVERLAP_SETTINGS); + sigmaRef.current?.refresh(); + + setIsLayoutRunning(false); + } + }, duration); + }, + [stopAllLayouts], + ); const setGraph = useCallback( (newGraph: Graph) => { const sigma = sigmaRef.current; if (!sigma) return; - if (layoutRef.current) { - layoutRef.current.kill(); - layoutRef.current = null; - } - if (layoutTimeoutRef.current) { - clearTimeout(layoutTimeoutRef.current); - layoutTimeoutRef.current = null; - } + stopAllLayouts(false); graphRef.current = newGraph; sigma.setGraph(newGraph); setSelectedNode(null); - runLayout(newGraph); + if (options.layoutMode === 'tree') { + runTreeLayout(newGraph); + } else if (options.layoutMode === 'circles') { + runCirclesLayout(newGraph); + } else { + runLayout(newGraph); + } + sigma.getCamera().animatedReset({ duration: 500 }); }, - [runLayout, setSelectedNode], + [ + options.layoutMode, + runLayout, + runTreeLayout, + runCirclesLayout, + setSelectedNode, + stopAllLayouts, + ], ); const focusNode = useCallback((nodeId: string) => { @@ -603,27 +1540,18 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => { const startLayout = useCallback(() => { const graph = graphRef.current; if (!graph || graph.order === 0) return; - runLayout(graph); - }, [runLayout]); + if (options.layoutMode === 'tree') { + runTreeLayout(graph); + } else if (options.layoutMode === 'circles') { + runCirclesLayout(graph); + } else { + runLayout(graph); + } + }, [options.layoutMode, runLayout, runTreeLayout, runCirclesLayout]); const stopLayout = useCallback(() => { - if (layoutTimeoutRef.current) { - clearTimeout(layoutTimeoutRef.current); - layoutTimeoutRef.current = null; - } - if (layoutRef.current) { - layoutRef.current.stop(); - layoutRef.current = null; - - const graph = graphRef.current; - if (graph) { - noverlap.assign(graph, NOVERLAP_SETTINGS); - sigmaRef.current?.refresh(); - } - - setIsLayoutRunning(false); - } - }, []); + stopAllLayouts(true); + }, [stopAllLayouts]); const refreshHighlights = useCallback(() => { sigmaRef.current?.refresh(); diff --git a/gitnexus-web/src/lib/circles-layout.ts b/gitnexus-web/src/lib/circles-layout.ts new file mode 100644 index 000000000..8a061728b --- /dev/null +++ b/gitnexus-web/src/lib/circles-layout.ts @@ -0,0 +1,307 @@ +import type { KnowledgeGraph } from '../core/graph/types'; +import type { GraphNode, NodeLabel } from 'gitnexus-shared'; +import { NODE_SIZES } from './constants'; + +export interface CirclesNodePosition { + x: number; + y: number; + size: number; + /** Logical ring index 0 (innermost) … RING_COUNT-1 (outermost) */ + ring: number; + /** Angle in radians, stored so the physics can use it as an anchor */ + angle: number; +} + +// --------------------------------------------------------------------------- +// Configurable constants +// --------------------------------------------------------------------------- + +/** Target radius (px) for each ring. Ring 0 is innermost. */ +export const CIRCLES_RING_RADII = [90, 240, 420, 620] as const; + +/** + * Half-width of the allowed radial band around each ring centre. + * Keep this small enough that adjacent rings never overlap. + * Current ring gaps: 150 / 180 / 200 px → band = 45 leaves 60-110 px of clear air. + */ +export const CIRCLES_BAND_HALF = 45; + +/** Number of rings (= number of layers). */ +export const RING_COUNT = CIRCLES_RING_RADII.length; // 4 + +// --------------------------------------------------------------------------- +// Layer assignment — identical to tree-layout so the same node types +// end up in the same conceptual layer. +// --------------------------------------------------------------------------- + +const TYPE_TO_RING: Record = { + // Ring 0 – innermost: structural containers + Project: 0, + Package: 0, + Module: 0, + Folder: 0, + Namespace: 0, + + // Ring 1 – files + File: 1, + Section: 1, + Import: 1, + Route: 1, + Tool: 1, + + // Ring 2 – type definitions + Class: 2, + Interface: 2, + Enum: 2, + Type: 2, + Struct: 2, + Trait: 2, + Union: 2, + Record: 2, + Typedef: 2, + Template: 2, + TypeAlias: 2, + + // Ring 3 – outermost: functions / methods / variables + Function: 3, + Method: 3, + Impl: 3, + Delegate: 3, + Constructor: 3, + Variable: 3, + Const: 3, + Static: 3, + Property: 3, + Decorator: 3, + Annotation: 3, + Macro: 3, + CodeElement: 3, +}; + +const DEFAULT_RING = 1; + +/** Hierarchy edges used for angular-allocation grouping. */ +export const CIRCLES_HIERARCHY_RELATIONS = new Set([ + 'CONTAINS', + 'DEFINES', + 'HAS_METHOD', + 'HAS_PROPERTY', +]); + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function getNodeRing(node: GraphNode): number { + return TYPE_TO_RING[node.label] ?? DEFAULT_RING; +} + +function calculateNodeSize(ring: number, nodeType: NodeLabel): number { + const baseSize = NODE_SIZES[nodeType] || 6; + const ringMultiplier = Math.max(0.6, 1 - ring * 0.12); + return baseSize * ringMultiplier; +} + +function deterministicHash(str: string): number { + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = (hash << 5) + hash + str.charCodeAt(i); + hash |= 0; + } + return (Math.abs(hash) % 10000) / 10000; +} + +function buildHierarchyMaps(graph: KnowledgeGraph) { + const childrenByParent = new Map(); + const parentsByChild = new Map(); + + for (const rel of graph.relationships) { + if (!CIRCLES_HIERARCHY_RELATIONS.has(rel.type)) continue; + + if (!childrenByParent.has(rel.sourceId)) childrenByParent.set(rel.sourceId, []); + childrenByParent.get(rel.sourceId)!.push(rel.targetId); + + if (!parentsByChild.has(rel.targetId)) parentsByChild.set(rel.targetId, []); + parentsByChild.get(rel.targetId)!.push(rel.sourceId); + } + + return { childrenByParent, parentsByChild }; +} + +// --------------------------------------------------------------------------- +// Parent-centred angular allocation +// +// Each parent's children are placed in an arc CENTRED on the parent's own +// angle, with arc size proportional to child count. This prevents the +// sequential-concatenation bias (where the largest group's arc centre drifts +// to 90° / 270° regardless of where the parent sits) that caused top-bottom +// crowding in the previous sequential allocation. +// +// Overlapping initial arcs are fine — the physics simulation's angular spread +// force resolves them during the simulation. +// --------------------------------------------------------------------------- + +function initParentCentredAngles( + graph: KnowledgeGraph, + parentsByChild: Map, +): Map { + const positions = new Map(); + + // Group nodes by ring + const nodesByRing: GraphNode[][] = Array.from({ length: RING_COUNT }, () => []); + const nodeRingMap = new Map(); + + for (const node of graph.nodes) { + const ring = getNodeRing(node); + if (ring >= 0 && ring < RING_COUNT) { + nodesByRing[ring].push(node); + nodeRingMap.set(node.id, ring); + } + } + + const TWO_PI = Math.PI * 2; + + // --- Ring 0: sorted alphabetically, evenly spaced around full circle --- + const ring0Nodes = [...nodesByRing[0]].sort((a, b) => + a.properties.name.localeCompare(b.properties.name), + ); + + if (ring0Nodes.length > 0) { + const count = ring0Nodes.length; + for (let i = 0; i < count; i++) { + const node = ring0Nodes[i]; + const angle = (i / count) * TWO_PI; + const r = CIRCLES_RING_RADII[0]; + positions.set(node.id, { + x: r * Math.cos(angle), + y: r * Math.sin(angle), + size: calculateNodeSize(0, node.label), + ring: 0, + angle, + }); + } + } + + // --- Rings 1-3: parent-centred arc placement --- + for (let ring = 1; ring < RING_COUNT; ring++) { + const ringNodes = nodesByRing[ring]; + if (ringNodes.length === 0) continue; + + const r = CIRCLES_RING_RADII[ring]; + + // Find each node's primary parent: placed ancestor with highest ring index + // (so a Method prefers its Class over a distant Package). + const assignedParent = new Map(); + for (const node of ringNodes) { + const parents = parentsByChild.get(node.id) ?? []; + let bestParent: string | null = null; + let bestParentRing = -1; + for (const p of parents) { + if (!positions.has(p)) continue; + const pRing = nodeRingMap.get(p) ?? -1; + if (pRing > bestParentRing) { + bestParentRing = pRing; + bestParent = p; + } + } + if (bestParent) assignedParent.set(node.id, bestParent); + } + + // Bucket into parent groups and orphans + const childrenOfParent = new Map(); + const orphans: GraphNode[] = []; + + for (const node of ringNodes) { + const p = assignedParent.get(node.id); + if (!p) { + orphans.push(node); + } else { + if (!childrenOfParent.has(p)) childrenOfParent.set(p, []); + childrenOfParent.get(p)!.push(node); + } + } + + for (const children of childrenOfParent.values()) { + children.sort((a, b) => a.properties.name.localeCompare(b.properties.name)); + } + orphans.sort((a, b) => a.properties.name.localeCompare(b.properties.name)); + + const totalParented = ringNodes.length - orphans.length; + const parentedFraction = totalParented > 0 ? totalParented / ringNodes.length : 0; + + // Place each parent's children in an arc centred on the parent's angle. + // Arc size ∝ child count relative to all parented nodes. + for (const [parentId, children] of childrenOfParent) { + if (children.length === 0) continue; + + const parentAngle = positions.get(parentId)?.angle ?? 0; + const slotArc = (children.length / totalParented) * parentedFraction * TWO_PI; + const startAngle = parentAngle - slotArc / 2; + + for (let i = 0; i < children.length; i++) { + const angle = startAngle + (i + 0.5) * (slotArc / children.length); + positions.set(children[i].id, { + x: r * Math.cos(angle), + y: r * Math.sin(angle), + size: calculateNodeSize(ring, children[i].label), + ring, + angle, + }); + } + } + + // Orphans: spread evenly in their proportional arc, centred at angle = π + // (left side), away from the 0° / ±π boundary to avoid wrapping artefacts. + if (orphans.length > 0) { + const orphanFraction = orphans.length / ringNodes.length; + const orphanArc = orphanFraction * TWO_PI; + // Centre orphan arc at π so it doesn't overlap with the typical 0° cluster + const orphanStart = Math.PI - orphanArc / 2; + for (let i = 0; i < orphans.length; i++) { + const angle = orphanStart + (i + 0.5) * (orphanArc / orphans.length); + positions.set(orphans[i].id, { + x: r * Math.cos(angle), + y: r * Math.sin(angle), + size: calculateNodeSize(ring, orphans[i].label), + ring, + angle, + }); + } + } + } + + return positions; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Circles view layout: concentric rings with parent-centred angular allocation. + * + * Ring 0 (innermost) = Folders/Packages + * Ring 1 = Files + * Ring 2 = Classes/Interfaces + * Ring 3 (outermost) = Functions/Methods/Variables + * + * Returns initial positions; the physics simulation in useSigma.ts refines + * them using radial gravity + hard band clamping, angular spread, and 2D + * repulsion — identical in structure to the tree-view physics. + */ +export function calculateCirclesLayout(graph: KnowledgeGraph): Map { + const { parentsByChild } = buildHierarchyMaps(graph); + + // 1. Parent-centred angular allocation — no top/bottom bias + const positions = initParentCentredAngles(graph, parentsByChild); + + // 2. Subtle radial jitter only — angular jitter would fight the centred placement + for (const [nodeId, pos] of positions) { + const jitter = (deterministicHash(nodeId + 'r') - 0.5) * 10; // ±10 px + const r = CIRCLES_RING_RADII[pos.ring] + jitter; + pos.x = r * Math.cos(pos.angle); + pos.y = r * Math.sin(pos.angle); + } + + return positions; +} diff --git a/gitnexus-web/src/lib/constants.ts b/gitnexus-web/src/lib/constants.ts index f6804405b..fe0505483 100644 --- a/gitnexus-web/src/lib/constants.ts +++ b/gitnexus-web/src/lib/constants.ts @@ -101,7 +101,9 @@ export const getCommunityColor = (communityIndex: number): string => { return COMMUNITY_COLORS[communityIndex % COMMUNITY_COLORS.length]; }; -// Labels to show by default (hide imports and variables by default as they clutter) +// Labels to show by default (hide imports by default as they clutter). +// Property/Const are the Kotlin/Java equivalents of Variable — include them so +// Kotlin repos don't appear to have no leaf nodes. export const DEFAULT_VISIBLE_LABELS: NodeLabel[] = [ 'Project', 'Package', @@ -111,6 +113,8 @@ export const DEFAULT_VISIBLE_LABELS: NodeLabel[] = [ 'Class', 'Function', 'Method', + 'Property', // Kotlin/Java fields (HAS_PROPERTY + DEFINES File→Property) + 'Const', // Top-level constants 'Interface', 'Enum', 'Type', @@ -127,6 +131,8 @@ export const FILTERABLE_LABELS: NodeLabel[] = [ 'Function', 'Method', 'Variable', + 'Property', // Kotlin/Java field nodes + 'Const', 'Decorator', 'Import', ]; diff --git a/gitnexus-web/src/lib/graph-adapter.test.ts b/gitnexus-web/src/lib/graph-adapter.test.ts new file mode 100644 index 000000000..4cbb6b8ef --- /dev/null +++ b/gitnexus-web/src/lib/graph-adapter.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect } from 'vitest'; +import { knowledgeGraphToTreeGraphology, knowledgeGraphToCirclesGraphology } from './graph-adapter'; +import type { KnowledgeGraph } from '../core/graph/types'; +import type { GraphNode } from 'gitnexus-shared'; +import { EDGE_INFO } from './constants'; + +function makeNode(id: string, label: string, name: string): GraphNode { + return { + id, + label: label as any, + properties: { name, filePath: '', startLine: 1, endLine: 1 }, + }; +} + +describe('knowledgeGraphToTreeGraphology', () => { + it('should create a graph with tree layout', () => { + const graph: KnowledgeGraph = { + nodes: [ + makeNode('root', 'Project', 'MyProject'), + makeNode('folder', 'Folder', 'src'), + makeNode('file', 'File', 'main.ts'), + ], + relationships: [ + { id: 'r1', type: 'CONTAINS', sourceId: 'root', targetId: 'folder' }, + { id: 'r2', type: 'CONTAINS', sourceId: 'folder', targetId: 'file' }, + { id: 'r3', type: 'CALLS', sourceId: 'file', targetId: 'root' }, + ], + }; + + const sigmaGraph = knowledgeGraphToTreeGraphology(graph); + + expect(sigmaGraph.hasNode('root')).toBe(true); + expect(sigmaGraph.hasNode('folder')).toBe(true); + expect(sigmaGraph.hasNode('file')).toBe(true); + + const rootAttrs = sigmaGraph.getNodeAttributes('root'); + const folderAttrs = sigmaGraph.getNodeAttributes('folder'); + const fileAttrs = sigmaGraph.getNodeAttributes('file'); + + // Tree view is inverted vertically, so files sit above containers. + expect(fileAttrs.y).toBeLessThan(rootAttrs.y); + expect(fileAttrs.y).toBeLessThan(folderAttrs.y); + + // Nodes should have reasonable sizes + expect(rootAttrs.size).toBeGreaterThan(2); + expect(folderAttrs.size).toBeGreaterThan(2); + expect(fileAttrs.size).toBeGreaterThan(2); + + expect(rootAttrs.treeAnchorX).toBe(rootAttrs.x); + expect(rootAttrs.treeAnchorY).toBe(rootAttrs.y); + expect(rootAttrs.treeLayer).toBe(0); + expect(fileAttrs.treeLayer).toBe(1); + }); + + it('should style hierarchy edges differently from cross-cutting edges', () => { + const graph: KnowledgeGraph = { + nodes: [makeNode('a', 'Function', 'fnA'), makeNode('b', 'Function', 'fnB')], + relationships: [ + { id: 'r1', type: 'CONTAINS', sourceId: 'a', targetId: 'b' }, + { id: 'r2', type: 'CALLS', sourceId: 'a', targetId: 'b' }, + ], + }; + + const sigmaGraph = knowledgeGraphToTreeGraphology(graph); + + // MultiGraph allows multiple edges per pair — both CONTAINS and CALLS must survive. + expect(sigmaGraph.size).toBe(2); + + const attrsByType = new Map(); + sigmaGraph.forEachEdge((_edge, attrs) => { + attrsByType.set(attrs.relationType, attrs); + }); + + const containsAttrs = attrsByType.get('CONTAINS'); + expect(containsAttrs).toBeDefined(); + expect(containsAttrs!.isHierarchyEdge).toBe(true); + expect(containsAttrs!.color).toBe(EDGE_INFO.CONTAINS.color); + + const callsAttrs = attrsByType.get('CALLS'); + expect(callsAttrs).toBeDefined(); + expect(callsAttrs!.isHierarchyEdge).toBe(false); + expect(callsAttrs!.color).toBe(EDGE_INFO.CALLS.color); + }); + + it('should treat imports as cross-cutting edges in tree view', () => { + const graph: KnowledgeGraph = { + nodes: [makeNode('a', 'File', 'a.ts'), makeNode('b', 'File', 'b.ts')], + relationships: [{ id: 'r1', type: 'IMPORTS', sourceId: 'a', targetId: 'b' }], + }; + + const sigmaGraph = knowledgeGraphToTreeGraphology(graph); + + sigmaGraph.forEachEdge((edge, attrs) => { + if (attrs.relationType === 'IMPORTS') { + expect(attrs.isHierarchyEdge).toBe(false); + expect(attrs.color).toBe(EDGE_INFO.IMPORTS.color); + } + }); + }); + + it('should handle a medium-sized graph without dropping nodes or edges', () => { + // 2000 nodes + 4000 edges — exercises the adaptive spring iteration path (14 iters). + // Structural assertion only: wall-clock timing is too variable across CI machines. + const nodes: GraphNode[] = Array.from({ length: 2000 }, (_, i) => + makeNode(`n${i}`, i % 4 === 0 ? 'Folder' : i % 4 === 1 ? 'File' : 'Function', `node${i}`), + ); + const relationships = Array.from({ length: 4000 }, (_, i) => ({ + id: `r${i}`, + type: i % 3 === 0 ? 'CONTAINS' : 'CALLS', + sourceId: `n${i % 2000}`, + targetId: `n${(i + 7) % 2000}`, + })); + const graph: KnowledgeGraph = { nodes, relationships }; + + const sigmaGraph = knowledgeGraphToTreeGraphology(graph); + + // All nodes that have a tree-layout position must be present in the output. + expect(sigmaGraph.order).toBe(2000); + // Every relationship whose source and target both exist should produce an edge. + // Self-loops (sourceId === targetId) are excluded — the adapter skips them. + const selfLoops = relationships.filter((r) => r.sourceId === r.targetId).length; + expect(sigmaGraph.size).toBe(relationships.length - selfLoops); + }); +}); + +describe('knowledgeGraphToCirclesGraphology', () => { + it('should place nodes into ring positions based on their type', () => { + const graph: KnowledgeGraph = { + nodes: [ + makeNode('folder', 'Folder', 'src'), + makeNode('file', 'File', 'main.ts'), + makeNode('fn', 'Function', 'doSomething'), + ], + relationships: [ + { id: 'r1', type: 'CONTAINS', sourceId: 'folder', targetId: 'file' }, + { id: 'r2', type: 'CALLS', sourceId: 'file', targetId: 'fn' }, + ], + }; + + const sigmaGraph = knowledgeGraphToCirclesGraphology(graph); + + expect(sigmaGraph.hasNode('folder')).toBe(true); + expect(sigmaGraph.hasNode('file')).toBe(true); + expect(sigmaGraph.hasNode('fn')).toBe(true); + + // Each node carries its ring index and anchor coordinates + const folderAttrs = sigmaGraph.getNodeAttributes('folder'); + const fileAttrs = sigmaGraph.getNodeAttributes('file'); + const fnAttrs = sigmaGraph.getNodeAttributes('fn'); + + expect(typeof folderAttrs.circlesRing).toBe('number'); + expect(typeof folderAttrs.circlesAnchorX).toBe('number'); + expect(typeof folderAttrs.circlesAnchorY).toBe('number'); + + // Folders/Packages live in ring 0 (innermost); Files in ring 1; Functions in ring 3. + expect(folderAttrs.circlesRing).toBe(0); + expect(fileAttrs.circlesRing).toBe(1); + expect(fnAttrs.circlesRing).toBe(3); + + // Tree anchor attributes must NOT be set in circles mode + expect(folderAttrs.treeAnchorX).toBeUndefined(); + expect(folderAttrs.treeAnchorY).toBeUndefined(); + }); + + it('should style hierarchy edges differently from cross-cutting edges', () => { + const graph: KnowledgeGraph = { + nodes: [makeNode('a', 'File', 'a.ts'), makeNode('b', 'Function', 'fn')], + relationships: [ + { id: 'r1', type: 'CONTAINS', sourceId: 'a', targetId: 'b' }, + { id: 'r2', type: 'CALLS', sourceId: 'a', targetId: 'b' }, + ], + }; + + // MultiGraph allows multiple edges per pair — both CONTAINS and CALLS must survive. + const sigmaGraph = knowledgeGraphToCirclesGraphology(graph); + + expect(sigmaGraph.size).toBe(2); + + const attrsByType = new Map(); + sigmaGraph.forEachEdge((_, attrs) => { + attrsByType.set(attrs.relationType, attrs); + }); + + const containsAttrs = attrsByType.get('CONTAINS'); + expect(containsAttrs).toBeDefined(); + expect(containsAttrs!.isHierarchyEdge).toBe(true); + expect(containsAttrs!.color).toBe(EDGE_INFO.CONTAINS.color); + + const callsAttrs = attrsByType.get('CALLS'); + expect(callsAttrs).toBeDefined(); + expect(callsAttrs!.isHierarchyEdge).toBe(false); + expect(callsAttrs!.color).toBe(EDGE_INFO.CALLS.color); + }); + + it('should treat CALLS as a cross-cutting edge in circles view', () => { + const graph: KnowledgeGraph = { + nodes: [makeNode('a', 'Function', 'fnA'), makeNode('b', 'Function', 'fnB')], + relationships: [{ id: 'r1', type: 'CALLS', sourceId: 'a', targetId: 'b' }], + }; + + const sigmaGraph = knowledgeGraphToCirclesGraphology(graph); + + sigmaGraph.forEachEdge((_, attrs) => { + expect(attrs.isHierarchyEdge).toBe(false); + expect(attrs.color).toBe(EDGE_INFO.CALLS.color); + }); + }); +}); diff --git a/gitnexus-web/src/lib/graph-adapter.ts b/gitnexus-web/src/lib/graph-adapter.ts index 03caf8c92..bb3f5f11a 100644 --- a/gitnexus-web/src/lib/graph-adapter.ts +++ b/gitnexus-web/src/lib/graph-adapter.ts @@ -1,7 +1,9 @@ -import Graph from 'graphology'; +import Graph, { MultiGraph } from 'graphology'; import type { NodeLabel } from 'gitnexus-shared'; import type { KnowledgeGraph } from '../core/graph/types'; -import { NODE_COLORS, NODE_SIZES, getCommunityColor } from './constants'; +import { EDGE_INFO, NODE_COLORS, NODE_SIZES, getCommunityColor } from './constants'; +import { calculateTreeLayout } from './tree-layout'; +import { calculateCirclesLayout } from './circles-layout'; export interface SigmaNodeAttributes { x: number; @@ -17,6 +19,13 @@ export interface SigmaNodeAttributes { zIndex?: number; highlighted?: boolean; mass?: number; // ForceAtlas2 mass - higher = more repulsion + treeAnchorX?: number; + treeAnchorY?: number; + treeLayer?: number; + circlesAnchorX?: number; + circlesAnchorY?: number; + circlesRing?: number; + circlesAnchorAngle?: number; community?: number; // Community index from Leiden algorithm communityColor?: string; // Color assigned by community } @@ -28,6 +37,7 @@ export interface SigmaEdgeAttributes { type?: string; curvature?: number; zIndex?: number; + isHierarchyEdge?: boolean; } /** @@ -91,18 +101,21 @@ export const knowledgeGraphToGraphology = ( // Build parent-child map from hierarchy relationships // CONTAINS: Folder -> File // DEFINES: File -> Function/Class/Interface/Method - // IMPORTS: File -> Import - // parent -> children + // parent -> children (used only for initial spatial seeding before FA2 runs) const parentToChildren = new Map(); // child -> parent const childToParent = new Map(); - const hierarchyRelations = new Set(['CONTAINS', 'DEFINES', 'IMPORTS']); + // IMPORTS is not a true structural hierarchy, but treating it as a spatial + // seed helps FA2 converge for import-heavy codebases: files that import each + // other start near each other, so the simulation doesn't have to close many + // long cross-package springs from scratch. + const spatialSeedRelations = new Set(['CONTAINS', 'DEFINES', 'IMPORTS']); knowledgeGraph.relationships.forEach((rel) => { - // These relationships represent parent-child hierarchy for positioning - if (hierarchyRelations.has(rel.type)) { - // source CONTAINS/DEFINES/IMPORTS target, so source is parent + // These relationships determine initial node positions (not graph semantics) + if (spatialSeedRelations.has(rel.type)) { + // source CONTAINS/DEFINES/IMPORTS target → source acts as spatial parent if (!parentToChildren.has(rel.sourceId)) { parentToChildren.set(rel.sourceId, []); } @@ -295,23 +308,202 @@ export const knowledgeGraphToGraphology = ( // TYPE RELATIONSHIPS - Warm colors (OOP) EXTENDS: { color: '#c2410c', sizeMultiplier: 1.0 }, // Orange - extension IMPLEMENTS: { color: '#be185d', sizeMultiplier: 0.9 }, // Pink - interface implementation + + // KOTLIN/JAVA HIERARCHY — same hues as their logical equivalents so force + // mode renders these consistently with tree/circles view. + HAS_METHOD: { color: EDGE_INFO.DEFINES.color, sizeMultiplier: 0.4 }, // Class→Method (≈ DEFINES) + HAS_PROPERTY: { color: EDGE_INFO.CONTAINS.color, sizeMultiplier: 0.35 }, // Class→Property (≈ CONTAINS) + }; + + // Two-pass insertion so hierarchy/DEFINES edges are drawn first (behind) + // and cross-edges (CALLS, IMPORTS, EXTENDS) are drawn on top. + const BACKGROUND_EDGE_TYPES = new Set(['CONTAINS', 'DEFINES', 'HAS_METHOD', 'HAS_PROPERTY']); + + const addEdge = (rel: (typeof knowledgeGraph.relationships)[number]) => { + if (!graph.hasNode(rel.sourceId) || !graph.hasNode(rel.targetId)) return; + if (graph.hasEdge(rel.sourceId, rel.targetId)) return; + const style = EDGE_STYLES[rel.type] || { color: '#4a4a5a', sizeMultiplier: 0.5 }; + const curvature = 0.12 + Math.random() * 0.08; + graph.addEdge(rel.sourceId, rel.targetId, { + size: edgeBaseSize * style.sizeMultiplier, + color: style.color, + relationType: rel.type, + type: 'curved', + curvature, + }); + }; + + // Pass 1: background (hierarchy) edges — rendered behind + knowledgeGraph.relationships.forEach((rel) => { + if (BACKGROUND_EDGE_TYPES.has(rel.type)) addEdge(rel); + }); + // Pass 2: foreground (cross) edges — rendered on top + knowledgeGraph.relationships.forEach((rel) => { + if (!BACKGROUND_EDGE_TYPES.has(rel.type)) addEdge(rel); + }); + + return graph; +}; + +export const knowledgeGraphToTreeGraphology = ( + knowledgeGraph: KnowledgeGraph, +): Graph => { + const graph = new MultiGraph(); + const nodeCount = knowledgeGraph.nodes.length; + const positions = calculateTreeLayout(knowledgeGraph); + + // Add nodes with tree positions + for (const node of knowledgeGraph.nodes) { + const pos = positions.get(node.id); + if (!pos) continue; + + const baseSize = NODE_SIZES[node.label] || 8; + const scaledSize = getScaledNodeSize(baseSize, nodeCount); + const finalSize = Math.max(2, pos.size * (scaledSize / baseSize)); + + graph.addNode(node.id, { + x: pos.x, + y: pos.y, + size: finalSize, + color: NODE_COLORS[node.label] || '#9ca3af', + label: node.properties.name, + nodeType: node.label, + filePath: node.properties.filePath, + startLine: node.properties.startLine, + endLine: node.properties.endLine, + hidden: false, + mass: 1, // No force layout in tree view + treeAnchorX: pos.x, + treeAnchorY: pos.y, + treeLayer: pos.depth, + }); + } + + // Add edges with tree-specific styling + const edgeBaseSize = nodeCount > 20000 ? 0.4 : nodeCount > 5000 ? 0.6 : 1.0; + + const HIERARCHY_EDGE_STYLES: Record = { + CONTAINS: { color: EDGE_INFO.CONTAINS.color, sizeMultiplier: 0.3 }, + DEFINES: { color: EDGE_INFO.DEFINES.color, sizeMultiplier: 0.3 }, + HAS_METHOD: { color: EDGE_INFO.DEFINES.color, sizeMultiplier: 0.3 }, // Kotlin Class→Method hierarchy + HAS_PROPERTY: { color: EDGE_INFO.CONTAINS.color, sizeMultiplier: 0.25 }, // Kotlin Class→Property hierarchy + }; + + const CROSS_EDGE_STYLES: Record = { + IMPORTS: { color: EDGE_INFO.IMPORTS.color, sizeMultiplier: 0.6 }, + CALLS: { color: EDGE_INFO.CALLS.color, sizeMultiplier: 0.8 }, + EXTENDS: { color: EDGE_INFO.EXTENDS.color, sizeMultiplier: 1.0 }, + IMPLEMENTS: { color: EDGE_INFO.IMPLEMENTS.color, sizeMultiplier: 0.9 }, + }; + + // Two-pass insertion: hierarchy edges first (rendered behind), cross-edges on top. + // Dedup by relationship ID so CONTAINS + CALLS between the same pair both survive. + const addedTreeRelIds = new Set(); + const addTreeEdge = (rel: (typeof knowledgeGraph.relationships)[number]) => { + if (!graph.hasNode(rel.sourceId) || !graph.hasNode(rel.targetId)) return; + if (addedTreeRelIds.has(rel.id)) return; + addedTreeRelIds.add(rel.id); + const isHierarchy = HIERARCHY_EDGE_STYLES[rel.type] !== undefined; + const style = isHierarchy + ? HIERARCHY_EDGE_STYLES[rel.type] + : CROSS_EDGE_STYLES[rel.type] || { color: '#4a4a5a', sizeMultiplier: 0.5 }; + graph.addEdge(rel.sourceId, rel.targetId, { + size: edgeBaseSize * style.sizeMultiplier, + color: style.color, + relationType: rel.type, + type: 'curved', + curvature: 0.1 + Math.random() * 0.1, + isHierarchyEdge: isHierarchy, + }); }; knowledgeGraph.relationships.forEach((rel) => { - if (graph.hasNode(rel.sourceId) && graph.hasNode(rel.targetId)) { - if (!graph.hasEdge(rel.sourceId, rel.targetId)) { - const style = EDGE_STYLES[rel.type] || { color: '#4a4a5a', sizeMultiplier: 0.5 }; - const curvature = 0.12 + Math.random() * 0.08; + if (HIERARCHY_EDGE_STYLES[rel.type] !== undefined) addTreeEdge(rel); + }); + knowledgeGraph.relationships.forEach((rel) => { + if (HIERARCHY_EDGE_STYLES[rel.type] === undefined) addTreeEdge(rel); + }); - graph.addEdge(rel.sourceId, rel.targetId, { - size: edgeBaseSize * style.sizeMultiplier, - color: style.color, - relationType: rel.type, - type: 'curved', - curvature: curvature, - }); - } - } + return graph; +}; + +export const knowledgeGraphToCirclesGraphology = ( + knowledgeGraph: KnowledgeGraph, +): Graph => { + const graph = new MultiGraph(); + const nodeCount = knowledgeGraph.nodes.length; + const positions = calculateCirclesLayout(knowledgeGraph); + + for (const node of knowledgeGraph.nodes) { + const pos = positions.get(node.id); + if (!pos) continue; + + const baseSize = NODE_SIZES[node.label] || 8; + const scaledSize = getScaledNodeSize(baseSize, nodeCount); + const finalSize = Math.max(2, pos.size * (scaledSize / baseSize)); + + graph.addNode(node.id, { + x: pos.x, + y: pos.y, + size: finalSize, + color: NODE_COLORS[node.label] || '#9ca3af', + label: node.properties.name, + nodeType: node.label, + filePath: node.properties.filePath, + startLine: node.properties.startLine, + endLine: node.properties.endLine, + hidden: false, + mass: 1, + circlesAnchorX: pos.x, + circlesAnchorY: pos.y, + circlesRing: pos.ring, + circlesAnchorAngle: pos.angle, + }); + } + + const edgeBaseSize = nodeCount > 20000 ? 0.4 : nodeCount > 5000 ? 0.6 : 1.0; + + // Reuse the same edge style maps as tree view + const HIERARCHY_EDGE_STYLES: Record = { + CONTAINS: { color: EDGE_INFO.CONTAINS.color, sizeMultiplier: 0.3 }, + DEFINES: { color: EDGE_INFO.DEFINES.color, sizeMultiplier: 0.3 }, + HAS_METHOD: { color: EDGE_INFO.DEFINES.color, sizeMultiplier: 0.3 }, + HAS_PROPERTY: { color: EDGE_INFO.CONTAINS.color, sizeMultiplier: 0.25 }, + }; + + const CROSS_EDGE_STYLES: Record = { + IMPORTS: { color: EDGE_INFO.IMPORTS.color, sizeMultiplier: 0.6 }, + CALLS: { color: EDGE_INFO.CALLS.color, sizeMultiplier: 0.8 }, + EXTENDS: { color: EDGE_INFO.EXTENDS.color, sizeMultiplier: 1.0 }, + IMPLEMENTS: { color: EDGE_INFO.IMPLEMENTS.color, sizeMultiplier: 0.9 }, + }; + + // Two-pass insertion: hierarchy edges first (rendered behind), cross-edges on top. + // Dedup by relationship ID so CONTAINS + CALLS between the same pair both survive. + const addedCirclesRelIds = new Set(); + const addCirclesEdge = (rel: (typeof knowledgeGraph.relationships)[number]) => { + if (!graph.hasNode(rel.sourceId) || !graph.hasNode(rel.targetId)) return; + if (addedCirclesRelIds.has(rel.id)) return; + addedCirclesRelIds.add(rel.id); + const isHierarchy = HIERARCHY_EDGE_STYLES[rel.type] !== undefined; + const style = isHierarchy + ? HIERARCHY_EDGE_STYLES[rel.type] + : CROSS_EDGE_STYLES[rel.type] || { color: '#4a4a5a', sizeMultiplier: 0.5 }; + graph.addEdge(rel.sourceId, rel.targetId, { + size: edgeBaseSize * style.sizeMultiplier, + color: style.color, + relationType: rel.type, + type: 'curved', + curvature: 0.1 + Math.random() * 0.1, + isHierarchyEdge: isHierarchy, + }); + }; + + knowledgeGraph.relationships.forEach((rel) => { + if (HIERARCHY_EDGE_STYLES[rel.type] !== undefined) addCirclesEdge(rel); + }); + knowledgeGraph.relationships.forEach((rel) => { + if (HIERARCHY_EDGE_STYLES[rel.type] === undefined) addCirclesEdge(rel); }); return graph; diff --git a/gitnexus-web/src/lib/lucide-icons.tsx b/gitnexus-web/src/lib/lucide-icons.tsx index dc69b279f..7d9b5fc7d 100644 --- a/gitnexus-web/src/lib/lucide-icons.tsx +++ b/gitnexus-web/src/lib/lucide-icons.tsx @@ -79,6 +79,7 @@ export { Loader2, Maximize2, MousePointerClick, + Network, PanelLeft, PanelLeftClose, PanelRightClose, diff --git a/gitnexus-web/src/lib/tree-layout.test.ts b/gitnexus-web/src/lib/tree-layout.test.ts new file mode 100644 index 000000000..3858a59cd --- /dev/null +++ b/gitnexus-web/src/lib/tree-layout.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect } from 'vitest'; +import { calculateTreeLayout } from './tree-layout'; +import type { KnowledgeGraph } from '../core/graph/types'; +import type { GraphNode } from 'gitnexus-shared'; + +function makeNode(id: string, label: string, name: string): GraphNode { + return { + id, + label: label as any, + properties: { name, filePath: '', startLine: 1, endLine: 1 }, + }; +} + +describe('calculateTreeLayout', () => { + it('should place different types in correct layers', () => { + const graph: KnowledgeGraph = { + nodes: [ + makeNode('f1', 'Folder', 'src'), + makeNode('file1', 'File', 'main.ts'), + makeNode('cls1', 'Class', 'MyClass'), + makeNode('fn1', 'Function', 'myFunc'), + ], + relationships: [], + }; + + const positions = calculateTreeLayout(graph); + + const folderY = positions.get('f1')!.y; + const fileY = positions.get('file1')!.y; + const classY = positions.get('cls1')!.y; + const funcY = positions.get('fn1')!.y; + + // Layer ordering is visually inverted in tree view: + // Function < Class < File < Folder + expect(funcY).toBeLessThan(classY); + expect(classY).toBeLessThan(fileY); + expect(fileY).toBeLessThan(folderY); + }); + + it('should arrange many same-type nodes in a grid within a layer', () => { + const nodes: GraphNode[] = []; + for (let i = 0; i < 40; i++) { + nodes.push(makeNode(`fn${i}`, 'Function', `func${i}`)); + } + + const graph: KnowledgeGraph = { nodes, relationships: [] }; + const positions = calculateTreeLayout(graph); + + const xValues = nodes.map((n) => positions.get(n.id)!.x); + const yValues = nodes.map((n) => positions.get(n.id)!.y); + + // Should have multiple columns (spread horizontally) + const uniqueX = [...new Set(xValues)].sort((a, b) => a - b); + expect(uniqueX.length).toBeGreaterThan(3); + + // Should have multiple rows (spread vertically within layer) + const uniqueY = [...new Set(yValues)].sort((a, b) => a - b); + expect(uniqueY.length).toBeGreaterThan(1); + + // Overall width should be significant + const minX = Math.min(...xValues); + const maxX = Math.max(...xValues); + expect(maxX - minX).toBeGreaterThan(500); + + // Height spread within layer should be moderate (not a single line) + const minY = Math.min(...yValues); + const maxY = Math.max(...yValues); + expect(maxY - minY).toBeGreaterThan(50); + expect(maxY - minY).toBeLessThan(250); // But not too tall + }); + + it('should sort nodes alphabetically within layers', () => { + const graph: KnowledgeGraph = { + nodes: [ + makeNode('z', 'Function', 'zFn'), + makeNode('a', 'Function', 'aFn'), + makeNode('m', 'Function', 'mFn'), + ], + relationships: [], + }; + + const positions = calculateTreeLayout(graph); + + // In grid layout, 'a' should appear before 'm' and 'z' in reading order + // (left-to-right, top-to-bottom) + const aPos = positions.get('a')!; + const mPos = positions.get('m')!; + const zPos = positions.get('z')!; + + // Reading order: a comes before m, which comes before z + const aIndex = aPos.y * 10000 + aPos.x; + const mIndex = mPos.y * 10000 + mPos.x; + const zIndex = zPos.y * 10000 + zPos.x; + + expect(aIndex).toBeLessThan(mIndex); + expect(mIndex).toBeLessThan(zIndex); + }); + + it('should place multiple node types in correct layers', () => { + const graph: KnowledgeGraph = { + nodes: [ + makeNode('folder', 'Folder', 'src'), + makeNode('file', 'File', 'main.ts'), + makeNode('iface', 'Interface', 'MyInterface'), + makeNode('enum', 'Enum', 'MyEnum'), + makeNode('method', 'Method', 'myMethod'), + ], + relationships: [], + }; + + const positions = calculateTreeLayout(graph); + + // Folder now appears below files/types/methods in the inverted tree view + expect(positions.get('file')!.y).toBeLessThan(positions.get('folder')!.y); + + // File (layer 1) should be below Class/Interface/Enum (layer 2) + expect(positions.get('iface')!.y).toBeLessThan(positions.get('file')!.y); + expect(positions.get('enum')!.y).toBeLessThan(positions.get('file')!.y); + + // Interface/Enum (layer 2) should be below Method (layer 3) + expect(positions.get('method')!.y).toBeLessThan(positions.get('iface')!.y); + expect(positions.get('method')!.y).toBeLessThan(positions.get('enum')!.y); + }); + + it('should keep node sizes reasonable', () => { + const graph: KnowledgeGraph = { + nodes: [ + makeNode('folder', 'Folder', 'src'), + makeNode('file', 'File', 'main.ts'), + makeNode('fn', 'Function', 'myFunc'), + ], + relationships: [], + }; + + const positions = calculateTreeLayout(graph); + + for (const id of ['folder', 'file', 'fn']) { + expect(positions.get(id)!.size).toBeGreaterThan(2); + expect(positions.get(id)!.size).toBeLessThan(25); + } + }); + + it('should spread sibling branches under their structural parent in auto mode', () => { + const graph: KnowledgeGraph = { + nodes: [ + makeNode('folder', 'Folder', 'apps'), + makeNode('fileA', 'File', 'a.ts'), + makeNode('fileB', 'File', 'b.ts'), + makeNode('fileC', 'File', 'c.ts'), + makeNode('fnA', 'Function', 'fnA'), + makeNode('fnB', 'Function', 'fnB'), + makeNode('fnC', 'Function', 'fnC'), + ], + relationships: [ + { id: 'r1', type: 'CONTAINS', sourceId: 'folder', targetId: 'fileA' }, + { id: 'r2', type: 'CONTAINS', sourceId: 'folder', targetId: 'fileB' }, + { id: 'r3', type: 'CONTAINS', sourceId: 'folder', targetId: 'fileC' }, + { id: 'r4', type: 'DEFINES', sourceId: 'fileA', targetId: 'fnA' }, + { id: 'r5', type: 'DEFINES', sourceId: 'fileB', targetId: 'fnB' }, + { id: 'r6', type: 'DEFINES', sourceId: 'fileC', targetId: 'fnC' }, + ], + }; + + const positions = calculateTreeLayout(graph); + const fileXs = ['fileA', 'fileB', 'fileC'].map((id) => positions.get(id)!.x); + const fnXs = ['fnA', 'fnB', 'fnC'].map((id) => positions.get(id)!.x); + + expect(Math.max(...fileXs) - Math.min(...fileXs)).toBeGreaterThan(120); + expect(Math.max(...fnXs) - Math.min(...fnXs)).toBeGreaterThan(120); + expect(Math.abs(positions.get('fileA')!.x - positions.get('fnA')!.x)).toBeLessThan(120); + expect(Math.abs(positions.get('fileB')!.x - positions.get('fnB')!.x)).toBeLessThan(120); + expect(Math.abs(positions.get('fileC')!.x - positions.get('fnC')!.x)).toBeLessThan(120); + }); + + it('should let long edges pull connected nodes closer without breaking their layer', () => { + const nodes = Array.from({ length: 10 }, (_, i) => makeNode(`fn${i}`, 'Function', `fn${i}`)); + + const baseline = calculateTreeLayout({ nodes, relationships: [] }); + const relaxed = calculateTreeLayout({ + nodes, + relationships: [ + { id: 'r1', type: 'CALLS', sourceId: 'fn0', targetId: 'fn9' }, + { id: 'r2', type: 'CALLS', sourceId: 'fn1', targetId: 'fn8' }, + ], + }); + + const baselineDistance = Math.abs(baseline.get('fn0')!.x - baseline.get('fn9')!.x); + const relaxedDistance = Math.abs(relaxed.get('fn0')!.x - relaxed.get('fn9')!.x); + expect(relaxedDistance).toBeLessThan(baselineDistance); + + const relaxedYValues = nodes.map((node) => relaxed.get(node.id)!.y); + const minY = Math.min(...relaxedYValues); + const maxY = Math.max(...relaxedYValues); + expect(maxY - minY).toBeGreaterThan(50); + expect(maxY - minY).toBeLessThan(250); + }); + + it('should preserve layer spread under heavy edge attraction', () => { + const nodes: GraphNode[] = [makeNode('file', 'File', 'hub.ts')]; + for (let i = 0; i < 18; i++) { + nodes.push(makeNode(`fn${i}`, 'Function', `fn${i}`)); + } + + const relationships = Array.from({ length: 18 }, (_, i) => ({ + id: `r${i}`, + type: 'CALLS', + sourceId: `fn${i}`, + targetId: 'file', + })); + + const positions = calculateTreeLayout({ nodes, relationships }); + const functionXs = Array.from({ length: 18 }, (_, i) => positions.get(`fn${i}`)!.x); + + expect(Math.max(...functionXs) - Math.min(...functionXs)).toBeGreaterThan(280); + expect(positions.get('file')!.y).toBeGreaterThan(positions.get('fn0')!.y); + }); +}); diff --git a/gitnexus-web/src/lib/tree-layout.ts b/gitnexus-web/src/lib/tree-layout.ts new file mode 100644 index 000000000..5fb284d5c --- /dev/null +++ b/gitnexus-web/src/lib/tree-layout.ts @@ -0,0 +1,570 @@ +import type { KnowledgeGraph } from '../core/graph/types'; +import type { GraphNode, NodeLabel } from 'gitnexus-shared'; +import { NODE_SIZES } from './constants'; + +export interface TreeNodePosition { + x: number; + y: number; + size: number; + depth: number; +} + +/** + * Maps node types to display layers in the tree view. + * Layer 0 = top (containers), Layer 3 = bottom (functions/methods). + */ +const TYPE_TO_LAYER: Record = { + // Layer 0: Structural containers + Project: 0, + Package: 0, + Module: 0, + Folder: 0, + Namespace: 0, + + // Layer 1: Files + File: 1, + Section: 1, + Import: 1, + Route: 1, + Tool: 1, + + // Layer 2: Type definitions + Class: 2, + Interface: 2, + Enum: 2, + Type: 2, + Struct: 2, + Trait: 2, + Union: 2, + Record: 2, + Typedef: 2, + Template: 2, + TypeAlias: 2, + + // Layer 3: Functions / Methods + Function: 3, + Method: 3, + Impl: 3, + Delegate: 3, + Constructor: 3, + Variable: 3, + Const: 3, + Static: 3, + Property: 3, + Decorator: 3, + Annotation: 3, + Macro: 3, + CodeElement: 3, +}; + +/** Fallback layer for unmapped types. */ +const DEFAULT_LAYER = 1; + +/** Virtual canvas size for layout calculation. */ +const CANVAS_WIDTH = 1200; +const CANVAS_HEIGHT = 800; +const LAYER_COUNT = 4; +const LAYER_HEIGHT = CANVAS_HEIGHT / LAYER_COUNT; // 200 +const PADDING_X = 60; +const PADDING_Y = 15; +const MIN_NODE_GAP = 45; +const MAX_LAYER_ROW_SPREAD = 132; +// HAS_METHOD and HAS_PROPERTY are Kotlin/Java-style hierarchy edges +// (Class→Method, Class→Property). Treat them like DEFINES for layout purposes +// so Methods/Properties cluster beneath their parent Class horizontally. +const HIERARCHY_RELATIONS = new Set(['CONTAINS', 'DEFINES', 'HAS_METHOD', 'HAS_PROPERTY']); +const MAX_X = (CANVAS_WIDTH - PADDING_X * 2) / 2; + +const RELATION_SPRING_WEIGHTS: Record = { + CONTAINS: 0.12, + DEFINES: 0.16, + HAS_METHOD: 0.16, // Same as DEFINES — keeps methods near their class + HAS_PROPERTY: 0.14, // Slightly weaker — properties can spread more + IMPORTS: 0.2, + CALLS: 0.24, + EXTENDS: 0.18, + IMPLEMENTS: 0.18, +}; + +function calculateNodeSize(layer: number, nodeType: NodeLabel): number { + const baseSize = NODE_SIZES[nodeType] || 6; + const layerMultiplier = Math.max(0.6, 1 - layer * 0.12); + return baseSize * layerMultiplier; +} + +function deterministicHash(str: string): number { + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = (hash << 5) + hash + str.charCodeAt(i); + hash |= 0; + } + return (Math.abs(hash) % 10000) / 10000; +} + +function getNodeLayer(node: GraphNode): number { + return TYPE_TO_LAYER[node.label] ?? DEFAULT_LAYER; +} + +function buildHierarchyMaps(graph: KnowledgeGraph) { + const childrenByParent = new Map(); + const parentsByChild = new Map(); + + for (const rel of graph.relationships) { + if (!HIERARCHY_RELATIONS.has(rel.type)) continue; + + if (!childrenByParent.has(rel.sourceId)) { + childrenByParent.set(rel.sourceId, []); + } + childrenByParent.get(rel.sourceId)!.push(rel.targetId); + + if (!parentsByChild.has(rel.targetId)) { + parentsByChild.set(rel.targetId, []); + } + parentsByChild.get(rel.targetId)!.push(rel.sourceId); + } + + return { childrenByParent, parentsByChild }; +} + +function buildLayerNodeIds(graph: KnowledgeGraph): string[][] { + const nodeIdsByLayer: string[][] = Array.from({ length: LAYER_COUNT }, () => []); + + for (const node of graph.nodes) { + const layer = getNodeLayer(node); + if (layer >= 0 && layer < LAYER_COUNT) { + nodeIdsByLayer[layer].push(node.id); + } + } + + return nodeIdsByLayer; +} + +function getRestEdgeLength( + relationType: string, + source: TreeNodePosition, + target: TreeNodePosition, +) { + const depthGap = Math.abs(source.depth - target.depth); + const baseLength = HIERARCHY_RELATIONS.has(relationType) ? 60 : 85; + return baseLength + depthGap * 40; +} + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +function getLayerRowOffsets(nodeCount: number): number[] { + if (nodeCount <= 4) return [0]; + + const rowCount = nodeCount <= 16 ? 2 : 3; + const totalSpread = rowCount === 2 ? 72 : MAX_LAYER_ROW_SPREAD; + const rowGap = totalSpread / (rowCount - 1); + + return Array.from({ length: rowCount }, (_, rowIndex) => -totalSpread / 2 + rowIndex * rowGap); +} + +function placeNodesInSlice( + positions: Map, + nodes: GraphNode[], + startX: number, + slotWidth: number, + layerY: number, + layer: number, +) { + const rowOffsets = getLayerRowOffsets(nodes.length); + const rowCount = rowOffsets.length; + const baseNodesPerRow = Math.floor(nodes.length / rowCount); + const remainder = nodes.length % rowCount; + + let cursor = 0; + + for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) { + const nodesInRow = baseNodesPerRow + (rowIndex < remainder ? 1 : 0); + if (nodesInRow === 0) continue; + + const rowSpacing = slotWidth / nodesInRow; + for (let i = 0; i < nodesInRow; i++) { + const node = nodes[cursor++]; + positions.set(node.id, { + x: startX + (i + 0.5) * rowSpacing, + y: layerY + rowOffsets[rowIndex], + size: calculateNodeSize(layer, node.label), + depth: layer, + }); + } + } +} + +function enforceLayerSpacing( + layerNodeIds: string[], + positions: Map, + anchorXByNode: Map, +) { + if (layerNodeIds.length < 2) return; + + const sortedIds = [...layerNodeIds].sort((a, b) => positions.get(a)!.x - positions.get(b)!.x); + + for (let pass = 0; pass < 2; pass++) { + for (let i = 1; i < sortedIds.length; i++) { + const prev = positions.get(sortedIds[i - 1])!; + const curr = positions.get(sortedIds[i])!; + const minGap = Math.max(MIN_NODE_GAP * 0.65, (prev.size + curr.size) * 1.7); + const gap = curr.x - prev.x; + + if (gap < minGap) { + const push = (minGap - gap) / 2; + prev.x -= push; + curr.x += push; + } + } + + for (let i = sortedIds.length - 2; i >= 0; i--) { + const curr = positions.get(sortedIds[i])!; + const next = positions.get(sortedIds[i + 1])!; + const minGap = Math.max(MIN_NODE_GAP * 0.65, (curr.size + next.size) * 1.7); + const gap = next.x - curr.x; + + if (gap < minGap) { + const push = (minGap - gap) / 2; + curr.x -= push; + next.x += push; + } + } + } + + const anchorCenter = + sortedIds.reduce((sum, nodeId) => sum + (anchorXByNode.get(nodeId) ?? 0), 0) / sortedIds.length; + const currentCenter = + sortedIds.reduce((sum, nodeId) => sum + positions.get(nodeId)!.x, 0) / sortedIds.length; + const recenterDelta = currentCenter - anchorCenter; + + for (const nodeId of sortedIds) { + const pos = positions.get(nodeId)!; + pos.x = clamp(pos.x - recenterDelta, -MAX_X, MAX_X); + } +} + +/** + * Initialize positions using proportional X allocation. + * + * Each parent in layer N is allocated a horizontal slice proportional to how + * many direct hierarchy children it has in layer N+1. Children are then placed + * evenly within their parent's slice. Orphan nodes (no placed hierarchy parent) + * fill a proportional slice at the far right. + * + * Why this is better than uniform distribution: + * 1. Dense parents (many children) get more canvas space → no artificial + * crowding in the centre even before the physics simulation runs. + * 2. Each child starts within its parent's X slice → parent-child edges are + * short by construction, so the spring system converges quickly. + * 3. Orphan nodes land at the right end; their spring connections pull them + * toward better positions at runtime without fighting a spread force. + */ +function initProportionalPositions( + graph: KnowledgeGraph, + parentsByChild: Map, +): Map { + const positions = new Map(); + + // Group nodes by layer and build a fast layer-lookup map. + const nodesByLayer: GraphNode[][] = Array.from({ length: LAYER_COUNT }, () => []); + const nodeLayerMap = new Map(); + for (const node of graph.nodes) { + const layer = getNodeLayer(node); + if (layer >= 0 && layer < LAYER_COUNT) { + nodesByLayer[layer].push(node); + nodeLayerMap.set(node.id, layer); + } + } + + const availableWidth = CANVAS_WIDTH - PADDING_X * 2; + const halfWidth = availableWidth / 2; + const availableHeight = LAYER_HEIGHT - PADDING_Y * 2; + + // Y centre for a given logical layer (layer 0 = top). + const getLayerY = (layer: number): number => { + const visualLayer = LAYER_COUNT - 1 - layer; + return visualLayer * LAYER_HEIGHT + PADDING_Y + availableHeight / 2; + }; + + // --- Layer 0: sorted alphabetically, evenly spaced --- + const layer0Nodes = [...nodesByLayer[0]].sort((a, b) => + a.properties.name.localeCompare(b.properties.name), + ); + if (layer0Nodes.length > 0) { + const spacing = availableWidth / layer0Nodes.length; + for (let i = 0; i < layer0Nodes.length; i++) { + const node = layer0Nodes[i]; + positions.set(node.id, { + x: -halfWidth + (i + 0.5) * spacing, + y: getLayerY(0), + size: calculateNodeSize(0, node.label), + depth: 0, + }); + } + } + + // --- Layers 1-3: proportional allocation from their parents --- + for (let layer = 1; layer < LAYER_COUNT; layer++) { + const layerNodes = nodesByLayer[layer]; + if (layerNodes.length === 0) continue; + + const layerY = getLayerY(layer); + + // For each node, find its "primary parent": the already-placed hierarchy + // parent with the highest layer index (= closest ancestor in the tree). + // Walking all parents and picking the deepest-placed one means a Method + // prefers its Class over a distant Package, for example. + const assignedParent = new Map(); + for (const node of layerNodes) { + const parents = parentsByChild.get(node.id) ?? []; + let bestParent: string | null = null; + let bestParentLayer = -1; + for (const p of parents) { + if (!positions.has(p)) continue; // not yet placed + const pLayer = nodeLayerMap.get(p) ?? -1; + if (pLayer > bestParentLayer) { + bestParentLayer = pLayer; + bestParent = p; + } + } + if (bestParent) assignedParent.set(node.id, bestParent); + } + + // Bucket nodes into parent groups or orphans. + const childrenOfParent = new Map(); + const orphans: GraphNode[] = []; + for (const node of layerNodes) { + const p = assignedParent.get(node.id); + if (!p) { + orphans.push(node); + } else { + if (!childrenOfParent.has(p)) childrenOfParent.set(p, []); + childrenOfParent.get(p)!.push(node); + } + } + + // Sort within each parent's group and orphans alphabetically. + for (const children of childrenOfParent.values()) { + children.sort((a, b) => a.properties.name.localeCompare(b.properties.name)); + } + orphans.sort((a, b) => a.properties.name.localeCompare(b.properties.name)); + + // Sort active parents left-to-right by their placed X position. + const activeParents = [...childrenOfParent.keys()].sort( + (a, b) => (positions.get(a)?.x ?? 0) - (positions.get(b)?.x ?? 0), + ); + + const totalParented = layerNodes.length - orphans.length; + + // Divide the full canvas width: + // • parented children → (totalParented / total) fraction of width + // • orphans → remaining fraction at the right + const parentedWidth = + totalParented > 0 ? availableWidth * (totalParented / layerNodes.length) : 0; + const orphanWidth = availableWidth - parentedWidth; + + let curX = -halfWidth; + + // Place each parent's children in a sub-slice proportional to child count. + for (const parentId of activeParents) { + const children = childrenOfParent.get(parentId) ?? []; + if (children.length === 0) continue; + + const slotWidth = (children.length / totalParented) * parentedWidth; + placeNodesInSlice(positions, children, curX, slotWidth, layerY, layer); + curX += slotWidth; + } + + // Orphans fill the rightmost slice. + if (orphans.length > 0 && orphanWidth > 0) { + placeNodesInSlice(positions, orphans, curX, orphanWidth, layerY, layer); + } + } + + // Shift Y so the layout is centred at y = 0. + const centerY = CANVAS_HEIGHT / 2; + for (const pos of positions.values()) { + pos.y -= centerY; + } + + return positions; +} + +/** + * Tree view layout: type-layered grid with organic jitter and + * structure-aware horizontal branch shaping. + */ +export function calculateTreeLayout(graph: KnowledgeGraph): Map { + // Build hierarchy maps before initial placement so initProportionalPositions + // can assign each node to its closest placed ancestor's X slice. + const nodeIdsByLayer = buildLayerNodeIds(graph); + const { childrenByParent, parentsByChild } = buildHierarchyMaps(graph); + + // 1. Start with proportional X allocation: each parent gets a canvas slice + // proportional to its child count, so dense subtrees never crowd the centre. + const positions = initProportionalPositions(graph, parentsByChild); + + // 2. Add subtle Y jitter only — X jitter would scramble the hierarchy ordering + // that initProportionalPositions established (especially bad when node spacing < jitter). + for (const [nodeId, pos] of positions) { + pos.y += (deterministicHash(nodeId + 'y') - 0.5) * 20; + } + + // 3. Use structural edges to create a tree-like horizontal ordering while + // preserving the type-based vertical layers. + const STRUCTURE_ITERATIONS = 6; + for (let iter = 0; iter < STRUCTURE_ITERATIONS; iter++) { + const childTargets = new Map(); + + for (const [parentId, children] of childrenByParent) { + const parentPos = positions.get(parentId); + if (!parentPos || children.length === 0) continue; + + const childPositions = children + .map((childId) => ({ childId, pos: positions.get(childId) })) + .filter( + (entry): entry is { childId: string; pos: TreeNodePosition } => entry.pos !== undefined, + ) + .sort((a, b) => a.pos.x - b.pos.x); + + if (childPositions.length === 0) continue; + + const currentCenter = + childPositions.reduce((sum, entry) => sum + entry.pos.x, 0) / childPositions.length; + const shift = parentPos.x - currentCenter; + + for (const entry of childPositions) { + const existing = childTargets.get(entry.childId) || { sum: 0, count: 0 }; + existing.sum += entry.pos.x + shift; + existing.count += 1; + childTargets.set(entry.childId, existing); + } + } + + for (const [nodeId, target] of childTargets) { + const pos = positions.get(nodeId); + if (!pos) continue; + const avgTargetX = target.sum / target.count; + pos.x = pos.x * 0.45 + avgTargetX * 0.55; + } + + const parentTargets = new Map(); + for (const [parentId, children] of childrenByParent) { + const parentPos = positions.get(parentId); + if (!parentPos || children.length === 0) continue; + + const childXs = children + .map((childId) => positions.get(childId)?.x) + .filter((value): value is number => value !== undefined); + + if (childXs.length === 0) continue; + + const avgChildX = childXs.reduce((sum, value) => sum + value, 0) / childXs.length; + const existing = parentTargets.get(parentId) || { sum: 0, count: 0 }; + existing.sum += avgChildX; + existing.count += 1; + parentTargets.set(parentId, existing); + } + + for (const [nodeId, target] of parentTargets) { + const pos = positions.get(nodeId); + if (!pos) continue; + const avgTargetX = target.sum / target.count; + pos.x = pos.x * 0.65 + avgTargetX * 0.35; + } + } + + // 4. Pull childless nodes slightly toward their hierarchy parents when the + // graph has enough structure information to form branches. + for (const [nodeId, parents] of parentsByChild) { + if (childrenByParent.has(nodeId)) continue; + const pos = positions.get(nodeId); + if (!pos || parents.length === 0) continue; + + const parentXs = parents + .map((parentId) => positions.get(parentId)?.x) + .filter((value): value is number => value !== undefined); + + if (parentXs.length === 0) continue; + + const avgParentX = parentXs.reduce((sum, value) => sum + value, 0) / parentXs.length; + pos.x = pos.x * 0.7 + avgParentX * 0.3; + } + + // 5. Keep a per-node horizontal anchor so long edges can pull nodes closer + // without destroying each layer's original spread. + const anchorXByNode = new Map(); + for (const [nodeId, pos] of positions) { + anchorXByNode.set(nodeId, pos.x); + } + + // 6. Relax the graph like a constrained spring system. Only X is allowed + // to move, so node types stay on their original Y layers. + // For large graphs the spring phase is O(N×E×iterations) and would freeze + // the main thread — scale it down proportionally so the initial proportional + // layout (already good at large N) is kept without expensive refinement. + const nodeCount = graph.nodes.length; + const SPRING_ITERATIONS = nodeCount > 10000 ? 0 : nodeCount > 3000 ? 4 : 14; + for (let iter = 0; iter < SPRING_ITERATIONS; iter++) { + const deltaXByNode = new Map(); + + for (const [nodeId, pos] of positions) { + const anchorX = anchorXByNode.get(nodeId) ?? pos.x; + const normalizedDistance = Math.min(1, Math.abs(pos.x) / MAX_X); + const anchorStrength = 0.05 + normalizedDistance * normalizedDistance * 0.1; + deltaXByNode.set(nodeId, (anchorX - pos.x) * anchorStrength); + } + + for (const rel of graph.relationships) { + const sourcePos = positions.get(rel.sourceId); + const targetPos = positions.get(rel.targetId); + if (!sourcePos || !targetPos) continue; + + const dx = targetPos.x - sourcePos.x; + const dy = targetPos.y - sourcePos.y; + const distance = Math.sqrt(dx * dx + dy * dy) || 1; + const restLength = getRestEdgeLength(rel.type, sourcePos, targetPos); + const stretch = distance - restLength; + + if (stretch <= 0) continue; + + const springWeight = RELATION_SPRING_WEIGHTS[rel.type] ?? 0.14; + const pull = stretch * springWeight * 0.08; + const forceX = (dx / distance) * pull; + + deltaXByNode.set(rel.sourceId, (deltaXByNode.get(rel.sourceId) ?? 0) + forceX); + deltaXByNode.set(rel.targetId, (deltaXByNode.get(rel.targetId) ?? 0) - forceX); + } + + for (const [nodeId, pos] of positions) { + const deltaX = deltaXByNode.get(nodeId) ?? 0; + const normalizedDistance = Math.min(1, Math.abs(pos.x) / MAX_X); + const edgeResistance = 1 + normalizedDistance * normalizedDistance * 4.5; + const maxStep = 18 - normalizedDistance * 6; + const step = clamp(deltaX / edgeResistance, -maxStep, maxStep); + pos.x = clamp(pos.x + step, -MAX_X, MAX_X); + } + + for (const layerNodeIds of nodeIdsByLayer) { + enforceLayerSpacing(layerNodeIds, positions, anchorXByNode); + } + } + + // 7. Recenter and softly clamp X so the layout keeps its breadth without + // drifting too far off-canvas. + const xValues = Array.from(positions.values()).map((pos) => pos.x); + if (xValues.length > 0) { + const minX = Math.min(...xValues); + const maxX = Math.max(...xValues); + const centerX = (minX + maxX) / 2; + const halfSpan = Math.max(1, (maxX - minX) / 2); + const scale = halfSpan > MAX_X ? MAX_X / halfSpan : 1; + + for (const pos of positions.values()) { + pos.x = (pos.x - centerX) * scale; + } + } + + return positions; +} diff --git a/gitnexus-web/src/locales/en/chat.json b/gitnexus-web/src/locales/en/chat.json index 8fd9719bc..4d976c335 100644 --- a/gitnexus-web/src/locales/en/chat.json +++ b/gitnexus-web/src/locales/en/chat.json @@ -24,6 +24,7 @@ "clearChat": "Clear chat", "stopResponse": "Stop response" }, + "stopped": "Stopped by user", "badges": { "configureAI": "Configure AI", "connecting": "Connecting" diff --git a/gitnexus-web/src/locales/en/graph.json b/gitnexus-web/src/locales/en/graph.json index 6f4bdb66f..072e47c2d 100644 --- a/gitnexus-web/src/locales/en/graph.json +++ b/gitnexus-web/src/locales/en/graph.json @@ -10,7 +10,8 @@ "status": { "running": "running", "completed": "completed", - "error": "error" + "error": "error", + "stopped": "stopped" }, "tools": { "search": "🔍 Search Code", @@ -112,6 +113,12 @@ "codeNotAvailable": "Code not available in memory for {{path}}" }, "canvas": { + "viewModes": { + "label": "Graph view mode", + "force": "Force Graph", + "tree": "Sequential Layout", + "circles": "Radial Layout" + }, "zoomIn": "Zoom In", "zoomOut": "Zoom Out", "fit": "Fit to Screen", diff --git a/gitnexus-web/src/locales/zh-CN/chat.json b/gitnexus-web/src/locales/zh-CN/chat.json index 366fb7ccb..f83c5ad75 100644 --- a/gitnexus-web/src/locales/zh-CN/chat.json +++ b/gitnexus-web/src/locales/zh-CN/chat.json @@ -24,6 +24,7 @@ "clearChat": "清空聊天", "stopResponse": "停止响应" }, + "stopped": "用户已停止", "badges": { "configureAI": "配置 AI", "connecting": "连接中" diff --git a/gitnexus-web/src/locales/zh-CN/graph.json b/gitnexus-web/src/locales/zh-CN/graph.json index d65689f8a..7fc7c71e7 100644 --- a/gitnexus-web/src/locales/zh-CN/graph.json +++ b/gitnexus-web/src/locales/zh-CN/graph.json @@ -10,7 +10,8 @@ "status": { "running": "运行中", "completed": "已完成", - "error": "错误" + "error": "错误", + "stopped": "已停止" }, "tools": { "search": "🔍 搜索代码", @@ -112,6 +113,12 @@ "codeNotAvailable": "内存中没有 {{path}} 的代码内容" }, "canvas": { + "viewModes": { + "label": "图形视图模式", + "force": "力导向图", + "tree": "顺序布局", + "circles": "径向布局" + }, "zoomIn": "放大", "zoomOut": "缩小", "fit": "适应屏幕", diff --git a/gitnexus-web/test/unit/agent-abort.test.ts b/gitnexus-web/test/unit/agent-abort.test.ts new file mode 100644 index 000000000..2a8475e34 --- /dev/null +++ b/gitnexus-web/test/unit/agent-abort.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { streamAgentResponse, type AgentMessage } from '../../src/core/llm/agent'; + +describe('streamAgentResponse abort', () => { + const userMessage: AgentMessage[] = [{ role: 'user', content: 'hello' }]; + + it('yields cancelled when the LangGraph stream throws AbortError', async () => { + const agent = { + stream: async () => { + throw new DOMException('The operation was aborted', 'AbortError'); + }, + }; + + const chunks = []; + for await (const chunk of streamAgentResponse(agent as any, userMessage, { + signal: new AbortController().signal, + })) { + chunks.push(chunk); + } + + expect(chunks).toEqual([{ type: 'cancelled' }]); + }); + + it('yields cancelled when the abort signal is set mid-stream', async () => { + const controller = new AbortController(); + const agent = { + stream: async function* () { + yield ['values', { messages: [] }]; + controller.abort(); + for (let i = 0; i < 100; i++) { + yield ['messages', [{ _getType: () => 'ai', content: 'still going' }]]; + } + }, + }; + + const chunks = []; + for await (const chunk of streamAgentResponse(agent as any, userMessage, { + signal: controller.signal, + })) { + chunks.push(chunk); + if (chunk.type === 'cancelled') break; + } + + expect(chunks[chunks.length - 1]).toEqual({ type: 'cancelled' }); + expect(chunks.filter((c) => c.type === 'error')).toEqual([]); + }); + + it('passes AbortSignal to agent.stream config', async () => { + const controller = new AbortController(); + let capturedConfig: Record | undefined; + + const agent = { + stream: async (_input: unknown, config: Record) => { + capturedConfig = config; + throw new DOMException('aborted', 'AbortError'); + }, + }; + + for await (const _chunk of streamAgentResponse(agent as any, userMessage, { + signal: controller.signal, + })) { + // drain + } + + expect(capturedConfig?.signal).toBe(controller.signal); + }); + + it('yields cancelled for a plain Error with name AbortError', async () => { + const agent = { + stream: async () => { + throw Object.assign(new Error('aborted'), { name: 'AbortError' }); + }, + }; + + const chunks = []; + for await (const chunk of streamAgentResponse(agent as any, userMessage)) { + chunks.push(chunk); + } + + expect(chunks).toEqual([{ type: 'cancelled' }]); + }); + + it('does not treat unrelated errors mentioning abort as cancellation', async () => { + const agent = { + stream: async () => { + throw new Error('Cannot abort the current transaction'); + }, + }; + + const chunks = []; + for await (const chunk of streamAgentResponse(agent as any, userMessage)) { + chunks.push(chunk); + } + + expect(chunks).toEqual([{ type: 'error', error: 'Cannot abort the current transaction' }]); + }); +}); diff --git a/gitnexus-web/test/unit/filter-panel.test.ts b/gitnexus-web/test/unit/filter-panel.test.ts index 93b691b3f..67ab3397c 100644 --- a/gitnexus-web/test/unit/filter-panel.test.ts +++ b/gitnexus-web/test/unit/filter-panel.test.ts @@ -28,6 +28,8 @@ const ICON_MAP: Record = { Decorator: 'AtSign', Import: 'FileCode', Variable: 'Variable', + Property: 'Variable', + Const: 'Target', }; describe('filter panel icon mappings', () => { diff --git a/gitnexus-web/vitest.config.ts b/gitnexus-web/vitest.config.ts index e1460d840..1de8ef065 100644 --- a/gitnexus-web/vitest.config.ts +++ b/gitnexus-web/vitest.config.ts @@ -25,7 +25,7 @@ export default defineConfig({ globals: true, environment: 'jsdom', setupFiles: ['./test/setup.ts'], - include: ['test/**/*.test.{ts,tsx}'], + include: ['test/**/*.test.{ts,tsx}', 'src/**/*.test.{ts,tsx}'], testTimeout: 15000, coverage: { provider: 'v8', 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/package-lock.json b/gitnexus/package-lock.json index 3ba1bae5e..4a7575c02 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -1307,9 +1307,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", - "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", "dev": true, "license": "MIT", "funding": { @@ -1387,9 +1387,9 @@ "license": "BSD-3-Clause" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", - "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", "cpu": [ "arm64" ], @@ -1404,9 +1404,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", - "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", "cpu": [ "arm64" ], @@ -1421,9 +1421,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", - "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", "cpu": [ "x64" ], @@ -1438,9 +1438,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", - "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", "cpu": [ "x64" ], @@ -1455,9 +1455,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", - "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", "cpu": [ "arm" ], @@ -1472,9 +1472,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", - "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", "cpu": [ "arm64" ], @@ -1489,9 +1489,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", - "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", "cpu": [ "arm64" ], @@ -1506,9 +1506,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", - "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", "cpu": [ "ppc64" ], @@ -1523,9 +1523,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", - "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", "cpu": [ "s390x" ], @@ -1540,9 +1540,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", - "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", "cpu": [ "x64" ], @@ -1557,9 +1557,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", - "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", "cpu": [ "x64" ], @@ -1574,9 +1574,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", - "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", "cpu": [ "arm64" ], @@ -1591,9 +1591,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", - "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", "cpu": [ "wasm32" ], @@ -1621,9 +1621,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", - "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", "cpu": [ "arm64" ], @@ -1638,9 +1638,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", - "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", "cpu": [ "x64" ], @@ -1792,9 +1792,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.8.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz", - "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==", + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", "license": "MIT", "dependencies": { "undici-types": ">=7.24.0 <7.24.7" @@ -1847,14 +1847,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.6.tgz", - "integrity": "sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", + "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.6", + "@vitest/utils": "4.1.7", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -1868,8 +1868,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.6", - "vitest": "4.1.6" + "@vitest/browser": "4.1.7", + "vitest": "4.1.7" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1878,16 +1878,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz", - "integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.6", - "@vitest/utils": "4.1.6", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1896,13 +1896,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz", - "integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.6", + "@vitest/spy": "4.1.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1923,9 +1923,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz", - "integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", "dev": true, "license": "MIT", "dependencies": { @@ -1936,13 +1936,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz", - "integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.6", + "@vitest/utils": "4.1.7", "pathe": "^2.0.3" }, "funding": { @@ -1950,14 +1950,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz", - "integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.6", - "@vitest/utils": "4.1.6", + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1966,9 +1966,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz", - "integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", "dev": true, "license": "MIT", "funding": { @@ -1976,13 +1976,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz", - "integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.6", + "@vitest/pretty-format": "4.1.7", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -3594,9 +3594,9 @@ "license": "Apache-2.0" }, "node_modules/lru-cache": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", - "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -4112,9 +4112,9 @@ "license": "MIT" }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -4132,7 +4132,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -4291,13 +4291,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", - "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.130.0", + "@oxc-project/types": "=0.132.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -4307,21 +4307,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.1", - "@rolldown/binding-darwin-arm64": "1.0.1", - "@rolldown/binding-darwin-x64": "1.0.1", - "@rolldown/binding-freebsd-x64": "1.0.1", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", - "@rolldown/binding-linux-arm64-gnu": "1.0.1", - "@rolldown/binding-linux-arm64-musl": "1.0.1", - "@rolldown/binding-linux-ppc64-gnu": "1.0.1", - "@rolldown/binding-linux-s390x-gnu": "1.0.1", - "@rolldown/binding-linux-x64-gnu": "1.0.1", - "@rolldown/binding-linux-x64-musl": "1.0.1", - "@rolldown/binding-openharmony-arm64": "1.0.1", - "@rolldown/binding-wasm32-wasi": "1.0.1", - "@rolldown/binding-win32-arm64-msvc": "1.0.1", - "@rolldown/binding-win32-x64-msvc": "1.0.1" + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" } }, "node_modules/router": { @@ -5167,16 +5167,16 @@ } }, "node_modules/vite": { - "version": "8.0.13", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", - "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.14", - "rolldown": "1.0.1", + "postcss": "^8.5.15", + "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "bin": { @@ -5245,19 +5245,19 @@ } }, "node_modules/vitest": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz", - "integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.6", - "@vitest/mocker": "4.1.6", - "@vitest/pretty-format": "4.1.6", - "@vitest/runner": "4.1.6", - "@vitest/snapshot": "4.1.6", - "@vitest/spy": "4.1.6", - "@vitest/utils": "4.1.6", + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -5285,12 +5285,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.6", - "@vitest/browser-preview": "4.1.6", - "@vitest/browser-webdriverio": "4.1.6", - "@vitest/coverage-istanbul": "4.1.6", - "@vitest/coverage-v8": "4.1.6", - "@vitest/ui": "4.1.6", + "@vitest/browser-playwright": "4.1.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" 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 faaaac30b..be8cbcdb6 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', @@ -58,6 +59,7 @@ const LBUG_NATIVE = [ 'test/integration/lbug-close-handle-release.test.ts', 'test/integration/lbug-orphan-sidecar-recovery.test.ts', 'test/integration/lbug-readonly-init.test.ts', + 'test/integration/lbug-non-ascii-path.test.ts', 'test/integration/local-backend.test.ts', 'test/integration/local-backend-calltool.test.ts', 'test/integration/search-core.test.ts', @@ -79,6 +81,8 @@ 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', ]; diff --git a/gitnexus/src/cli/doctor.ts b/gitnexus/src/cli/doctor.ts index b137d9d50..0fb2a98a0 100644 --- a/gitnexus/src/cli/doctor.ts +++ b/gitnexus/src/cli/doctor.ts @@ -1,6 +1,7 @@ import { getRuntimeCapabilities, getRuntimeFingerprint } from '../core/platform/capabilities.js'; import { resolveEmbeddingConfig } from '../core/embeddings/config.js'; import { isHttpMode } from '../core/embeddings/http-client.js'; +import { checkLbugNative } from '../core/lbug/native-check.js'; import { t } from './i18n/index.js'; function isCombiningMark(codePoint: number): boolean { @@ -59,6 +60,13 @@ export const doctorCommand = async () => { console.log(` ${label('doctor.labels.node', 10)}${fingerprint.node}`); console.log(` ${label('doctor.labels.gitnexus', 10)}${fingerprint.gitnexus}`); console.log(` ${label('doctor.labels.ladybugdb', 10)}${fingerprint.ladybugdb ?? 'unknown'}`); + const nativeCheck = checkLbugNative(); + if (nativeCheck.ok) { + console.log(` ${padDisplayEnd('native', 10)}✓ lbugjs.node loaded`); + } else { + console.log(` ${padDisplayEnd('native', 10)}✗ lbugjs.node missing`); + process.stderr.write(`\n${nativeCheck.message?.replace(/^/gm, ' ')}\n\n`); + } console.log(` ${label('doctor.labels.onnx', 10)}${fingerprint.onnxruntime ?? 'unknown'}`); console.log(''); console.log(t('doctor.capabilities')); diff --git a/gitnexus/src/cli/eval-server.ts b/gitnexus/src/cli/eval-server.ts index d8e171947..f28225e0c 100644 --- a/gitnexus/src/cli/eval-server.ts +++ b/gitnexus/src/cli/eval-server.ts @@ -206,19 +206,39 @@ export function formatImpactResult(result: any): string { 3: 'MAY NEED TESTING (transitive)', }; - for (const depth of [1, 2, 3]) { - const items = byDepth[depth]; - if (!items || items.length === 0) continue; - - lines.push(`d=${depth}: ${depthLabels[depth] || ''} (${items.length})`); - for (const item of items.slice(0, 12)) { - const conf = item.confidence < 1 ? ` (conf: ${item.confidence})` : ''; - lines.push(` ${item.type} ${item.name} → ${item.filePath} [${item.relationType}]${conf}`); - } - if (items.length > 12) { - lines.push(` ... and ${items.length - 12} more`); + if (!result.byDepth && result.byDepthCounts) { + lines.push('(summary only — use summaryOnly: false to see symbol lists)'); + const depthCounts = result.byDepthCounts; + for (const depth of [1, 2, 3]) { + const count = depthCounts[depth] ?? 0; + if (count === 0) continue; + lines.push(`d=${depth}: ${depthLabels[depth] || ''} (${count})`); } lines.push(''); + } else { + const depthCounts = result.byDepthCounts || {}; + for (const depth of [1, 2, 3]) { + const items = byDepth[depth] || []; + const trueCount = depthCounts[depth] ?? items.length; + if (trueCount === 0) continue; + + lines.push(`d=${depth}: ${depthLabels[depth] || ''} (${trueCount})`); + if (items.length === 0) { + lines.push(` (0 items on this page — adjust offset)`); + } else { + const shown = Math.min(items.length, 12); + for (const item of items.slice(0, shown)) { + const conf = item.confidence < 1 ? ` (conf: ${item.confidence})` : ''; + lines.push( + ` ${item.type} ${item.name} → ${item.filePath} [${item.relationType}]${conf}`, + ); + } + if (trueCount > shown) { + lines.push(` ... and ${trueCount - shown} more`); + } + } + lines.push(''); + } } return lines.join('\n').trim(); diff --git a/gitnexus/src/cli/help-i18n.ts b/gitnexus/src/cli/help-i18n.ts index 1c4312112..8d620fc6f 100644 --- a/gitnexus/src/cli/help-i18n.ts +++ b/gitnexus/src/cli/help-i18n.ts @@ -103,6 +103,9 @@ const OPTION_DESCRIPTION_KEYS = { 'impact|-r, --repo ': 'help.option.repo.target', 'impact|--depth ': 'help.option.impact.depth', 'impact|--include-tests': 'help.option.impact.includeTests', + 'impact|--limit ': 'help.option.impact.limit', + 'impact|--offset ': 'help.option.impact.offset', + 'impact|--summary-only': 'help.option.impact.summaryOnly', 'cypher|-r, --repo ': 'help.option.repo.target', 'detect-changes|-s, --scope ': 'help.option.detectChanges.scope', 'detect-changes|-b, --base-ref ': 'help.option.detectChanges.baseRef', diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index e808f244b..51cbc2816 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -216,6 +216,9 @@ export const en = { 'help.option.impact.direction': 'upstream (dependants) or downstream (dependencies)', 'help.option.impact.depth': 'Max relationship depth (default: 3)', 'help.option.impact.includeTests': 'Include test files in results', + 'help.option.impact.limit': 'Max symbols per depth level (default: 100)', + 'help.option.impact.offset': 'Skip N symbols per depth level for pagination', + 'help.option.impact.summaryOnly': 'Return counts and risk only, omit symbol list', 'help.option.detectChanges.scope': 'What to analyze: unstaged, staged, all, or compare', 'help.option.detectChanges.baseRef': 'Branch/commit for compare scope (e.g. main)', 'help.option.evalServer.host': diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index 9e896f33e..2a1374fc6 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -202,6 +202,9 @@ export const zhCN = { 'help.option.impact.direction': 'upstream(依赖它的项)或 downstream(它依赖的项)', 'help.option.impact.depth': '最大关系遍历深度(默认:3)', 'help.option.impact.includeTests': '在结果中包含测试文件', + 'help.option.impact.limit': '每层深度最大符号数(默认:100)', + 'help.option.impact.offset': '每层深度跳过 N 个符号(分页用)', + 'help.option.impact.summaryOnly': '仅返回计数和风险等级,省略符号列表', 'help.option.detectChanges.scope': '分析范围:unstaged、staged、all 或 compare', 'help.option.detectChanges.baseRef': 'compare 范围的分支/提交(例如 main)', 'help.option.evalServer.host': '绑定地址(默认:127.0.0.1;用 0.0.0.0 暴露到所有网卡)', diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 8f8ffa45f..67b34387a 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -5,7 +5,7 @@ import { Command } from 'commander'; import { createRequire } from 'node:module'; -import { createLazyAction } from './lazy-action.js'; +import { createLazyAction, createLbugLazyAction } from './lazy-action.js'; import { registerGroupCommands } from './group.js'; import { localizeCliHelp } from './help-i18n.js'; import { t } from './i18n/index.js'; @@ -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 @@ -87,7 +89,7 @@ program .option('--embedding-sub-batch-size ', 'Number of chunks per embedding model call') .option('--embedding-device ', 'Embedding device: auto, cpu, dml, cuda, or wasm') .addHelpText('after', () => t('help.analyze.environment')) - .action(createLazyAction(() => import('./analyze.js'), 'analyzeCommand')); + .action(createLbugLazyAction(() => import('./analyze.js'), 'analyzeCommand')); program .command('index [path...]') @@ -103,12 +105,12 @@ program .description('Start local HTTP server for web UI connection') .option('-p, --port ', 'Port number', '4747') .option('--host ', 'Bind address (default: 127.0.0.1, use 0.0.0.0 for remote access)') - .action(createLazyAction(() => import('./serve.js'), 'serveCommand')); + .action(createLbugLazyAction(() => import('./serve.js'), 'serveCommand')); program .command('mcp') .description('Start MCP server (stdio) — serves all indexed repos') - .action(createLazyAction(() => import('./mcp.js'), 'mcpCommand')); + .action(createLbugLazyAction(() => import('./mcp.js'), 'mcpCommand')); program .command('list') @@ -175,12 +177,12 @@ program '--lang ', 'Output language for generated documentation (e.g. english, chinese, spanish, japanese)', ) - .action(createLazyAction(() => import('./wiki.js'), 'wikiCommand')); + .action(createLbugLazyAction(() => import('./wiki.js'), 'wikiCommand')); program .command('augment ') .description('Augment a search pattern with knowledge graph context (used by hooks)') - .action(createLazyAction(() => import('./augment.js'), 'augmentCommand')); + .action(createLbugLazyAction(() => import('./augment.js'), 'augmentCommand')); program .command('publish [path]') @@ -205,7 +207,7 @@ program .option('-g, --goal ', 'What you want to find') .option('-l, --limit ', 'Max processes to return (default: 5)') .option('--content', 'Include full symbol source code') - .action(createLazyAction(() => import('./tool.js'), 'queryCommand')); + .action(createLbugLazyAction(() => import('./tool.js'), 'queryCommand')); program .command('context [name]') @@ -214,7 +216,7 @@ program .option('-u, --uid ', 'Direct symbol UID (zero-ambiguity lookup)') .option('-f, --file ', 'File path to disambiguate common names') .option('--content', 'Include full symbol source code') - .action(createLazyAction(() => import('./tool.js'), 'contextCommand')); + .action(createLbugLazyAction(() => import('./tool.js'), 'contextCommand')); program .command('impact ') @@ -223,13 +225,16 @@ program .option('-r, --repo ', 'Target repository') .option('--depth ', 'Max relationship depth (default: 3)') .option('--include-tests', 'Include test files in results') - .action(createLazyAction(() => import('./tool.js'), 'impactCommand')); + .option('--limit ', 'Max symbols per depth level (default: 100)') + .option('--offset ', 'Skip N symbols per depth level for pagination') + .option('--summary-only', 'Return counts and risk only, omit symbol list') + .action(createLbugLazyAction(() => import('./tool.js'), 'impactCommand')); program .command('cypher ') .description('Execute raw Cypher query against the knowledge graph') .option('-r, --repo ', 'Target repository') - .action(createLazyAction(() => import('./tool.js'), 'cypherCommand')); + .action(createLbugLazyAction(() => import('./tool.js'), 'cypherCommand')); program .command('detect-changes') @@ -238,7 +243,7 @@ program .option('-s, --scope ', 'What to analyze: unstaged, staged, all, or compare', 'unstaged') .option('-b, --base-ref ', 'Branch/commit for compare scope (e.g. main)') .option('-r, --repo ', 'Target repository') - .action(createLazyAction(() => import('./tool.js'), 'detectChangesCommand')); + .action(createLbugLazyAction(() => import('./tool.js'), 'detectChangesCommand')); // ─── Eval Server (persistent daemon for SWE-bench) ───────────────── @@ -251,7 +256,7 @@ program 'Bind address (default: 127.0.0.1, use 0.0.0.0 to expose to all interfaces)', ) .option('--idle-timeout ', 'Auto-shutdown after N seconds idle (0 = disabled)', '0') - .action(createLazyAction(() => import('./eval-server.js'), 'evalServerCommand')); + .action(createLbugLazyAction(() => import('./eval-server.js'), 'evalServerCommand')); registerGroupCommands(program); localizeCliHelp(program); diff --git a/gitnexus/src/cli/lazy-action.ts b/gitnexus/src/cli/lazy-action.ts index 0655a8f4b..3a7bea846 100644 --- a/gitnexus/src/cli/lazy-action.ts +++ b/gitnexus/src/cli/lazy-action.ts @@ -4,6 +4,8 @@ * at compile time — catching typos when used with concrete module imports. */ +import { checkLbugNative } from '../core/lbug/native-check.js'; + function isCallable(value: unknown): value is (...args: unknown[]) => unknown { return typeof value === 'function'; } @@ -21,3 +23,23 @@ export function createLazyAction< await action(...args); }; } + +export function createLbugLazyAction< + TModule extends Record, + TKey extends string & keyof TModule, +>(loader: () => Promise, exportName: TKey): (...args: unknown[]) => Promise { + return async (...args: unknown[]): Promise => { + const check = checkLbugNative(); + if (!check.ok) { + process.stderr.write(`\n ${check.message?.replace(/\n/g, '\n ')}\n\n`); + process.exitCode = 1; + return; + } + const module = await loader(); + const action = module[exportName]; + if (!isCallable(action)) { + throw new Error(`Lazy action export not found: ${exportName}`); + } + await action(...args); + }; +} 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/tool.ts b/gitnexus/src/cli/tool.ts index dd15f09c8..0e70c3970 100644 --- a/gitnexus/src/cli/tool.ts +++ b/gitnexus/src/cli/tool.ts @@ -117,6 +117,9 @@ export async function impactCommand( repo?: string; depth?: string; includeTests?: boolean; + limit?: string; + offset?: string; + summaryOnly?: boolean; }, ): Promise { if (!target?.trim()) { @@ -126,12 +129,19 @@ export async function impactCommand( try { const backend = await getBackend(); + const rawLimit = parseInt(options?.limit ?? '', 10); + const rawOffset = parseInt(options?.offset ?? '', 10); + const parsedLimit = Number.isFinite(rawLimit) ? rawLimit : undefined; + const parsedOffset = Number.isFinite(rawOffset) ? rawOffset : undefined; const result = await backend.callTool('impact', { target, direction: options?.direction || 'upstream', maxDepth: options?.depth ? parseInt(options.depth, 10) : undefined, includeTests: options?.includeTests ?? false, repo: options?.repo, + limit: parsedLimit, + offset: parsedOffset, + summaryOnly: options?.summaryOnly ?? undefined, }); output(result); } catch (err: unknown) { diff --git a/gitnexus/src/config/ignore-service.ts b/gitnexus/src/config/ignore-service.ts index 17b9d14bd..02a26068e 100644 --- a/gitnexus/src/config/ignore-service.ts +++ b/gitnexus/src/config/ignore-service.ts @@ -87,6 +87,7 @@ const DEFAULT_IGNORE_LIST = new Set([ '.generated', 'generated', 'auto-generated', + 'monaco-workers', // Monaco editor web-worker bundles generated for browser runtime '.terraform', '.serverless', @@ -323,14 +324,6 @@ export const shouldIgnorePath = (filePath: string): boolean => { } } - // Ignore hidden files (starting with .) - if (fileName.startsWith('.') && fileName !== '.') { - // But allow some important config files - const allowedDotFiles = ['.env', '.gitignore']; // Already in IGNORED_FILES, so this is redundant - // Actually, let's NOT ignore all dot files - many are important configs - // Just rely on the explicit lists above - } - // Ignore files that look like generated/bundled code if ( fileNameLower.includes('.bundle.') || diff --git a/gitnexus/src/core/group/cross-impact.ts b/gitnexus/src/core/group/cross-impact.ts index eab942a62..e8db52dd5 100644 --- a/gitnexus/src/core/group/cross-impact.ts +++ b/gitnexus/src/core/group/cross-impact.ts @@ -25,6 +25,10 @@ import { getGroupDir } from './storage.js'; import { closeBridgeDb, openBridgeDbReadOnly, queryBridge, readBridgeMeta } from './bridge-db.js'; import { BRIDGE_SCHEMA_VERSION } from './bridge-schema.js'; +// High limit for the local phase of group impact so collectImpactSymbolUids +// sees (nearly) all symbols. Bypasses the MCP-facing default of 100. +const GROUP_LOCAL_PHASE_LIMIT = 10000; + /** Cross-boundary hops beyond this value are clamped (multi-hop reserved for future work). */ export const MAX_SUPPORTED_CROSS_DEPTH = 1; @@ -429,6 +433,7 @@ export async function runGroupImpact( relationTypes: relationTypes && relationTypes.length > 0 ? relationTypes : undefined, includeTests, minConfidence, + limit: GROUP_LOCAL_PHASE_LIMIT, }; const deadline = Date.now() + Math.max(0, timeoutMs); diff --git a/gitnexus/src/core/group/extractors/http-patterns/index.ts b/gitnexus/src/core/group/extractors/http-patterns/index.ts index c62eb618c..4cc758218 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/index.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/index.ts @@ -2,6 +2,7 @@ import * as path from 'node:path'; import { isBladeTemplateFilename } from 'gitnexus-shared'; import type { HttpLanguagePlugin } from './types.js'; import { JAVA_HTTP_PLUGIN } from './java.js'; +import { KOTLIN_HTTP_PLUGIN } from './kotlin.js'; import { GO_HTTP_PLUGIN } from './go.js'; import { PYTHON_HTTP_PLUGIN } from './python.js'; import { PHP_HTTP_PLUGIN } from './php.js'; @@ -18,6 +19,11 @@ export type { HttpDetection, HttpLanguagePlugin, HttpRole } from './types.js'; * new language, drop a `http-patterns/.ts` that exports a * `HttpLanguagePlugin`, import it here and register the extension(s). * No edits to `http-route-extractor.ts` are required. + * + * Optional grammar plugins (e.g. `kotlin.ts`, which depends on the + * optionalDependency `tree-sitter-kotlin`) export `null` when the + * native binding is unavailable; we skip registration in that case so + * a missing optional grammar never crashes the orchestrator. */ const REGISTRY: Record = { '.java': JAVA_HTTP_PLUGIN, @@ -30,16 +36,26 @@ const REGISTRY: Record = { '.tsx': TSX_HTTP_PLUGIN, }; +if (KOTLIN_HTTP_PLUGIN) { + REGISTRY['.kt'] = KOTLIN_HTTP_PLUGIN; + REGISTRY['.kts'] = KOTLIN_HTTP_PLUGIN; +} + /** * Glob for files worth scanning for HTTP routes. Kept alongside the * registry so adding a new language widens the glob in one edit. * + * `.kt`/`.kts` are always present in the glob even when the optional + * `tree-sitter-kotlin` grammar isn't installed — `getPluginForFile` + * will return `undefined` for those files in that case, so the + * orchestrator simply skips them at scan time without erroring. + * * `.vue` / `.svelte` files are intentionally omitted for the source-scan * path — they need their own grammar-aware extraction and the existing * regex fallback for them was never very accurate. The graph-assisted * Strategy A still handles them via the ingestion pipeline. */ -export const HTTP_SCAN_GLOB = '**/*.{ts,tsx,js,jsx,java,go,py,php}'; +export const HTTP_SCAN_GLOB = '**/*.{ts,tsx,js,jsx,java,kt,kts,go,py,php}'; /** * Return the HTTP plugin registered for the given file's extension, diff --git a/gitnexus/src/core/group/extractors/http-patterns/java.ts b/gitnexus/src/core/group/extractors/http-patterns/java.ts index 484f74fb2..b3ced5920 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/java.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/java.ts @@ -30,6 +30,19 @@ const METHOD_ANNOTATION_TO_HTTP: Record = { }; // ─── Provider: Spring class-level @RequestMapping prefix ────────────── +// Two patterns are needed because the AST shape differs depending on +// whether the annotation uses a positional argument or a named one: +// @RequestMapping("/api") → (annotation_argument_list (string_literal)) +// @RequestMapping(path = "/api") → (annotation_argument_list (element_value_pair key:(identifier) value:(string_literal))) +// @RequestMapping(value = "/api") → same as above +// +// The named-argument pattern MUST constrain the `key` field to the route +// member names (`path`/`value`); without it, the query also captures +// non-route attributes such as `produces`, `consumes`, `headers`, `name`, +// `params` (their right-hand string literals would be mis-extracted as +// route prefixes — e.g. `produces = "application/json"` would corrupt +// every method route under that controller). The sibling +// `topic-patterns/java.ts` uses the same `key:` constraint approach. const SPRING_CLASS_PREFIX_PATTERNS = compilePatterns({ name: 'java-spring-class-prefix', language: Java, @@ -44,10 +57,27 @@ const SPRING_CLASS_PREFIX_PATTERNS = compilePatterns({ arguments: (annotation_argument_list (string_literal) @prefix)))) @class `, }, + { + meta: {}, + query: ` + (class_declaration + (modifiers + (annotation + name: (identifier) @ann (#eq? @ann "RequestMapping") + arguments: (annotation_argument_list + (element_value_pair + key: (identifier) @key (#match? @key "^(path|value)$") + value: (string_literal) @prefix))))) @class + `, + }, ], } satisfies LanguagePatterns>); // ─── Provider: Spring @(Get|Post|...)Mapping method annotations ─────── +// Same dual-pattern approach: positional vs named argument. The named +// pattern restricts the annotation member name to `path`/`value` to +// avoid capturing unrelated string-valued attributes +// (`produces`, `consumes`, `headers`, `name`, `params`, ...). const SPRING_METHOD_ROUTE_PATTERNS = compilePatterns({ name: 'java-spring-method-route', language: Java, @@ -63,6 +93,20 @@ const SPRING_METHOD_ROUTE_PATTERNS = compilePatterns({ name: (identifier) @method_name) @method `, }, + { + meta: {}, + query: ` + (method_declaration + (modifiers + (annotation + name: (identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$") + arguments: (annotation_argument_list + (element_value_pair + key: (identifier) @key (#match? @key "^(path|value)$") + value: (string_literal) @path)))) + name: (identifier) @method_name) @method + `, + }, ], } satisfies LanguagePatterns>); diff --git a/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts b/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts new file mode 100644 index 000000000..0bafb7a7e --- /dev/null +++ b/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts @@ -0,0 +1,469 @@ +import Parser from 'tree-sitter'; +import { createRequire } from 'node:module'; +import { + compilePatterns, + runCompiledPatterns, + unquoteLiteral, + type LanguagePatterns, +} from '../tree-sitter-scanner.js'; +import type { HttpDetection, HttpLanguagePlugin } from './types.js'; + +/** + * Kotlin HTTP plugin (Spring providers + consumers). + * + * **Providers** (#1849) — Spring `@RequestMapping` class prefixes and + * `@(Get|Post|...)Mapping` method annotations on Kotlin Spring Boot + * controllers. Both positional shorthand (`@GetMapping("/x")`) and + * named annotation arguments (`@GetMapping(value = "/x")` and + * `@GetMapping(path = "/x")`) are supported. + * + * **Consumers** (this PR) — three call-site patterns common in Kotlin + * Spring projects: + * + * 1. `restTemplate.getForObject("/x", ...)` and friends + * 2. `webClient.get().uri("/x")` (short form, 1 verb hop + 1 uri hop) + * 3. `Request.Builder().url("/x")` (OkHttp) + * + * The long-form `webClient.method(HttpMethod.X).uri("/y")` chain is + * intentionally deferred to a follow-up: it requires walk-up logic + * to recover the verb from a sibling `call_expression`, and we can + * land 80% of real-world Kotlin Spring consumer coverage with the + * three simpler patterns above. + * + * tree-sitter-kotlin (fwcd) AST shapes used here: + * class_declaration + * modifiers + * annotation + * constructor_invocation + * user_type → type_identifier ← annotation name + * value_arguments + * value_argument + * (simple_identifier "=")? ← absent for positional, present for named + * string_literal + * type_identifier ← class name + * + * Consumer call shape (Kotlin chains everything via `navigation_expression`): + * call_expression ← outer `.uri("/x")` or `.url("/x")` + * navigation_expression + * call_expression ← inner `.get()` / `Request.Builder()` / `restTemplate.x` + * navigation_expression + * simple_identifier ← receiver: `webClient` / `Request` / `restTemplate` + * navigation_suffix ← `.method` / `.Builder` / `.getForObject` + * call_suffix (value_arguments) + * navigation_suffix ← `.uri` / `.url` + * call_suffix + * value_arguments + * value_argument + * string_literal ← the path + * + * tree-sitter-kotlin is an optional npm dependency — when its native + * binding is unavailable the plugin gracefully exports `null` and + * `http-patterns/index.ts` skips registration for `.kt`/`.kts` files. + */ + +const _require = createRequire(import.meta.url); + +/** Loaded lazily; null when the grammar binding isn't installed. */ +let Kotlin: unknown | null = null; +try { + Kotlin = _require('tree-sitter-kotlin'); +} catch { + Kotlin = null; +} + +const METHOD_ANNOTATION_TO_HTTP: Record = { + GetMapping: 'GET', + PostMapping: 'POST', + PutMapping: 'PUT', + DeleteMapping: 'DELETE', + PatchMapping: 'PATCH', +}; + +/** + * RestTemplate method-name → HTTP verb. Mirrors the Java plugin's + * `REST_TEMPLATE_TO_HTTP` (java.ts) so a polyglot repo emits the + * same contract IDs from .java and .kt sources. + */ +const REST_TEMPLATE_TO_HTTP: Record = { + getForObject: 'GET', + getForEntity: 'GET', + postForObject: 'POST', + postForEntity: 'POST', + put: 'PUT', + delete: 'DELETE', + patchForObject: 'PATCH', +}; + +/** + * WebClient short-form verb → HTTP verb. The reactive WebClient API + * exposes `.get()`, `.post()`, `.put()`, `.delete()`, `.patch()` as + * one-liners that return a `RequestHeadersUriSpec` whose `.uri(...)` + * carries the path. We capture both pieces in a single query (see + * `WEB_CLIENT_SHORT_PATTERNS` below) and translate the verb here. + */ +const WEB_CLIENT_SHORT_TO_HTTP: Record = { + get: 'GET', + post: 'POST', + put: 'PUT', + delete: 'DELETE', + patch: 'PATCH', +}; + +/** + * Build the plugin only if the Kotlin grammar is available. Compiling + * the queries against a null grammar would throw at module load time + * and abort the whole http-route-extractor module. + */ +function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { + // ─── Provider: Spring class-level @RequestMapping prefix ────────────── + // Two patterns mirror the Java plugin's positional vs named split: + // @RequestMapping("/api") → value_argument has string_literal as its first named child + // @RequestMapping(path = "/api") → value_argument has [simple_identifier @key, string_literal] + // @RequestMapping(value = "/api") → same as above, with key="value" + // + // Tree-sitter-kotlin grammar (fwcd 0.3.8) does NOT have a separate + // node for named arguments — both positional and named forms share + // `value_argument`. The positional pattern uses the immediate-child + // anchor `.` so it only matches when the string_literal is the FIRST + // named child (i.e. no preceding simple_identifier "=" prefix). The + // named pattern explicitly captures the simple_identifier and uses + // `#match?` to restrict it to `path`/`value`, matching the same + // safety bar that the Java plugin enforces (see java.ts and the + // sibling topic-patterns/java.ts for the analogous constraint). + // + // Without the `key:` constraint the named query would also capture + // unrelated attributes like `produces`, `consumes`, `headers`, + // `name`, `params` — emitting bogus route contracts (a regression + // identical to the one Claude flagged on PR #1834 for Java). + const SPRING_CLASS_PREFIX_PATTERNS = compilePatterns({ + name: 'kotlin-spring-class-prefix', + language, + patterns: [ + { + meta: {}, + query: ` + (class_declaration + (modifiers + (annotation + (constructor_invocation + (user_type (type_identifier) @ann (#eq? @ann "RequestMapping")) + (value_arguments + (value_argument . (string_literal) @prefix))))) + (type_identifier) @cls) @class + `, + }, + { + meta: {}, + query: ` + (class_declaration + (modifiers + (annotation + (constructor_invocation + (user_type (type_identifier) @ann (#eq? @ann "RequestMapping")) + (value_arguments + (value_argument + (simple_identifier) @key (#match? @key "^(path|value)$") + (string_literal) @prefix))))) + (type_identifier) @cls) @class + `, + }, + ], + } satisfies LanguagePatterns>); + + // ─── Provider: Spring @(Get|Post|...)Mapping method annotations ─────── + // Same dual-pattern positional/named approach. The Kotlin AST puts the + // function name (`simple_identifier`) outside the `modifiers` subtree, + // so we capture it from `function_declaration` directly. + const SPRING_METHOD_ROUTE_PATTERNS = compilePatterns({ + name: 'kotlin-spring-method-route', + language, + patterns: [ + { + meta: {}, + query: ` + (function_declaration + (modifiers + (annotation + (constructor_invocation + (user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$")) + (value_arguments + (value_argument . (string_literal) @path))))) + (simple_identifier) @method_name) @method + `, + }, + { + meta: {}, + query: ` + (function_declaration + (modifiers + (annotation + (constructor_invocation + (user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$")) + (value_arguments + (value_argument + (simple_identifier) @key (#match? @key "^(path|value)$") + (string_literal) @path))))) + (simple_identifier) @method_name) @method + `, + }, + ], + } satisfies LanguagePatterns>); + + // ─── Consumer: Spring RestTemplate ──────────────────────────────────── + // Kotlin call-site shape mirrors the Java plugin's + // `REST_TEMPLATE_PATTERNS`, but goes through tree-sitter-kotlin's + // `navigation_expression` instead of Java's `method_invocation`: + // + // restTemplate.getForObject("/x", User::class.java) + // + // becomes + // + // call_expression + // navigation_expression + // simple_identifier "restTemplate" + // navigation_suffix → simple_identifier "getForObject" + // call_suffix + // value_arguments + // value_argument . string_literal "/x" ← captured + // value_argument User::class.java + // + // The receiver name is constrained to `restTemplate` (#eq? @obj), + // matching the Java plugin's heuristic. This means a non-conventional + // field name (e.g. `userServiceTemplate`) will not be picked up; + // that's the same trade-off already accepted on the Java side. + const REST_TEMPLATE_PATTERNS = compilePatterns({ + name: 'kotlin-rest-template', + language, + patterns: [ + { + meta: {}, + query: ` + (call_expression + (navigation_expression + (simple_identifier) @obj (#eq? @obj "restTemplate") + (navigation_suffix (simple_identifier) @method)) + (call_suffix + (value_arguments . (value_argument . (string_literal) @path)))) + `, + }, + ], + } satisfies LanguagePatterns>); + + // ─── Consumer: Spring WebClient (short form) ────────────────────────── + // Reactive WebClient exposes one-liner verb helpers: + // + // webClient.get().uri("/x").retrieve().awaitBody() + // webClient.post().uri("/x")... + // + // The chain `webClient.get().uri("/x")` parses as two nested + // `call_expression` nodes — the OUTER call is `.uri("/x")` and the + // INNER call is `webClient.get()`. We anchor on the outer call and + // require: + // - inner receiver is `webClient` + // - inner suffix is one of the HTTP verbs (#match?) + // - outer suffix is exactly `uri` + // - outer call's first value_argument is a string literal + // + // The long-form `webClient.method(HttpMethod.GET).uri("/x")` chain + // uses an extra navigation hop and an enum field access — it's + // intentionally out of scope here (see file header). + const WEB_CLIENT_SHORT_PATTERNS = compilePatterns({ + name: 'kotlin-web-client-short', + language, + patterns: [ + { + meta: {}, + query: ` + (call_expression + (navigation_expression + (call_expression + (navigation_expression + (simple_identifier) @obj (#eq? @obj "webClient") + (navigation_suffix + (simple_identifier) @verb (#match? @verb "^(get|post|put|delete|patch)$"))) + (call_suffix (value_arguments))) + (navigation_suffix (simple_identifier) @uri (#eq? @uri "uri"))) + (call_suffix + (value_arguments . (value_argument . (string_literal) @path)))) + `, + }, + ], + } satisfies LanguagePatterns>); + + // ─── Consumer: OkHttp Request.Builder().url("/x") ───────────────────── + // Kotlin parses `Request.Builder()` as a `call_expression` whose + // callee is a `navigation_expression` (Request → .Builder), NOT as + // Java's `object_creation_expression`. The chain `.url("/x")` then + // wraps that in another `call_expression`. The query mirrors Java's + // `OK_HTTP_PATTERNS` (java.ts) but adapts the node types. + // + // Receiver `Request` is constrained by name (#eq? @cls); a project + // that imports OkHttp's `Request` under an alias (`import okhttp3.Request as OkRequest`) + // would not be picked up — this matches the Java plugin's heuristic. + // + // **Known limitation — verb defaults to GET.** OkHttp encodes the + // verb on a *sibling* call further down the builder chain (e.g. + // `.post(body)` / `.get()` / `.delete()`), not on `.url(...)` itself. + // This query intentionally does not walk the chain to recover the + // verb — it emits `method: 'GET'` for every match, mirroring + // `java.ts:OK_HTTP_PATTERNS`. So a `Request.Builder().url("/x").post(body).build()` + // call becomes `http::GET::/x`, not `http::POST::/x`. This is the + // same trade-off Java has accepted; pinned by an anti-overreach + // test in `http-route-extractor.test.ts` so a future verb-walk + // implementation has to update this comment in lockstep. + const OK_HTTP_PATTERNS = compilePatterns({ + name: 'kotlin-okhttp', + language, + patterns: [ + { + meta: {}, + query: ` + (call_expression + (navigation_expression + (call_expression + (navigation_expression + (simple_identifier) @cls (#eq? @cls "Request") + (navigation_suffix (simple_identifier) @builder (#eq? @builder "Builder"))) + (call_suffix (value_arguments))) + (navigation_suffix (simple_identifier) @method (#eq? @method "url"))) + (call_suffix + (value_arguments . (value_argument . (string_literal) @path)))) + `, + }, + ], + } satisfies LanguagePatterns>); + + /** + * Find the nearest enclosing class_declaration ancestor for a node, or + * null if the node is top-level. Mirrors the Java plugin's helper. + */ + function findEnclosingClass(node: Parser.SyntaxNode): Parser.SyntaxNode | null { + let cur: Parser.SyntaxNode | null = node.parent; + while (cur) { + if (cur.type === 'class_declaration') return cur; + cur = cur.parent; + } + return null; + } + + /** + * Join a class-level prefix and a method-level path. Identical + * semantics to the Java plugin: strip leading/trailing slashes on + * the prefix, strip leading slashes on the method path, ensure a + * single slash between them. + */ + function joinPath(prefix: string, methodPath: string): string { + const cleanPrefix = prefix.replace(/^\/+/, '').replace(/\/+$/, ''); + const cleanSub = methodPath.replace(/^\/+/, ''); + if (!cleanPrefix) return `/${cleanSub}`; + return `/${cleanPrefix}/${cleanSub}`; + } + + return { + name: 'kotlin-http', + language, + scan(tree) { + const out: HttpDetection[] = []; + + // ─── Class prefixes ───────────────────────────────────────────── + const prefixByClassId = new Map(); + for (const match of runCompiledPatterns(SPRING_CLASS_PREFIX_PATTERNS, tree)) { + const prefixNode = match.captures.prefix; + const classNode = match.captures.class; + if (!prefixNode || !classNode) continue; + const prefix = unquoteLiteral(prefixNode.text); + if (prefix !== null) prefixByClassId.set(classNode.id, prefix); + } + + // ─── Method routes ────────────────────────────────────────────── + for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) { + const annNode = match.captures.ann; + const pathNode = match.captures.path; + const nameNode = match.captures.method_name; + const methodNode = match.captures.method; + if (!annNode || !pathNode || !methodNode) continue; + const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text]; + if (!httpMethod) continue; + const rawPath = unquoteLiteral(pathNode.text); + if (rawPath === null) continue; + const enclosingClass = findEnclosingClass(methodNode); + const prefix = enclosingClass ? (prefixByClassId.get(enclosingClass.id) ?? '') : ''; + const fullPath = joinPath(prefix, rawPath); + out.push({ + role: 'provider', + framework: 'spring', + method: httpMethod, + path: fullPath, + name: nameNode?.text ?? null, + confidence: 0.8, + }); + } + + // ─── Consumers: RestTemplate ──────────────────────────────────── + for (const match of runCompiledPatterns(REST_TEMPLATE_PATTERNS, tree)) { + const methodNode = match.captures.method; + const pathNode = match.captures.path; + if (!methodNode || !pathNode) continue; + const httpMethod = REST_TEMPLATE_TO_HTTP[methodNode.text]; + if (!httpMethod) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'consumer', + framework: 'spring-rest-template', + method: httpMethod, + path, + name: null, + confidence: 0.7, + }); + } + + // ─── Consumers: WebClient short form (.get()/.post()/etc → .uri) ─ + for (const match of runCompiledPatterns(WEB_CLIENT_SHORT_PATTERNS, tree)) { + const verbNode = match.captures.verb; + const pathNode = match.captures.path; + if (!verbNode || !pathNode) continue; + const httpMethod = WEB_CLIENT_SHORT_TO_HTTP[verbNode.text]; + if (!httpMethod) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'consumer', + framework: 'spring-web-client', + method: httpMethod, + path, + name: null, + confidence: 0.7, + }); + } + + // ─── Consumers: OkHttp Request.Builder().url("path") ──────────── + for (const match of runCompiledPatterns(OK_HTTP_PATTERNS, tree)) { + const pathNode = match.captures.path; + if (!pathNode) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'consumer', + framework: 'okhttp', + method: 'GET', + path, + name: null, + confidence: 0.7, + }); + } + + return out; + }, + }; +} + +/** + * The exported plugin is `null` when tree-sitter-kotlin's native + * binding is unavailable. `http-patterns/index.ts` checks for null + * before registering `.kt`/`.kts` so missing optional grammars never + * crash the orchestrator. + */ +export const KOTLIN_HTTP_PLUGIN: HttpLanguagePlugin | null = Kotlin + ? buildKotlinPlugin(Kotlin) + : null; diff --git a/gitnexus/src/core/group/service.ts b/gitnexus/src/core/group/service.ts index 3435f1017..b1812bb7d 100644 --- a/gitnexus/src/core/group/service.ts +++ b/gitnexus/src/core/group/service.ts @@ -43,6 +43,7 @@ export interface GroupToolPort { relationTypes?: string[]; includeTests?: boolean; minConfidence?: number; + limit?: number; }, ): Promise; query( diff --git a/gitnexus/src/core/ingestion/languages/cobol.ts b/gitnexus/src/core/ingestion/languages/cobol.ts index 6e54c2309..a28f87a8f 100644 --- a/gitnexus/src/core/ingestion/languages/cobol.ts +++ b/gitnexus/src/core/ingestion/languages/cobol.ts @@ -6,11 +6,18 @@ * processed by cobol-processor.ts in pipeline Phase 2.6, not by the * tree-sitter pipeline. * - * This provider exists to satisfy the SupportedLanguages exhaustiveness - * checks and to declare parseStrategy: 'standalone'. + * This provider supports scope-based resolution (RFC #909 Ring 3) via + * `emitScopeCaptures` which wraps the regex tagger. COPY statements are + * interpreted as imports; there is no type system and no implicit receiver. */ import { SupportedLanguages } from 'gitnexus-shared'; import { defineLanguage } from '../language-provider.js'; +import { + emitCobolScopeCaptures, + interpretCobolImport, + cobolImportOwningScope, + cobolReceiverBinding, +} from './cobol/index.js'; export const cobolProvider = defineLanguage({ id: SupportedLanguages.Cobol, @@ -26,4 +33,10 @@ export const cobolProvider = defineLanguage({ }, exportChecker: () => false, importResolver: () => null, + + // ── Scope-resolution hooks ─────────────────────────────────────── + emitScopeCaptures: emitCobolScopeCaptures, + interpretImport: interpretCobolImport, + importOwningScope: cobolImportOwningScope, + receiverBinding: cobolReceiverBinding, }); diff --git a/gitnexus/src/core/ingestion/languages/cobol/captures.ts b/gitnexus/src/core/ingestion/languages/cobol/captures.ts new file mode 100644 index 000000000..69a6c80b9 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cobol/captures.ts @@ -0,0 +1,291 @@ +/** + * `emitScopeCaptures` for COBOL. + * + * Wraps the existing regex tagger (`extractCobolSymbolsWithRegex`) and + * produces parser-agnostic `CaptureMatch[]` matching the RFC §5.1 + * vocabulary. The central `ScopeExtractor` consumes these captures + * without knowing whether they came from tree-sitter or regex. + * + * Pure given the input source text. No I/O, no globals consulted. + * The regex tagger is synchronous — no async needed. + */ + +import type { Capture, CaptureMatch, Range } from 'gitnexus-shared'; +import { + extractCobolSymbolsWithRegex, + preprocessCobolSource, +} from '../../cobol/cobol-preprocessor.js'; + +// --------------------------------------------------------------------------- +// Capture building helpers +// --------------------------------------------------------------------------- + +function capture(name: string, range: Range, text: string): Capture { + return { name, range, text }; +} + +function rangeOf(startLine: number, startCol: number, endLine: number, endCol: number): Range { + return { startLine, startCol, endLine, endCol }; +} + +/** + * Build a single CaptureMatch from a record of captures. + * Returns null if the record is empty. + */ +function matchFrom(grouped: Record): CaptureMatch | null { + if (Object.keys(grouped).length === 0) return null; + return Object.freeze(grouped) as CaptureMatch; +} + +/** + * Compute end column for a single-line capture from the source lines array. + */ +function endColFrom(line: string): number { + return line.length > 0 ? line.length - 1 : 0; +} + +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- + +export function emitCobolScopeCaptures( + sourceText: string, + _filePath: string, + _cachedTree?: unknown, +): readonly CaptureMatch[] { + const lines = sourceText.split(/\r?\n/); + // Preprocess: strip patch markers from columns 1-6 + const cleaned = preprocessCobolSource(sourceText); + // Run the regex tagger on the preprocessed source + const extracted = extractCobolSymbolsWithRegex(cleaned, _filePath); + + const out: CaptureMatch[] = []; + + // ── 1. PROGRAM-ID → @scope.module ─────────────────────────────────── + // The primary program name (first PROGRAM-ID encountered) + if (extracted.programName) { + const name = extracted.programName; + const lastLine = lines.length; + + const progDef = extracted.programs.find((p) => p.name.toUpperCase() === name.toUpperCase()); + const startLine = progDef?.startLine ?? 1; + const endLine = progDef?.endLine ?? lastLine; + const startCol = 0; + const endCol = endColFrom(lines[Math.min(endLine, lines.length) - 1] ?? ''); + + const progIdLine = findProgramIdLine(cleaned, name); + const nameRange = + progIdLine !== -1 + ? rangeOf(progIdLine, 7, progIdLine, lines[progIdLine - 1]?.length ?? endCol) + : rangeOf(startLine, startCol, endLine, endCol); + + const grouped: Record = { + '@scope.module': capture('@scope.module', nameRange, name), + '@declaration.program': capture( + '@declaration.program', + rangeOf(startLine, startCol, endLine, endCol), + name, + ), + '@declaration.name': capture('@declaration.name', nameRange, name), + }; + + if (progDef?.procedureUsing && progDef.procedureUsing.length > 0) { + grouped['@declaration.parameter-count'] = capture( + '@declaration.parameter-count', + nameRange, + String(progDef.procedureUsing.length), + ); + } + + const m = matchFrom(grouped); + if (m !== null) out.push(m); + } + + // ── 2. Nested / additional programs → @scope.module ────────────── + for (const prog of extracted.programs) { + if (extracted.programName && prog.name.toUpperCase() === extracted.programName.toUpperCase()) + continue; + + const startLine = prog.startLine; + const endLine = prog.endLine; + const startCol = 0; + const endCol = endColFrom(lines[Math.min(endLine, lines.length) - 1] ?? ''); + + const progIdLine = findProgramIdLine(cleaned, prog.name); + const nameRange = + progIdLine !== -1 + ? rangeOf(progIdLine, 7, progIdLine, lines[progIdLine - 1]?.length ?? endCol) + : rangeOf(startLine, startCol, endLine, endCol); + + const grouped: Record = { + '@scope.module': capture('@scope.module', nameRange, prog.name), + '@declaration.program': capture( + '@declaration.program', + rangeOf(startLine, startCol, endLine, endCol), + prog.name, + ), + '@declaration.name': capture('@declaration.name', nameRange, prog.name), + }; + + if (prog.procedureUsing && prog.procedureUsing.length > 0) { + grouped['@declaration.parameter-count'] = capture( + '@declaration.parameter-count', + nameRange, + String(prog.procedureUsing.length), + ); + } + + const m = matchFrom(grouped); + if (m !== null) out.push(m); + } + + // ── 3. PROCEDURE DIVISION sections → @scope.function ───────────── + for (const section of extracted.sections) { + const lineIdx = section.line - 1; + if (lineIdx < 0 || lineIdx >= lines.length) continue; + + const sectionLine = lines[lineIdx]; + const startCol = 0; + const endCol = endColFrom(sectionLine); + const nameRange = rangeOf(section.line, startCol, section.line, endCol); + + const grouped: Record = { + '@scope.function': capture('@scope.function', nameRange, section.name), + '@declaration.function': capture('@declaration.function', nameRange, section.name), + '@declaration.name': capture('@declaration.name', nameRange, section.name), + }; + + const m = matchFrom(grouped); + if (m !== null) out.push(m); + } + + // ── 4. Paragraphs → @scope.function ────────────────────────────── + for (const para of extracted.paragraphs) { + const lineIdx = para.line - 1; + if (lineIdx < 0 || lineIdx >= lines.length) continue; + + const paraLine = lines[lineIdx]; + const startCol = 0; + const endCol = endColFrom(paraLine); + const nameRange = rangeOf(para.line, startCol, para.line, endCol); + + const grouped: Record = { + '@scope.function': capture('@scope.function', nameRange, para.name), + '@declaration.function': capture('@declaration.function', nameRange, para.name), + '@declaration.name': capture('@declaration.name', nameRange, para.name), + }; + + const m = matchFrom(grouped); + if (m !== null) out.push(m); + } + + // ── 5. COPY → @import.statement ────────────────────────────────── + for (const copy of extracted.copies) { + const lineIdx = copy.line - 1; + if (lineIdx < 0 || lineIdx >= lines.length) continue; + + const copyLine = lines[lineIdx]; + const startCol = 0; + const endCol = endColFrom(copyLine); + const stmtRange = rangeOf(copy.line, startCol, copy.line, endCol); + + const grouped: Record = { + '@import.statement': capture('@import.statement', stmtRange, copy.target), + '@import.name': capture('@import.name', stmtRange, copy.target), + }; + + const m = matchFrom(grouped); + if (m !== null) out.push(m); + } + + // ── 6. CALL (quoted/referenced) → @reference.call ──────────────── + for (const call of extracted.calls) { + const lineIdx = call.line - 1; + if (lineIdx < 0 || lineIdx >= lines.length) continue; + + const callLine = lines[lineIdx]; + const startCol = 0; + const endCol = endColFrom(callLine); + const stmtRange = rangeOf(call.line, startCol, call.line, endCol); + + const grouped: Record = { + '@reference.call': capture('@reference.call', stmtRange, call.target), + '@reference.name': capture('@reference.name', stmtRange, call.target), + }; + + // Arity from CALL USING parameters + if (call.parameters && call.parameters.length > 0) { + grouped['@reference.arity'] = capture( + '@reference.arity', + stmtRange, + String(call.parameters.length), + ); + } + + const m = matchFrom(grouped); + if (m !== null) out.push(m); + } + + // ── 7. PERFORM → @reference.call ───────────────────────────────── + for (const perf of extracted.performs) { + const lineIdx = perf.line - 1; + if (lineIdx < 0 || lineIdx >= lines.length) continue; + + const perfLine = lines[lineIdx]; + const startCol = 0; + const endCol = endColFrom(perfLine); + const stmtRange = rangeOf(perf.line, startCol, perf.line, endCol); + + const grouped: Record = { + '@reference.call': capture('@reference.call', stmtRange, perf.target), + '@reference.name': capture('@reference.name', stmtRange, perf.target), + }; + + const m = matchFrom(grouped); + if (m !== null) out.push(m); + } + + // ── 8. GO TO → @reference.call ─────────────────────────────────── + for (const gt of extracted.gotos) { + const lineIdx = gt.line - 1; + if (lineIdx < 0 || lineIdx >= lines.length) continue; + + const gtLine = lines[lineIdx]; + const startCol = 0; + const endCol = endColFrom(gtLine); + const stmtRange = rangeOf(gt.line, startCol, gt.line, endCol); + + const grouped: Record = { + '@reference.call': capture('@reference.call', stmtRange, gt.target), + '@reference.name': capture('@reference.name', stmtRange, gt.target), + }; + + const m = matchFrom(grouped); + if (m !== null) out.push(m); + } + + return out; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Find the PROGRAM-ID. line for a given program name in the cleaned source. + * Returns 1-based line number, or -1 if not found. + */ +function findProgramIdLine(cleanedSource: string, programName: string): number { + const lines = cleanedSource.split(/\r?\n/); + const upper = programName.toUpperCase(); + const re = new RegExp(`\\bPROGRAM-ID\\.\\s*${escapeRegex(upper)}\\b`, 'i'); + for (let i = 0; i < lines.length; i++) { + if (re.test(lines[i])) return i + 1; // 1-based + } + return -1; +} + +/** Simple regex escape for special chars. */ +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/gitnexus/src/core/ingestion/languages/cobol/index.ts b/gitnexus/src/core/ingestion/languages/cobol/index.ts new file mode 100644 index 000000000..24cb80473 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cobol/index.ts @@ -0,0 +1,20 @@ +/** + * COBOL scope-resolution public API barrel. + * + * Consumers should import from this file rather than the individual + * modules — that keeps the per-hook organization an implementation + * detail we can refactor without touching the provider wiring. + * + * Module layout: + * + * - `captures.ts` — `emitCobolScopeCaptures` (wraps the regex tagger) + * - `interpret.ts` — import/type-binding/receiver hooks + */ + +export { emitCobolScopeCaptures } from './captures.js'; +export { + interpretCobolImport, + interpretCobolTypeBinding, + cobolImportOwningScope, + cobolReceiverBinding, +} from './interpret.js'; diff --git a/gitnexus/src/core/ingestion/languages/cobol/interpret.ts b/gitnexus/src/core/ingestion/languages/cobol/interpret.ts new file mode 100644 index 000000000..62959069c --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cobol/interpret.ts @@ -0,0 +1,99 @@ +/** + * COBOL scope-resolution interpret hooks. + * + * Interprets raw `@import.statement` capture matches (from COPY statements) + * into `ParsedImport` for the central finalize algorithm. + * + * COBOL's import semantic is simple: `COPY bookname` means the copybook's + * content is inlined at compile time. There is no module-system equivalent + * of `export` — everything is text-inclusion. The scope-resolution pipeline + * models this as a `'named'` import where the imported name is the copybook + * name and the target is the copybook file path. + */ + +import type { + CaptureMatch, + ParsedImport, + ParsedTypeBinding, + ScopeId, + ScopeTree, + Scope, + TypeRef, +} from 'gitnexus-shared'; + +// ─── interpretImport ────────────────────────────────────────────────────── + +/** + * Interpret a COPY statement as a `ParsedImport`. + * + * The `@import.name` capture contains the copybook target name (e.g., + * `CPSESP` from `COPY CPSESP.`). Returns a `'named'` import with the + * copybook name as both `localName` and `importedName`. + * + * Returns `null` for any match that doesn't carry an `@import.name` (e.g., + * malformed COPY statements the regex tagger might emit). + */ +export function interpretCobolImport(match: CaptureMatch): ParsedImport | null { + const nameCap = match['@import.name']; + if (nameCap === undefined) return null; + + const name = nameCap.text; + if (name === '') return null; + + return { + kind: 'named', + localName: name, + importedName: name, + targetRaw: name, + }; +} + +// ─── interpretTypeBinding ───────────────────────────────────────────────── + +/** + * COBOL has no type system — no type bindings to interpret. + * Always returns `null`. + */ +export function interpretCobolTypeBinding(_match: CaptureMatch): ParsedTypeBinding | null { + return null; +} + +// ─── importOwningScope ──────────────────────────────────────────────────── + +/** + * COPY statements in COBOL are module-level — they expand inline at + * compile time and their bindings belong to the enclosing PROGRAM-ID + * (Module) scope. Walk up from the innermost scope through ancestors + * to find the enclosing Module scope. + * + * For the edge case of a COPY inside a paragraph (unusual but possible + * with some vendors), we walk the scope tree to ensure the import is + * attached to the program scope, not the paragraph Function scope. + */ +export function cobolImportOwningScope( + _imp: ParsedImport, + innermost: Scope, + tree: ScopeTree, +): ScopeId | null { + // If already in a Module scope, use it directly. + if (innermost.kind === 'Module') return innermost.id; + // Walk through ancestors to find the enclosing Module. + const ancestors = tree.getAncestors(innermost.id); + for (const ancId of ancestors) { + const anc = tree.getScope(ancId); + if (anc !== undefined && anc.kind === 'Module') return ancId; + } + // Fallback: delegate to central default. + return null; +} + +// ─── receiverBinding ────────────────────────────────────────────────────── + +/** + * COBOL has no implicit receiver (no `self`, `this`, or equivalent). + * All function calls are explicit CALL statements or PERFORM/GO TO + * control flow. Always returns `null`. + */ +export function cobolReceiverBinding(_functionScope: Scope): TypeRef | null { + return null; +} diff --git a/gitnexus/src/core/ingestion/languages/cobol/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/cobol/scope-resolver.ts new file mode 100644 index 000000000..799cc518e --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cobol/scope-resolver.ts @@ -0,0 +1,75 @@ +/** + * COBOL `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed + * by the generic `runScopeResolution` orchestrator. + * + * The provider is a thin wiring object — COBOL's simple scope model + * (Module + Function only, no inheritance, no type system) plugs into + * `runScopeResolution` with minimal configuration. + * + * Reference: `languages/python/scope-resolver.ts`. + */ + +import path from 'node:path'; +import type { ParsedFile } from 'gitnexus-shared'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js'; +import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; +import { cobolProvider } from '../cobol.js'; + +// Copybook file extensions for COPY name resolution +const COPYBOOK_EXTENSIONS = new Set(['.cpy', '.copybook']); + +const cobolScopeResolver: ScopeResolver = { + language: SupportedLanguages.Cobol, + languageProvider: cobolProvider, + importEdgeReason: 'cobol-scope: copy', + + // ── Resolve COPY bookname to file path ───────────────────────────── + resolveImportTarget: (targetRaw, _fromFile, allFilePaths) => { + const upper = targetRaw.toUpperCase(); + // Check copybook files first + for (const fp of allFilePaths) { + const ext = path.extname(fp).toLowerCase(); + if (!COPYBOOK_EXTENSIONS.has(ext)) continue; + const basename = path.basename(fp, ext).toUpperCase(); + if (basename === upper) return fp; + } + // Also search COBOL source files (.cbl, .cob, .cobol) + const COBOL_SOURCE_EXTS = new Set(['.cbl', '.cob', '.cobol']); + for (const fp of allFilePaths) { + const ext = path.extname(fp).toLowerCase(); + if (!COBOL_SOURCE_EXTS.has(ext)) continue; + const basename = path.basename(fp, ext).toUpperCase(); + if (basename === upper) return fp; + } + return null; + }, + + // COBOL has no binding-merge rules beyond the default (local-first-then-imports). + mergeBindings: (existing) => [...existing], + + // COBOL arity: compare CALL USING param count against def's parameterCount. + // COBOL requires exact arity match for CALL USING. + arityCompatibility: (callsite, def) => { + if (callsite.arity === undefined) return 'unknown'; + const defParamCount = def.parameterCount; + if (defParamCount === undefined) return 'unknown'; + if (callsite.arity === defParamCount) return 'compatible'; + return 'incompatible'; + }, + + // No inheritance in COBOL — empty MRO map. + buildMro: () => new Map(), + + // Everything lives under the PROGRAM-ID Module scope. + populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed), + + // COBOL has no super calls. + isSuperReceiver: () => false, + + // ── Optional toggles ───────────────────────────────────────────── + fieldFallbackOnMethodLookup: false, + propagatesReturnTypesAcrossImports: false, +}; + +export { cobolScopeResolver }; diff --git a/gitnexus/src/core/ingestion/languages/cpp/captures.ts b/gitnexus/src/core/ingestion/languages/cpp/captures.ts index de8cd058e..354f6ce4c 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/captures.ts @@ -126,6 +126,13 @@ export function emitCppScopeCaptures( JSON.stringify(arity.parameterTypeClasses), ); } + if (hasExplicitSpecifier(fnNode)) { + grouped['@declaration.is-explicit'] = syntheticCapture( + '@declaration.is-explicit', + fnNode, + 'true', + ); + } // Detect static storage class (file-local linkage) if (hasStaticStorageClass(fnNode)) { @@ -475,8 +482,9 @@ function detectCppDependentBases(root: SyntaxNode, filePath: string): void { for (const base of iterBaseClasses(baseClause)) { if (isBaseDependent(base, params)) { const baseName = extractBaseLookupName(base); + const baseQualifier = extractBaseLookupQualifier(base); if (baseName !== '') { - markCppDependentBase(filePath, className, baseName); + markCppDependentBase(filePath, className, baseName, baseQualifier); } } } @@ -553,10 +561,14 @@ function* iterBaseClasses(baseClause: SyntaxNode): IterableIterator */ function isBaseDependent(baseNode: SyntaxNode, templateParams: Set): boolean { if (baseNode.type !== 'template_type') { - // Bare `type_identifier` or `qualified_identifier` bases — not - // dependent (the base name itself doesn't reference a template - // parameter at this level). - return false; + if (baseNode.type === 'qualified_identifier') { + // Qualified identifier bases (e.g. `detail::Inner`) may contain + // template_type children — descend into them for template param check. + // Fall through to the stack walk below. + } else { + // Bare `type_identifier` bases — not dependent. + return false; + } } // Walk all descendants of the template_argument_list looking for any // type_identifier matching a template parameter, or any conservative- @@ -624,6 +636,27 @@ function extractBaseLookupName(baseNode: SyntaxNode): string { return ''; } +/** Extract the syntactic namespace qualifier from a base class node. + * For `detail::Inner`, returns `'detail'`. + * For unqualified bases (`Inner`, `Base`), returns `''`. + * Nested qualifiers (`a::b::Inner`) return the full scope text. + */ +function extractBaseLookupQualifier(baseNode: SyntaxNode): string { + if (baseNode.type === 'qualified_identifier') { + const scopeNode = baseNode.childForFieldName('scope'); + if (scopeNode !== null) return scopeNode.text; + } + // template_type nodes may have a qualified_identifier as their name child + if (baseNode.type === 'template_type') { + const nameNode = baseNode.childForFieldName('name'); + if (nameNode !== null && nameNode.type === 'qualified_identifier') { + const scopeNode = nameNode.childForFieldName('scope'); + if (scopeNode !== null) return scopeNode.text; + } + } + return ''; +} + /** * Walk parent chain from a function_definition / declaration / field_declaration * to find the enclosing `template_declaration`. Returns null when the function @@ -1516,6 +1549,20 @@ function extractDeclaratorLeafName(node: SyntaxNode): string | null { return null; } +/** + * Check if a C++ declaration has an `explicit` specifier. Tree-sitter-cpp + * exposes `explicit` as a direct keyword child on constructor declarations in + * current grammar builds; the bounded text prefix keeps this resilient across + * small grammar shape differences without scanning whole function bodies. + */ +function hasExplicitSpecifier(node: SyntaxNode): boolean { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child !== null && child.text === 'explicit') return true; + } + return /\bexplicit\b/.test(node.text.slice(0, 128)); +} + /** * Check if a C++ function_definition or declaration has `static` storage class. */ diff --git a/gitnexus/src/core/ingestion/languages/cpp/conversion-rank.ts b/gitnexus/src/core/ingestion/languages/cpp/conversion-rank.ts index bea3600a7..330deea09 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/conversion-rank.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/conversion-rank.ts @@ -12,7 +12,8 @@ * - rank 2: standard conversion (arithmetic, nullptr -> T*, T* -> bool, * T* -> void*) * - rank 3: nullptr -> bool (kept worse than nullptr -> T*) - * - rank 4: ellipsis conversion (worst viable) + * - rank 4: user-defined conversion (one-step, conservative) + * - rank 5: ellipsis conversion (worst viable) * - Infinity: mismatch (string -> int, user types, unsupported shapes) * * This function is intentionally C++-specific. Other languages may define @@ -20,6 +21,7 @@ */ import type { ParameterTypeClass } from 'gitnexus-shared'; +import { hasCppUserDefinedConversion } from './user-defined-conversions.js'; /** Set of normalized arithmetic types that support implicit conversion. */ const ARITHMETIC = new Set(['int', 'double', 'char', 'bool']); @@ -34,7 +36,8 @@ const INTEGRAL_PROMOTION = new Map([ * Return the conversion rank from `argType` to `paramType`. * * @returns 0 for exact match, 1 for integral promotion, 2 for standard - * conversion, 3 for nullptr -> bool, 4 for ellipsis, Infinity + * conversion, 3 for nullptr -> bool, 4 for user-defined conversion, + * 5 for ellipsis, Infinity * for mismatch. */ export function cppConversionRank( @@ -46,13 +49,14 @@ export function cppConversionRank( if (argType === paramType) { return exactShapeCompatible(argTypeClass, paramTypeClass) ? 0 : Infinity; } - if (paramType === '...') return 4; + if (paramType === '...') return 5; if (INTEGRAL_PROMOTION.get(argType) === paramType) return 1; if (ARITHMETIC.has(argType) && ARITHMETIC.has(paramType)) return 2; if (argType === 'null' && isPointer(paramTypeClass)) return 2; if (argType === 'null' && paramType === 'bool') return 3; if (isPointer(argTypeClass) && paramType === 'bool') return 2; if (isPointer(argTypeClass) && isPointer(paramTypeClass) && paramType === 'void') return 2; + if (hasCppUserDefinedConversion(argType, paramType)) return 4; return Infinity; } diff --git a/gitnexus/src/core/ingestion/languages/cpp/query.ts b/gitnexus/src/core/ingestion/languages/cpp/query.ts index d42b586ce..af15e81e6 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/query.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/query.ts @@ -225,6 +225,12 @@ const CPP_SCOPE_QUERY = ` declarator: (function_declarator declarator: (field_identifier) @declaration.name))) @declaration.method +;; Constructor prototype in class body: User(int id); +(field_declaration_list + (declaration + declarator: (function_declarator + declarator: (identifier) @declaration.name)) @declaration.method) + ;; Method prototype with reference return: User& getRef(); (field_declaration declarator: (reference_declarator diff --git a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts index 9a5ac85a5..40ff978d6 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts @@ -34,6 +34,10 @@ import { } from './inline-namespaces.js'; import { populateCppRangeBindings } from './range-bindings.js'; import { cppConstraintCompatibility } from './constraint-filter.js'; +import { + clearCppUserDefinedConversions, + populateCppUserDefinedConversions, +} from './user-defined-conversions.js'; /** * C++ `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by @@ -61,6 +65,7 @@ export const cppScopeResolver: ScopeResolver = { clearCppDependentBases(); clearCppAdlState(); clearCppInlineNamespaces(); + clearCppUserDefinedConversions(); return scanCppHeaderFiles(repoPath); }, @@ -110,6 +115,10 @@ export const cppScopeResolver: ScopeResolver = { // by ADL (U2 of plan 2026-05-13-001) to identify each argument type's // associated namespace for Koenig lookup. populateCppAssociatedNamespaces(parsed); + // Build conservative one-step user-defined conversion facts for + // overload ranking (#1631): implicit converting constructors only, + // with no chaining or conversion-operator handling. + populateCppUserDefinedConversions(parsed); }, // Resolve recorded template-class → dependent-base simple names to 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 ec95d7701..928138ade 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/two-phase-lookup.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/two-phase-lookup.ts @@ -37,12 +37,14 @@ import { findEnclosingClassDef } from '../../scope-resolution/scope/walkers.js'; /** * Capture-time record: for each template class declaration in a file, - * the simple names of its dependent base classes. + * the simple names of its dependent base classes and their syntactic + * qualifiers (e.g., `detail` for `detail::Inner`). * * Key: filePath - * Value: Map> + * Value: Map> + * qualifier is '' when the base was unqualified. */ -const dependentBasesByFile = new Map>>(); +const dependentBasesByFile = new Map>>>(); /** * Post-`populateOwners` resolution: per-class-nodeId, the set of @@ -55,12 +57,19 @@ const dependentBaseNodeIds = new Map>(); * Record a dependent-base relationship discovered during scope-capture * emission. `className` is the simple name of the template class; * `baseName` is the simple name of the dependent base class. + * `qualifier` is the syntactic namespace qualifier (e.g. `detail` for + * `detail::Inner`), or '' for unqualified bases. * * The capture-time recorder uses simple names because the registry * resolution that maps names → nodeIds runs later (in * `populateCppDependentBases`). */ -export function markCppDependentBase(filePath: string, className: string, baseName: string): void { +export function markCppDependentBase( + filePath: string, + className: string, + baseName: string, + qualifier = '', +): void { let perFile = dependentBasesByFile.get(filePath); if (perFile === undefined) { perFile = new Map(); @@ -68,10 +77,15 @@ export function markCppDependentBase(filePath: string, className: string, baseNa } let bases = perFile.get(className); if (bases === undefined) { - bases = new Set(); + bases = new Map(); perFile.set(className, bases); } - bases.add(baseName); + let quals = bases.get(baseName); + if (quals === undefined) { + quals = new Set(); + bases.set(baseName, quals); + } + quals.add(qualifier); } /** Clear two-phase-lookup state. Called from `clearFileLocalNames`. */ @@ -89,8 +103,10 @@ export function clearCppDependentBases(): void { * Disambiguation strategy (multiple classes sharing a simple name): * 1. Prefer the candidate whose qualified-name namespace prefix matches * the deriving class's namespace prefix (same-namespace bias). - * 2. Fall back to accepting a unique simple-name match. - * 3. Skip when multiple candidates exist and no namespace match is + * 2. When a syntactic qualifier is available (`detail` in + * `detail::Inner`), target the exact namespace derived from it. + * 3. Fall back to accepting a unique simple-name match. + * 4. Skip when multiple candidates exist and no namespace match is * found (conservative: avoids false associations). */ export function populateCppDependentBases(parsedFiles: readonly ParsedFile[]): void { @@ -99,7 +115,13 @@ export function populateCppDependentBases(parsedFiles: readonly ParsedFile[]): v // Build workspace-wide index: simpleName → {nodeId, nsPrefix}[] // nsPrefix is the dot-joined namespace path (qualifiedName without the // last segment). Classes at global scope have nsPrefix = ''. + // Dedup by nodeId, keeping the LAST occurrence: parsed.localDefs may + // list the same class def multiple times — the scope-extractor creates + // a def with simple-name qualifiedName first, then the class extractor + // replaces it with the correct fully-qualified qualifiedName. Keeping + // the later entry ensures we capture the full namespace path. const classesBySimpleName = new Map(); + const entryByNodeId = new Map(); for (const parsed of parsedFiles) { for (const def of parsed.localDefs) { if (def.type !== 'Class' && def.type !== 'Struct' && def.type !== 'Interface') continue; @@ -108,14 +130,17 @@ export function populateCppDependentBases(parsedFiles: readonly ParsedFile[]): v const simple = lastDot >= 0 ? qn.slice(lastDot + 1) : qn; if (simple === '') continue; const nsPrefix = lastDot >= 0 ? qn.slice(0, lastDot) : ''; - let entries = classesBySimpleName.get(simple); - if (entries === undefined) { - entries = []; - classesBySimpleName.set(simple, entries); - } - entries.push({ nodeId: def.nodeId, nsPrefix }); + entryByNodeId.set(def.nodeId, { nodeId: def.nodeId, nsPrefix, simple }); } } + for (const entry of entryByNodeId.values()) { + let entries = classesBySimpleName.get(entry.simple); + if (entries === undefined) { + entries = []; + classesBySimpleName.set(entry.simple, entries); + } + entries.push({ nodeId: entry.nodeId, nsPrefix: entry.nsPrefix }); + } // Build a filePath → ParsedFile lookup for fast per-file access. const parsedByFile = new Map(); @@ -139,7 +164,12 @@ export function populateCppDependentBases(parsedFiles: readonly ParsedFile[]): v localClassByName.set(simple, { nodeId: def.nodeId, nsPrefix }); } - for (const [className, baseNames] of perFile) { + // V3: qualifier-based exact targeting. When the base specifier carries + // a syntactic qualifier (e.g., `detail` in `detail::Inner`), compute + // the expected namespace prefix and use exact (===) match. Falls back to + // the V2 prefix-contains heuristic when the qualifier isn't available or + // the exact match fails (absolute qualifier edge cases like `::std`). + for (const [className, baseEntries] of perFile) { const classEntry = localClassByName.get(className); if (classEntry === undefined) continue; @@ -149,54 +179,77 @@ export function populateCppDependentBases(parsedFiles: readonly ParsedFile[]): v dependentBaseNodeIds.set(classEntry.nodeId, bases); } - for (const baseName of baseNames) { - const candidates = classesBySimpleName.get(baseName); - if (candidates === undefined || candidates.length === 0) continue; + for (const [baseName, qualsSet] of baseEntries) { + for (const baseQualifier of qualsSet) { + const candidates = classesBySimpleName.get(baseName); + if (candidates === undefined || candidates.length === 0) continue; - if (candidates.length === 1) { - // Unique simple-name match — accept regardless of namespace. - bases.add(candidates[0].nodeId); - continue; - } + // Compute the expected namespace prefix from the qualifier. + // Relative qualifier (e.g. `inner`): prepend deriving class's prefix. + // Absolute qualifiers (`::std`, `ns::other`) will fail the relative + // lookup and fall through to the prefix-heuristic below. + const normalizedQualifier = baseQualifier.replace(/::/g, '.'); + const expectedNs = + baseQualifier && classEntry.nsPrefix + ? classEntry.nsPrefix + '.' + normalizedQualifier + : normalizedQualifier; - // Multiple classes share the same simple name — prefer the one - // whose namespace matches the deriving class's namespace. - // 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 (candidates.length === 1) { + // Unqualified base: accept unique match (pre-existing behavior). + if (!baseQualifier) { + bases.add(candidates[0].nodeId); + continue; + } + // Qualified base: verify namespace before accepting. + if ( + candidates[0].nsPrefix === expectedNs || + candidates[0].nsPrefix === normalizedQualifier + ) { + bases.add(candidates[0].nodeId); + } + // else: suppress — qualifier doesn't match. #1564 policy. + continue; } - if (c.nsPrefix.startsWith(classEntry.nsPrefix + '.')) { - const suffix = c.nsPrefix.slice(classEntry.nsPrefix.length + 1); - return !suffix.includes('.'); + + // V3: qualifier-based exact targeting. When the base specifier + // carries a syntactic qualifier, compute the expected namespace + // prefix and attempt an exact (===) match using the deduplicated + // nsPrefix. Dedup by nodeId removes broken entries from the + // classesBySimpleName index, making the surviving nsPrefix reliable. + if (baseQualifier) { + const qualifierMatch = candidates.find( + (c) => c.nsPrefix === expectedNs || c.nsPrefix === normalizedQualifier, + ); + if (qualifierMatch !== undefined) { + bases.add(qualifierMatch.nodeId); + continue; + } + continue; // qualifier was explicit but no match — suppress, don't fall through to V2 } - return false; - }); - const nsMatch = nsMatches.length === 1 ? nsMatches[0] : undefined; - if (nsMatch !== undefined) { - bases.add(nsMatch.nodeId); + + // V2 fallback: filter by prefix-match capped at one level deeper, + // then accept only if exactly one candidate survives. + 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); + } + // else: ambiguous (multiple candidates, no namespace match) → skip. } - // else: ambiguous (multiple candidates, no namespace match) → skip. } } } } - /** * Two-phase lookup predicate: is the candidate def a member of a * dependent base of the caller's enclosing template class? diff --git a/gitnexus/src/core/ingestion/languages/cpp/user-defined-conversions.ts b/gitnexus/src/core/ingestion/languages/cpp/user-defined-conversions.ts new file mode 100644 index 000000000..85fc951df --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/user-defined-conversions.ts @@ -0,0 +1,140 @@ +import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; +import type { ScopeId } from 'gitnexus-shared'; +import { normalizeCppParamType } from './arity-metadata.js'; + +const userDefinedConversions = new Set(); +const pendingUserDefinedConversions: PendingUserDefinedConversion[] = []; +const classIdentitiesBySimpleName = new Map>(); + +interface PendingUserDefinedConversion { + readonly argType: string; + readonly paramType: string; + readonly ownerClassName: string; +} + +export function clearCppUserDefinedConversions(): void { + userDefinedConversions.clear(); + pendingUserDefinedConversions.length = 0; + classIdentitiesBySimpleName.clear(); +} + +export function hasCppUserDefinedConversion(argType: string, paramType: string): boolean { + return userDefinedConversions.has(conversionKey(argType, paramType)); +} + +export function populateCppUserDefinedConversions(parsed: ParsedFile): void { + const scopesById = new Map(); + for (const scope of parsed.scopes) scopesById.set(scope.id, scope); + + for (const classScope of parsed.scopes) { + if (classScope.kind !== 'Class') continue; + const classDef = classScope.ownedDefs.find(isClassLike); + if (classDef !== undefined) recordClassIdentity(classDef); + } + + for (const classScope of parsed.scopes) { + if (classScope.kind !== 'Class') continue; + const classDef = classScope.ownedDefs.find(isClassLike); + if (classDef === undefined) continue; + const className = normalizedSimpleName(classDef); + if (className === '') continue; + + const methodDefs = collectClassMethodDefs(classScope.id, parsed, scopesById); + for (const def of methodDefs) { + const simpleName = simpleNameOf(def); + if (simpleName === className && def.parameterTypes?.length === 1) { + if (def.isExplicit === true) continue; + registerPendingCppUserDefinedConversion(def.parameterTypes[0], className, className); + } + } + } + + rebuildCppUserDefinedConversions(); +} + +export function registerCppUserDefinedConversion(argType: string, paramType: string): void { + if (argType === '' || paramType === '') return; + if (argType === paramType) return; + userDefinedConversions.add(conversionKey(argType, paramType)); +} + +function collectClassMethodDefs( + classScopeId: ScopeId, + parsed: ParsedFile, + scopesById: ReadonlyMap, +): SymbolDefinition[] { + const methods: SymbolDefinition[] = []; + const classScope = scopesById.get(classScopeId); + if (classScope === undefined) return methods; + + for (const def of classScope.ownedDefs) { + if (isCallableMember(def)) methods.push(def); + } + for (const scope of parsed.scopes) { + if (scope.parent !== classScopeId) continue; + if (scope.kind === 'Class') continue; + for (const def of scope.ownedDefs) { + if (isCallableMember(def)) methods.push(def); + } + } + return methods; +} + +function conversionKey(argType: string, paramType: string): string { + return `${argType}\0${paramType}`; +} + +function registerPendingCppUserDefinedConversion( + argType: string, + paramType: string, + ownerClassName: string, +): void { + if (argType === '' || paramType === '') return; + if (argType === paramType) return; + pendingUserDefinedConversions.push({ argType, paramType, ownerClassName }); +} + +function rebuildCppUserDefinedConversions(): void { + userDefinedConversions.clear(); + for (const conversion of pendingUserDefinedConversions) { + if (isAmbiguousClassName(conversion.ownerClassName)) continue; + userDefinedConversions.add(conversionKey(conversion.argType, conversion.paramType)); + } +} + +function recordClassIdentity(def: SymbolDefinition): void { + const simpleName = normalizedSimpleName(def); + if (simpleName === '') return; + const identities = classIdentitiesBySimpleName.get(simpleName) ?? new Set(); + identities.add(normalizedQualifiedClassName(def)); + classIdentitiesBySimpleName.set(simpleName, identities); +} + +function isAmbiguousClassName(simpleName: string): boolean { + return (classIdentitiesBySimpleName.get(simpleName)?.size ?? 0) > 1; +} + +function normalizedQualifiedClassName(def: SymbolDefinition): string { + const qualifiedName = def.qualifiedName ?? simpleNameOf(def); + if (qualifiedName === '' || !qualifiedName.includes('.')) return `${def.filePath}:${def.nodeId}`; + return qualifiedName + .split('.') + .map((part) => normalizeCppParamType(part)) + .join('.'); +} + +function normalizedSimpleName(def: SymbolDefinition): string { + return normalizeCppParamType(simpleNameOf(def)); +} + +function simpleNameOf(def: SymbolDefinition): string { + return def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; +} + +function isClassLike(def: SymbolDefinition): boolean { + return def.type === 'Class' || def.type === 'Struct' || def.type === 'Interface'; +} + +function isCallableMember(def: SymbolDefinition): boolean { + return def.type === 'Method' || def.type === 'Constructor'; +} diff --git a/gitnexus/src/core/ingestion/languages/python/captures.ts b/gitnexus/src/core/ingestion/languages/python/captures.ts index bc4911546..3c20101db 100644 --- a/gitnexus/src/core/ingestion/languages/python/captures.ts +++ b/gitnexus/src/core/ingestion/languages/python/captures.ts @@ -21,6 +21,7 @@ import { findNodeAtRange, nodeToCapture, syntheticCapture } from '../../utils/as import { splitImportStatement } from './import-decomposer.js'; import { getPythonParser, getPythonScopeQuery } from './query.js'; import { synthesizeReceiverTypeBinding } from './receiver-binding.js'; +import { synthesizeDependsReferences } from './depends-references.js'; import { computePythonArityMetadata } from './arity-metadata.js'; import { recordCacheHit, recordCacheMiss } from './cache-stats.js'; import { getTreeSitterBufferSize } from '../../constants.js'; @@ -98,6 +99,7 @@ export function emitPythonScopeCaptures( if (fnNode !== null) { const synth = synthesizeReceiverTypeBinding(fnNode); if (synth !== null) out.push(synth); + for (const depRef of synthesizeDependsReferences(fnNode)) out.push(depRef); } continue; } diff --git a/gitnexus/src/core/ingestion/languages/python/depends-references.ts b/gitnexus/src/core/ingestion/languages/python/depends-references.ts new file mode 100644 index 000000000..333c4f7e2 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/python/depends-references.ts @@ -0,0 +1,72 @@ +/** + * Synthesize `@reference.call.free` captures for FastAPI `Depends(callable)` + * parameter defaults. + * + * `Depends(get_db)` passes `get_db` as a callable that the DI framework + * calls on every request. The route handler is functionally a caller of + * the dependency — impact analysis needs that edge. + * + * Tree-sitter can't express "the first argument of a call named Depends + * inside a parameter default" in a single static query, so we synthesize + * reference captures in code, mirroring the receiver-binding pattern. + */ + +import type { CaptureMatch } from 'gitnexus-shared'; +import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; + +/** + * Inspect a `function_definition` node's parameters for `Depends(callable)` + * defaults. Returns one `@reference.call.free` CaptureMatch per dependency. + */ +export function synthesizeDependsReferences(fnNode: SyntaxNode): readonly CaptureMatch[] { + const params = fnNode.childForFieldName('parameters'); + if (params === null) return []; + + const results: CaptureMatch[] = []; + + for (let i = 0; i < params.namedChildCount; i++) { + const param = params.namedChild(i); + if (param === null) continue; + + if (param.type !== 'typed_default_parameter' && param.type !== 'default_parameter') { + continue; + } + + const defaultValue = param.childForFieldName('value') ?? param.childForFieldName('default'); + if (defaultValue === null) continue; + + const callNode = defaultValue.type === 'call' ? defaultValue : null; + if (callNode === null) continue; + + const fnIdent = callNode.childForFieldName('function'); + if (fnIdent === null || fnIdent.type !== 'identifier' || fnIdent.text !== 'Depends') continue; + + const args = callNode.childForFieldName('arguments'); + if (args === null || args.namedChildCount === 0) continue; + + const firstArg = args.namedChild(0); + if (firstArg === null) continue; + + if (firstArg.type === 'identifier') { + results.push({ + '@reference.call.free': nodeToCapture('@reference.call.free', firstArg), + '@reference.name': nodeToCapture('@reference.name', firstArg), + }); + continue; + } + + if (firstArg.type === 'attribute') { + const attrName = firstArg.childForFieldName('attribute'); + const obj = firstArg.childForFieldName('object'); + if (attrName !== null && obj !== null) { + results.push({ + '@reference.call.member': nodeToCapture('@reference.call.member', attrName), + '@reference.name': nodeToCapture('@reference.name', attrName), + '@reference.receiver': nodeToCapture('@reference.receiver', obj), + }); + } + } + } + + return results; +} diff --git a/gitnexus/src/core/ingestion/languages/ruby.ts b/gitnexus/src/core/ingestion/languages/ruby.ts index e5f71ee56..1441abb59 100644 --- a/gitnexus/src/core/ingestion/languages/ruby.ts +++ b/gitnexus/src/core/ingestion/languages/ruby.ts @@ -33,6 +33,15 @@ import { rubyHeritageConfig } from '../heritage-extractors/configs/ruby.js'; import { maybeRewriteRubyBareCallToSelf } from '../utils/ruby-self-call.js'; import { findEnclosingClassInfo } from '../utils/ast-helpers.js'; import type { DispatchDecision, ImplicitReceiverOverride } from '../call-types.js'; +import { + emitRubyScopeCaptures, + rubyArityCompatibility, + rubyBindingScopeFor, + rubyImportOwningScope, + rubyReceiverBinding, + interpretRubyImport, + interpretRubyTypeBinding, +} from './ruby/index.js'; /** * Ruby label override. Applied to: @@ -258,4 +267,12 @@ export const rubyProvider = defineLanguage({ }, builtInNames: BUILT_INS, + // ── RFC #909 Ring 3: scope-based resolution hooks ────────── + emitScopeCaptures: emitRubyScopeCaptures, + interpretImport: interpretRubyImport, + interpretTypeBinding: interpretRubyTypeBinding, + bindingScopeFor: rubyBindingScopeFor, + importOwningScope: rubyImportOwningScope, + receiverBinding: rubyReceiverBinding, + arityCompatibility: rubyArityCompatibility, }); diff --git a/gitnexus/src/core/ingestion/languages/ruby/arity.ts b/gitnexus/src/core/ingestion/languages/ruby/arity.ts new file mode 100644 index 000000000..3320b2b1e --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/arity.ts @@ -0,0 +1,45 @@ +/** + * Ruby arity check, accommodating `*args`, `**kwargs`, and defaults. + * + * The `def` metadata we care about (set by the existing Ruby method/ + * function extractor): + * - `parameterCount` — total positional + keyword params + * - `requiredParameterCount` — min required (excludes defaults / `*args` / `**kwargs`) + * - `parameterTypes` — present when types are known; we also use it + * as a "we have varargs" hint (`'*args'`, + * `'**kwargs'` literals appear in the array). + * + * Verdicts: + * - `'compatible'` — `requiredParameterCount <= argCount <= parameterCount`, + * OR the def takes `*args` (then any `argCount >= required` ok). + * - `'incompatible'` — argCount is below required, OR above max with no `*args`. + * - `'unknown'` — def metadata is absent / incomplete. + * + * `'incompatible'` is a soft signal in `Registry.lookup` (penalized but + * still considered when no compatible candidate exists), per RFC 4. + */ + +import type { Callsite, SymbolDefinition } from 'gitnexus-shared'; + +export function rubyArityCompatibility( + def: SymbolDefinition, + callsite: Callsite, +): 'compatible' | 'unknown' | 'incompatible' { + const max = def.parameterCount; + const min = def.requiredParameterCount; + if (max === undefined && min === undefined) return 'unknown'; + + const argCount = callsite.arity; + if (!Number.isFinite(argCount) || argCount < 0) return 'unknown'; + + // Detect varargs/kwargs from parameterTypes if present (the Ruby + // method extractor stores `'*args'`/`'**kwargs'` in this list). + const hasVarArgs = + def.parameterTypes !== undefined && + def.parameterTypes.some((t) => t === '*args' || t === '**kwargs' || t.startsWith('*')); + + if (min !== undefined && argCount < min) return 'incompatible'; + if (max !== undefined && argCount > max && !hasVarArgs) return 'incompatible'; + + return 'compatible'; +} diff --git a/gitnexus/src/core/ingestion/languages/ruby/cache-stats.ts b/gitnexus/src/core/ingestion/languages/ruby/cache-stats.ts new file mode 100644 index 000000000..de341c074 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/cache-stats.ts @@ -0,0 +1,18 @@ +let hits = 0; +let misses = 0; + +export function recordRubyCacheHit(): void { + hits++; +} +export function recordRubyCacheMiss(): void { + misses++; +} + +export function getRubyCaptureCacheStats(): { readonly hits: number; readonly misses: number } { + return { hits, misses }; +} + +export function resetRubyCaptureCacheStats(): void { + hits = 0; + misses = 0; +} diff --git a/gitnexus/src/core/ingestion/languages/ruby/captures.ts b/gitnexus/src/core/ingestion/languages/ruby/captures.ts new file mode 100644 index 000000000..577d7b6b5 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/captures.ts @@ -0,0 +1,619 @@ +import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { + findNodeAtRange, + nodeToCapture, + syntheticCapture, + type SyntaxNode, +} from '../../utils/ast-helpers.js'; +import { getRubyParser, getRubyScopeQuery } from './query.js'; +import { recordRubyCacheHit, recordRubyCacheMiss } from './cache-stats.js'; +import { synthesizeRubyReceiverBinding, findEnclosingClassOrModule } from './receiver-binding.js'; +import { getTreeSitterBufferSize } from '../../constants.js'; +import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; + +const FUNCTION_NODE_TYPES = ['method', 'singleton_method'] as const; +const HERITAGE_CALL_NAMES: ReadonlySet = new Set(['include', 'extend', 'prepend']); +const ATTR_CALL_NAMES: ReadonlySet = new Set([ + 'attr_accessor', + 'attr_reader', + 'attr_writer', +]); + +export function emitRubyScopeCaptures( + sourceText: string, + _filePath: string, + cachedTree?: unknown, +): readonly CaptureMatch[] { + let tree = cachedTree as ReturnType['parse']> | undefined; + if (tree === undefined) { + try { + tree = parseSourceSafe(getRubyParser(), sourceText, undefined, { + bufferSize: getTreeSitterBufferSize(sourceText), + }); + } catch (err) { + throw scopeExtractionError('parse', _filePath, err); + } + recordRubyCacheMiss(); + } else { + recordRubyCacheHit(); + } + + let rawMatches: ReturnType['matches']>; + try { + rawMatches = getRubyScopeQuery().matches(tree.rootNode); + } catch (err) { + throw scopeExtractionError('scope query', _filePath, err); + } + + 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 require/require_relative/load into import captures + if (grouped['@import.statement'] !== undefined) { + const anchor = grouped['@import.statement']!; + const callNode = findNodeAtRange(tree.rootNode, anchor.range, 'call'); + if (callNode !== null) { + const decomposed = decomposeRubyImport(callNode, anchor); + if (decomposed !== null) { + out.push(decomposed); + continue; + } + } + out.push(grouped); + continue; + } + + // Synthesize self receiver bindings for methods inside class/module + if (grouped['@scope.function'] !== undefined) { + const scopeCap = grouped['@scope.function']!; + const fnNode = findFunctionNode(tree.rootNode, scopeCap.range); + if (fnNode !== null) { + const enclosingNode = findEnclosingClassOrModule(fnNode); + const receiver = synthesizeRubyReceiverBinding(fnNode, enclosingNode); + if (receiver !== null) out.push(receiver); + } + out.push(grouped); + continue; + } + + // Reclassify declaration.function as declaration.method + attach arity + if (grouped['@declaration.function'] !== undefined) { + const anchorCap = grouped['@declaration.function']!; + const fnNode = findFunctionNode(tree.rootNode, anchorCap.range); + if (fnNode !== null) { + const enclosingNode = findEnclosingClassOrModule(fnNode); + if (enclosingNode !== 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 = computeRubyDeclarationArity(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), + ); + } + if (arity.parameterTypes !== undefined) { + grouped['@declaration.parameter-types'] = syntheticCapture( + '@declaration.parameter-types', + fnNode, + JSON.stringify(arity.parameterTypes), + ); + } + } + out.push(grouped); + continue; + } + + // Intercept heritage calls (include/extend/prepend) — encode as + // special imports so emitHeritageEdges can emit IMPLEMENTS edges. + if (grouped['@reference.call.free'] !== undefined && grouped['@reference.name'] !== undefined) { + const callName = grouped['@reference.name']!.text; + if (HERITAGE_CALL_NAMES.has(callName)) { + const callNode = findNodeAtRange( + tree.rootNode, + grouped['@reference.call.free']!.range, + 'call', + ); + if (callNode !== null) { + const enclosing = findEnclosingClassOrModule(callNode); + const ownerName = enclosing?.childForFieldName('name')?.text; + if (ownerName) { + const argList = callNode.childForFieldName('arguments'); + if (argList !== null) { + for (let ai = 0; ai < argList.namedChildCount; ai++) { + const arg = argList.namedChild(ai); + if (arg !== null && (arg.type === 'constant' || arg.type === 'scope_resolution')) { + out.push({ + '@import.statement': grouped['@reference.call.free']!, + '@import.kind': syntheticCapture('@import.kind', callNode, 'namespace'), + '@import.source': syntheticCapture( + '@import.source', + callNode, + `__heritage__:${callName}:${arg.text}:${ownerName}`, + ), + '@import.name': syntheticCapture('@import.name', callNode, arg.text), + }); + } + } + } + } + } + continue; + } + + // Intercept attr_accessor/attr_reader/attr_writer — encode as special + // imports so emitHeritageEdges can create Property nodes + HAS_PROPERTY. + // Also emit @declaration.property captures so each property ends up in + // localDefs and gets reconciled into model.fields, enabling write-access + // resolution via receiver-bound-calls (Case 4 → findOwnedMember). + if (ATTR_CALL_NAMES.has(callName)) { + const callNode = findNodeAtRange( + tree.rootNode, + grouped['@reference.call.free']!.range, + 'call', + ); + if (callNode !== null) { + const enclosing = findEnclosingClassOrModule(callNode); + const ownerName = enclosing?.childForFieldName('name')?.text; + if (ownerName) { + const argList = callNode.childForFieldName('arguments'); + if (argList !== null) { + for (let ai = 0; ai < argList.namedChildCount; ai++) { + const arg = argList.namedChild(ai); + if (arg !== null && (arg.type === 'simple_symbol' || arg.type === 'symbol')) { + const propName = arg.text.replace(/^:/, ''); + out.push({ + '@import.statement': grouped['@reference.call.free']!, + '@import.kind': syntheticCapture('@import.kind', callNode, 'namespace'), + '@import.source': syntheticCapture( + '@import.source', + callNode, + `__property__:${callName}:${propName}:${ownerName}`, + ), + '@import.name': syntheticCapture('@import.name', callNode, propName), + }); + // Emit a property declaration so the property flows into + // localDefs → model.fields for receiver-bound write access. + out.push({ + '@declaration.property': syntheticCapture( + '@declaration.property', + arg, + propName, + ), + '@declaration.name': syntheticCapture('@declaration.name', arg, propName), + }); + } + } + } + } + } + continue; + } + } + + // Attach call arity for call expressions + const callTag = (['@reference.call.free', '@reference.call.member'] as const).find( + (t) => grouped[t] !== undefined, + ); + if (callTag !== undefined && grouped['@reference.arity'] === undefined) { + const anchor = grouped[callTag]!; + const callNode = findNodeAtRange(tree.rootNode, anchor.range, 'call'); + if (callNode !== null) { + const arity = computeRubyCallArity(callNode); + grouped['@reference.arity'] = syntheticCapture('@reference.arity', callNode, String(arity)); + } + } + + out.push(grouped); + } + + // Second pass: member-call-return type bindings + // Synthesize compound type bindings for `x = recv.method(...)` assignments. + // The query-level @type-binding.call-return pattern only captures free calls + // (!receiver). For member calls we need `x → recv.method()` so the compound + // receiver resolver can chain-follow through the receiver's class scope. + for (const assignNode of tree.rootNode.descendantsOfType('assignment')) { + const left = assignNode.childForFieldName('left'); + const right = assignNode.childForFieldName('right'); + if (left === null || right === null) continue; + if (left.type !== 'identifier' && left.type !== 'constant') continue; + if (right.type !== 'call') continue; + const recvNode = right.childForFieldName('receiver'); + const methodNode = right.childForFieldName('method'); + if (recvNode === null || methodNode === null) continue; + // Skip .new calls — already handled by the constructor-inference query patterns + if (methodNode.text === 'new') continue; + const compoundName = `${recvNode.text}.${methodNode.text}()`; + out.push({ + '@type-binding.call-return': syntheticCapture( + '@type-binding.call-return', + assignNode, + assignNode.text, + ), + '@type-binding.name': syntheticCapture('@type-binding.name', assignNode, left.text), + '@type-binding.type': syntheticCapture('@type-binding.type', assignNode, compoundName), + }); + } + + // Third pass: YARD comment annotations (@param, @return, @type) + for (const comment of tree.rootNode.descendantsOfType('comment')) { + const text = comment.text; + + // @param name [Type] + const paramMatch = text.match(/@param\s+(\w+)\s+\[([^\]]+)\]/); + if (paramMatch) { + const [, paramName, typeName] = paramMatch; + const methodNode = findFollowingMethod(comment); + if (methodNode !== null && paramName && typeName) { + out.push({ + '@type-binding.parameter': syntheticCapture('@type-binding.parameter', methodNode, text), + '@type-binding.name': syntheticCapture('@type-binding.name', methodNode, paramName), + '@type-binding.type': syntheticCapture( + '@type-binding.type', + methodNode, + normalizeYardType(typeName), + ), + }); + } + } + + // @param [Type] name (alternate YARD order) + const paramAltMatch = text.match(/@param\s+\[([^\]]+)\]\s+(\w+)/); + if (!paramMatch && paramAltMatch) { + const [, typeName, paramName] = paramAltMatch; + const methodNode = findFollowingMethod(comment); + if (methodNode !== null && paramName && typeName) { + out.push({ + '@type-binding.parameter': syntheticCapture('@type-binding.parameter', methodNode, text), + '@type-binding.name': syntheticCapture('@type-binding.name', methodNode, paramName), + '@type-binding.type': syntheticCapture( + '@type-binding.type', + methodNode, + normalizeYardType(typeName), + ), + }); + } + } + + // @return [Type] + const returnMatch = text.match(/@return\s+\[([^\]]+)\]/); + if (returnMatch) { + const [, typeName] = returnMatch; + const methodNode = findFollowingMethod(comment); + if (methodNode !== null && typeName) { + const methodName = methodNode.childForFieldName('name')?.text; + if (methodName) { + out.push({ + '@type-binding.return': syntheticCapture('@type-binding.return', methodNode, text), + '@type-binding.name': syntheticCapture('@type-binding.name', methodNode, methodName), + '@type-binding.type': syntheticCapture( + '@type-binding.type', + methodNode, + normalizeYardType(typeName), + ), + }); + } + } else if (typeName) { + // YARD @return before attr_accessor/attr_reader/attr_writer: the + // comment precedes a `call` node (not a method). Extract the + // property name from the attr call's arguments and bind it to + // the annotated return type. This enables field-type chains + // like `user.address.save → Address#save`. + const attrNode = findFollowingAttrCall(comment); + if (attrNode !== null) { + const argList = attrNode.childForFieldName('arguments'); + if (argList !== null) { + for (let ai = 0; ai < argList.namedChildCount; ai++) { + const arg = argList.namedChild(ai); + if (arg !== null && (arg.type === 'simple_symbol' || arg.type === 'symbol')) { + const propName = arg.text.replace(/^:/, ''); + out.push({ + '@type-binding.return': syntheticCapture('@type-binding.return', attrNode, text), + '@type-binding.name': syntheticCapture('@type-binding.name', attrNode, propName), + '@type-binding.type': syntheticCapture( + '@type-binding.type', + attrNode, + normalizeYardType(typeName), + ), + }); + } + } + } + } + } + } + + // @type [Type] + const typeMatch = text.match(/@type\s+\[([^\]]+)\]/); + if (typeMatch) { + const [, typeName] = typeMatch; + const methodNode = findFollowingMethod(comment); + if (methodNode !== null && typeName) { + out.push({ + '@type-binding.parameter': syntheticCapture('@type-binding.parameter', methodNode, text), + '@type-binding.name': syntheticCapture('@type-binding.name', methodNode, ''), + '@type-binding.type': syntheticCapture( + '@type-binding.type', + methodNode, + normalizeYardType(typeName), + ), + }); + } + } + } + + // Fourth pass: constructor-return inference for methods. + // When a method's body ends with `ClassName.new(...)`, synthesize a + // return-type binding `methodName → ClassName` on the method node. + // This enables cross-file return-type propagation for factory methods + // like `def self.get_user; User.new; end` → `get_user → User`. + for (const methodNode of [ + ...tree.rootNode.descendantsOfType('method'), + ...tree.rootNode.descendantsOfType('singleton_method'), + ]) { + const methodName = methodNode.childForFieldName('name')?.text; + if (methodName === undefined) continue; + // Skip if a YARD @return already created a return binding for this method + if ( + out.some( + (m) => + m['@type-binding.return'] !== undefined && + m['@type-binding.name']?.text === methodName && + m['@type-binding.return']?.range.startLine === methodNode.startPosition.row, + ) + ) { + continue; + } + const body = methodNode.childForFieldName('body'); + if (body === null) continue; + // Find the last expression in the method body + const lastChild = body.namedChildCount > 0 ? body.namedChild(body.namedChildCount - 1) : null; + if (lastChild === null) continue; + // Check if the last expression is a `ClassName.new(...)` call + if (lastChild.type === 'call') { + const recv = lastChild.childForFieldName('receiver'); + const meth = lastChild.childForFieldName('method'); + if ( + recv !== null && + meth !== null && + meth.text === 'new' && + (recv.type === 'constant' || recv.type === 'scope_resolution') + ) { + out.push({ + '@type-binding.return': syntheticCapture( + '@type-binding.return', + methodNode, + `constructor-return: ${recv.text}.new`, + ), + '@type-binding.name': syntheticCapture('@type-binding.name', methodNode, methodName), + '@type-binding.type': syntheticCapture('@type-binding.type', methodNode, recv.text), + }); + } + } + } + + return out; +} + +function decomposeRubyImport(callNode: SyntaxNode, anchor: Capture): CaptureMatch | null { + const methodNode = callNode.childForFieldName('method'); + if (methodNode === null) return null; + const methodName = methodNode.text; + if (methodName !== 'require' && methodName !== 'require_relative' && methodName !== 'load') { + return null; + } + + const argsNode = callNode.childForFieldName('arguments'); + const argNode = argsNode !== null ? argsNode.namedChild(0) : callNode.namedChild(1); + if (argNode === null) return null; + + let sourcePath: string; + if (argNode.type === 'string') { + const contentChild = argNode.namedChild(0); + sourcePath = + contentChild !== null && contentChild.type === 'string_content' + ? contentChild.text + : argNode.text.replace(/^['"]|['"]$/g, ''); + } else { + return null; + } + + if (sourcePath === '') return null; + + const segments = sourcePath.replace(/\\/g, '/').split('/'); + const lastSegment = segments[segments.length - 1]!; + const moduleName = lastSegment.replace(/\.rb$/, ''); + + return { + '@import.statement': anchor, + '@import.kind': syntheticCapture('@import.kind', callNode, 'wildcard'), + '@import.source': syntheticCapture('@import.source', callNode, sourcePath), + '@import.name': syntheticCapture('@import.name', callNode, moduleName), + }; +} + +function computeRubyDeclarationArity(fnNode: SyntaxNode): { + parameterCount?: number; + requiredParameterCount?: number; + parameterTypes?: string[]; +} { + const params = fnNode.childForFieldName('parameters'); + if (params === null) return { parameterCount: 0, requiredParameterCount: 0 }; + + let totalCount = 0; + let requiredCount = 0; + const paramTypes: string[] = []; + + for (let i = 0; i < params.namedChildCount; i++) { + const child = params.namedChild(i); + if (child === null) continue; + + switch (child.type) { + case 'identifier': + totalCount++; + requiredCount++; + paramTypes.push(''); + break; + case 'optional_parameter': + totalCount++; + paramTypes.push(''); + break; + case 'splat_parameter': + totalCount++; + paramTypes.push('*args'); + break; + case 'hash_splat_parameter': + totalCount++; + paramTypes.push('**kwargs'); + break; + case 'block_parameter': + // &block not counted in arity + break; + case 'keyword_parameter': { + totalCount++; + const hasDefault = child.childForFieldName('value') !== null; + if (!hasDefault) requiredCount++; + paramTypes.push(''); + break; + } + default: + totalCount++; + requiredCount++; + paramTypes.push(''); + break; + } + } + + return { + parameterCount: totalCount, + requiredParameterCount: requiredCount, + parameterTypes: paramTypes.length > 0 ? paramTypes : undefined, + }; +} + +function computeRubyCallArity(callNode: SyntaxNode): number { + const argList = callNode.childForFieldName('arguments'); + if (argList === null) return 0; + + let count = 0; + for (let i = 0; i < argList.namedChildCount; i++) { + const child = argList.namedChild(i); + if (child !== null && child.type !== 'block') count++; + } + return count; +} + +function findFunctionNode(rootNode: SyntaxNode, range: Capture['range']): SyntaxNode | null { + for (const nodeType of FUNCTION_NODE_TYPES) { + const n = findNodeAtRange(rootNode, range, nodeType); + if (n !== null) return n; + } + return null; +} + +function scopeExtractionError(stage: string, filePath: string, err: unknown): Error { + const reason = err instanceof Error ? err.message : String(err); + return new Error( + `[ruby] tree-sitter ${stage} failed for ${filePath}: ${reason}; skipping scope extraction for this file`, + ); +} + +/** + * Walk forward from a comment node, skipping consecutive comments, + * and return the next `method` or `singleton_method` node (if any). + */ +function findFollowingMethod(commentNode: SyntaxNode): SyntaxNode | null { + let sibling = commentNode.nextNamedSibling; + while (sibling !== null && sibling.type === 'comment') { + sibling = sibling.nextNamedSibling; + } + if (sibling === null) return null; + if (sibling.type === 'method' || sibling.type === 'singleton_method') return sibling; + // In tree-sitter-ruby, YARD comments before a method inside a class body + // are children of `class`, while the method is inside `body_statement`. + // Walk into body_statement to find the method. + if (sibling.type === 'body_statement') { + const first = sibling.firstNamedChild; + if (first !== null && (first.type === 'method' || first.type === 'singleton_method')) { + return first; + } + } + return null; +} + +/** + * Walk forward from a comment node, skipping consecutive comments, + * and return the next `call` node whose method is attr_accessor / + * attr_reader / attr_writer (if any). Used to attach YARD `@return` + * annotations to property declarations. + */ +function findFollowingAttrCall(commentNode: SyntaxNode): SyntaxNode | null { + let sibling = commentNode.nextNamedSibling; + while (sibling !== null && sibling.type === 'comment') { + sibling = sibling.nextNamedSibling; + } + if (sibling === null) return null; + if (sibling.type === 'call') { + const methodNode = sibling.childForFieldName('method'); + if (methodNode !== null && ATTR_CALL_NAMES.has(methodNode.text)) { + return sibling; + } + } + return null; +} + +/** + * Normalize a YARD type string: for single-parameter generics like + * `Array` or `Array[User]` keep the inner type; for multi-param + * generics like `Hash` strip the generic and return the + * outer type name only. + */ +function normalizeYardType(raw: string): string { + const trimmed = raw.trim(); + + // Check for angle-bracket generics: Type or Type + const angleMatch = trimmed.match(/^(\w+)<(.+)>$/); + if (angleMatch) { + const inner = angleMatch[2]!; + // Single-param generic → return the inner type + if (!inner.includes(',')) return inner.trim(); + // Multi-param generic → return the outer type + return angleMatch[1]!; + } + + // Check for bracket generics: Type[Inner] (YARD sometimes uses this) + const bracketMatch = trimmed.match(/^(\w+)\[(.+)\]$/); + if (bracketMatch) { + const inner = bracketMatch[2]!; + if (!inner.includes(',')) return inner.trim(); + return bracketMatch[1]!; + } + + return trimmed; +} diff --git a/gitnexus/src/core/ingestion/languages/ruby/import-target.ts b/gitnexus/src/core/ingestion/languages/ruby/import-target.ts new file mode 100644 index 000000000..a8dcd323c --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/import-target.ts @@ -0,0 +1,107 @@ +/** + * Resolve a Ruby require/require_relative import path to a repo-relative file. + * + * Ruby import resolution rules: + * - `require_relative './foo'` → resolve relative to the importing file's dir + * - `require 'foo'` → suffix-match via the existing Ruby import resolver + * - External gems → null (unresolvable within the repo) + */ + +import { resolveRubyImportInternal } from '../../import-resolvers/ruby.js'; +import { buildSuffixIndex } from '../../import-resolvers/utils.js'; + +export interface RubyResolveContext { + readonly fromFile: string; + readonly allFilePaths: ReadonlySet; +} + +// ─── resolveRubyImportTarget ────────────────────────────────────────────── + +/** + * ScopeResolver-shaped adapter: + * `(targetRaw, fromFile, allFilePaths, resolutionConfig?) → string | string[] | null` + * + * For relative paths (`./` or `../` — require_relative semantics), resolves + * against the importing file's directory, trying `.rb` and `/index.rb` + * suffixes. + * + * For bare requires (gem-style like `'json'`, `'serializable'`), delegates + * to the existing `resolveRubyImportInternal` which uses suffix matching. + * + * Returns `null` for external gems that have no matching file in the repo. + */ +export function resolveRubyImportTarget( + targetRaw: string, + fromFile: string, + allFilePaths: ReadonlySet, + _resolutionConfig?: unknown, +): string | readonly string[] | null { + if (!targetRaw) return null; + if (targetRaw.startsWith('__heritage__:') || targetRaw.startsWith('__property__:')) return null; + + const fromNormalized = fromFile.replace(/\\/g, '/'); + const fromDir = fromNormalized.includes('/') + ? fromNormalized.slice(0, fromNormalized.lastIndexOf('/')) + : ''; + + // ── require_relative: relative path resolution ────────────────────── + if (targetRaw.startsWith('./') || targetRaw.startsWith('../')) { + const resolved = resolveRelative(targetRaw, fromDir, allFilePaths); + return resolved; + } + + // ── require: bare/gem-style suffix matching ───────────────────────── + return resolveBare(targetRaw, allFilePaths); +} + +// ─── internal helpers ───────────────────────────────────────────────────── + +/** + * Resolve a relative require path (`./foo`, `../bar`) against `fromDir`. + * Tries `${resolved}.rb` then `${resolved}/index.rb`. + */ +function resolveRelative( + targetRaw: string, + fromDir: string, + allFilePaths: ReadonlySet, +): string | null { + // Resolve `./` and `../` segments manually against fromDir + const segments = (fromDir ? fromDir + '/' + targetRaw : targetRaw).split('/'); + const resolved: string[] = []; + + for (const seg of segments) { + if (seg === '' || seg === '.') continue; + if (seg === '..') { + resolved.pop(); + } else { + resolved.push(seg); + } + } + + const resolvedPath = resolved.join('/'); + + // Try direct .rb file + const rbFile = `${resolvedPath}.rb`; + if (allFilePaths.has(rbFile)) return rbFile; + + // Try index.rb inside directory + const indexFile = `${resolvedPath}/index.rb`; + if (allFilePaths.has(indexFile)) return indexFile; + + // The path might already include .rb extension + if (resolvedPath.endsWith('.rb') && allFilePaths.has(resolvedPath)) return resolvedPath; + + return null; +} + +/** + * Resolve a bare require path (`'serializable'`, `'json'`, `'net/http'`) + * via suffix matching using the existing Ruby import resolver. + */ +function resolveBare(targetRaw: string, allFilePaths: ReadonlySet): string | null { + const normalizedFileList = [...allFilePaths].map((f) => f.replace(/\\/g, '/')); + const allFileList = [...allFilePaths]; + const index = buildSuffixIndex(normalizedFileList, allFileList); + + return resolveRubyImportInternal(targetRaw, normalizedFileList, allFileList, index); +} diff --git a/gitnexus/src/core/ingestion/languages/ruby/index.ts b/gitnexus/src/core/ingestion/languages/ruby/index.ts new file mode 100644 index 000000000..67c3678e8 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/index.ts @@ -0,0 +1,20 @@ +/** + * Ruby scope-resolution hooks (RFC #909 Ring 3). + */ +export { emitRubyScopeCaptures } from './captures.js'; +export { getRubyCaptureCacheStats, resetRubyCaptureCacheStats } from './cache-stats.js'; +export { + interpretRubyImport, + interpretRubyTypeBinding, + normalizeRubyTypeName, +} from './interpret.js'; +export { rubyArityCompatibility } from './arity.js'; +export { rubyMergeBindings } from './merge-bindings.js'; +export { synthesizeRubyReceiverBinding, findEnclosingClassOrModule } from './receiver-binding.js'; +export { + rubyBindingScopeFor, + rubyImportOwningScope, + rubyReceiverBinding, + rubyFunctionDefinitionLabel, +} from './simple-hooks.js'; +export { resolveRubyImportTarget, type RubyResolveContext } from './import-target.js'; diff --git a/gitnexus/src/core/ingestion/languages/ruby/interpret.ts b/gitnexus/src/core/ingestion/languages/ruby/interpret.ts new file mode 100644 index 000000000..2cee5a1d2 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/interpret.ts @@ -0,0 +1,117 @@ +import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared'; + +// ─── interpretImport ────────────────────────────────────────────────────── + +/** + * Interpret a pre-decomposed Ruby import capture into a `ParsedImport`. + * + * Ruby `require` / `require_relative` / `load` bring everything from the + * target file into scope (wildcard semantics). The captures layer (U5) + * pre-decomposes the raw tree-sitter match so that this function receives: + * - `@import.kind` — always `'wildcard'` for Ruby + * - `@import.source` — the string argument (e.g. `'./user'`, `'serializable'`) + * - `@import.name` — derived module name (informational) + */ +export function interpretRubyImport(captures: CaptureMatch): ParsedImport | null { + const kind = captures['@import.kind']?.text; + if (kind === undefined) return null; + + const source = captures['@import.source']?.text; + if (source === undefined) return null; + + // Heritage-encoded imports (__heritage__:include:Serializable:User) + // are stored as namespace imports so emitHeritageEdges can read them. + if (source.startsWith('__heritage__:') || source.startsWith('__property__:')) { + const name = captures['@import.name']?.text ?? source; + return { kind: 'namespace', localName: name, importedName: name, targetRaw: source }; + } + + // Ruby imports are always wildcard — everything in the required file + // becomes visible in the importing scope. + return { kind: 'wildcard', targetRaw: source }; +} + +// ─── interpretTypeBinding ───────────────────────────────────────────────── + +/** + * Interpret a Ruby type-binding capture into a `ParsedTypeBinding`. + * + * Type information in Ruby comes from YARD/RBS annotations, `.new` calls, + * and assignment inference. The captures layer tags each match with one of + * several sub-captures (`@type-binding.self`, `@type-binding.constructor`, + * etc.) so this function can determine the `source`. + */ +export function interpretRubyTypeBinding(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']; + let normalizedType: string; + + if (captures['@type-binding.self'] !== undefined) { + source = 'self'; + normalizedType = normalizeRubyTypeName(type); + } else if (captures['@type-binding.constructor'] !== undefined) { + source = 'constructor-inferred'; + normalizedType = normalizeRubyConstructorType(type); + } else if (captures['@type-binding.call-return'] !== undefined) { + source = 'constructor-inferred'; + normalizedType = normalizeRubyTypeName(type); + } else if (captures['@type-binding.return'] !== undefined) { + source = 'return-annotation'; + normalizedType = normalizeRubyTypeName(type); + } else if (captures['@type-binding.parameter'] !== undefined) { + source = 'parameter-annotation'; + normalizedType = normalizeRubyTypeName(type); + } else if (captures['@type-binding.alias'] !== undefined) { + source = 'assignment-inferred'; + normalizedType = normalizeRubyTypeName(type); + } else { + source = 'annotation'; + normalizedType = normalizeRubyTypeName(type); + } + + return { boundName: name, rawTypeName: normalizedType, source }; +} + +// ─── normalizeRubyTypeName ──────────────────────────────────────────────── + +/** + * Normalize a Ruby type name to its simple form: + * 1. Strip leading `::` (root-qualified) + * 2. Take last segment of qualified paths (`Foo::Bar::Baz` → `Baz`) + * 3. Strip generic angle brackets for consistency (`Array` → `Array`) + * 4. Trim whitespace + */ +export function normalizeRubyTypeName(text: string): string { + let t = text.trim(); + + // Strip leading root-qualifier + if (t.startsWith('::')) t = t.slice(2); + + // Strip generic angle brackets (e.g. `Array` → `Array`) + const angleBracket = t.indexOf('<'); + if (angleBracket !== -1) t = t.slice(0, angleBracket); + + // Take last segment of qualified paths (Foo::Bar::Baz → Baz) + const lastColon = t.lastIndexOf('::'); + if (lastColon !== -1) t = t.slice(lastColon + 2); + + return t.trim(); +} + +// ─── internal helpers ───────────────────────────────────────────────────── + +/** + * Normalize a constructor-inferred type from a `.new` call. + * Handles `Foo::Bar.new` → `Bar` and plain `Foo.new` → `Foo`. + */ +function normalizeRubyConstructorType(text: string): string { + let t = text.trim(); + + // Strip `.new` suffix if present (e.g. `Foo::Bar.new` → `Foo::Bar`) + if (t.endsWith('.new')) t = t.slice(0, -4); + + return normalizeRubyTypeName(t); +} diff --git a/gitnexus/src/core/ingestion/languages/ruby/merge-bindings.ts b/gitnexus/src/core/ingestion/languages/ruby/merge-bindings.ts new file mode 100644 index 000000000..136d96654 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/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 rubyMergeBindings( + 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/ruby/query.ts b/gitnexus/src/core/ingestion/languages/ruby/query.ts new file mode 100644 index 000000000..36a4e36b3 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/query.ts @@ -0,0 +1,221 @@ +/** + * Tree-sitter query for Ruby scope captures (U1 scope-resolution migration). + * + * Captures the structural skeleton the generic scope-resolution pipeline + * consumes: scopes (program/class/module/method/block), declarations + * (class, module, method, singleton_method, variable), imports + * (require/require_relative/load), type bindings (constructor-inferred + * locals via `.new`), and references (free calls, member calls). + * + * Ruby specifics that shape this query: + * + * - Ruby modules are class-like scopes (they hold methods and can be + * mixed in via include/extend/prepend). + * + * - `singleton_method` (`def self.foo`) is a class-level method + * declaration, captured as @scope.function + @declaration.function. + * + * - Ruby has no static type annotations. Constructor inference via + * `x = User.new` is handled here; YARD `@param`/`@return` comments + * are handled programmatically in captures.ts. + * + * - In Ruby, field access IS a method call (attr_reader generates + * methods). Member calls cover both method calls and field reads. + * + * - `require`, `require_relative`, and `load` are plain method calls + * in the grammar — matched by name via `#match?`. + * + * - `do_block` and `block` (`{ }`) are both block scopes that can + * introduce closures. + * + * Exposes lazy `Parser` and `Query` singletons so callers don't pay + * tree-sitter init cost per file. + */ + +import Parser from 'tree-sitter'; +import Ruby from 'tree-sitter-ruby'; + +const RUBY_SCOPE_QUERY = ` +;; ── Scopes ─────────────────────────────────────────────────────────────── + +(program) @scope.module + +(class) @scope.class +(module) @scope.class + +(method) @scope.function +(singleton_method) @scope.function + +(do_block) @scope.block +(block) @scope.block + +;; ── Declarations — class ───────────────────────────────────────────────── + +(class + name: (constant) @declaration.name) @declaration.class + +;; ── Declarations — module (labeled Trait for class-like registry lookup) ─ + +(module + name: (constant) @declaration.name) @declaration.trait + +;; ── Declarations — method (instance) ───────────────────────────────────── + +(method + name: (identifier) @declaration.name) @declaration.function + +;; ── Declarations — singleton method (class-level: def self.foo) ────────── + +(singleton_method + name: (identifier) @declaration.name) @declaration.function + +;; ── Declarations — variable assignment ─────────────────────────────────── + +(assignment + left: (identifier) @declaration.name) @declaration.variable + +;; ── Imports — require / require_relative / load ────────────────────────── +;; +;; All three are plain \`call\` nodes in tree-sitter-ruby with no receiver. +;; The import-decomposer in captures.ts fans out the argument to a path. + +(call + method: (identifier) @_method + (#match? @_method "^(require|require_relative|load)$")) @import.statement + +;; ── Type bindings — constructor inference: x = User.new ────────────────── +;; +;; tree-sitter-ruby parses \`x = User.new\` as: +;; (assignment +;; left: (identifier) ;; "x" +;; right: (call +;; receiver: (constant) ;; "User" +;; method: (identifier))) ;; "new" +;; +;; Captures the receiver constant as the type. + +(assignment + left: (identifier) @type-binding.name + right: (call + receiver: (constant) @type-binding.type + method: (identifier) @_new_method + (#eq? @_new_method "new"))) @type-binding.constructor + +;; Qualified constructor: x = Foo::Bar.new (scope_resolution receiver) + +(assignment + left: (identifier) @type-binding.name + right: (call + receiver: (scope_resolution) @type-binding.type + method: (identifier) @_new_method2 + (#eq? @_new_method2 "new"))) @type-binding.constructor + +;; Constant constructor: SERVICE = UserService.new (left is constant, not identifier) + +(assignment + left: (constant) @type-binding.name + right: (call + receiver: (constant) @type-binding.type + method: (identifier) @_new_method3 + (#eq? @_new_method3 "new"))) @type-binding.constructor + +(assignment + left: (constant) @type-binding.name + right: (call + receiver: (scope_resolution) @type-binding.type + method: (identifier) @_new_method4 + (#eq? @_new_method4 "new"))) @type-binding.constructor + +;; Call-return inference: x = build_service() (factory pattern) + +(assignment + left: (identifier) @type-binding.name + right: (call + !receiver + method: (identifier) @type-binding.type)) @type-binding.call-return + +;; Constant call-return: SERVICE = build_service() + +(assignment + left: (constant) @type-binding.name + right: (call + !receiver + method: (identifier) @type-binding.type)) @type-binding.call-return + +;; ── Type bindings — for-in loop: for x in collection ───────────────────── +;; +;; The loop variable \`x\` gets the element type of the collection. +;; We bind \`x → collection\` as an alias; the chain-follow pass +;; resolves \`collection → ElementType\` via YARD \`@param\` annotations. +;; tree-sitter-ruby wraps the collection in an \`in\` node: +;; (for pattern: (identifier) value: (in (identifier))) + +(for + pattern: (identifier) @type-binding.name + value: (in + (identifier) @type-binding.type)) @type-binding.alias + +;; ── Type bindings — variable alias: x = y ──────────────────────────────── + +(assignment + left: (identifier) @type-binding.name + right: (identifier) @type-binding.type) @type-binding.alias + +;; ── References — free calls (no receiver) ──────────────────────────────── + +(call + !receiver + method: (identifier) @reference.name) @reference.call.free + +;; ── References — bare calls (zero-arity calls without parentheses) ────── +;; +;; Ruby allows calling methods without parentheses. When no arguments are +;; passed, tree-sitter-ruby parses them as plain \`identifier\` nodes inside +;; \`body_statement\`. This mirrors the legacy query pattern. The scope- +;; resolution pipeline filters false positives via builtInNames and +;; arity-based overload narrowing. + +(body_statement + (identifier) @reference.name) @reference.call.free + +;; ── References — member calls (with receiver): obj.method() ────────────── + +(call + receiver: (_) @reference.receiver + method: (identifier) @reference.name) @reference.call.member + +;; ── References — field writes: obj.field = value ───────────────────────── +;; +;; Ruby setter syntax: \`obj.name = x\` is an assignment whose left-hand +;; side is a \`call\` node with a receiver. + +(assignment + left: (call + receiver: (_) @reference.receiver + method: (identifier) @reference.name)) @reference.write + +;; ── References — field writes (compound assignment: obj.field += value) ── + +(operator_assignment + left: (call + receiver: (_) @reference.receiver + method: (identifier) @reference.name)) @reference.write +`; + +let _parser: Parser | null = null; +let _query: Parser.Query | null = null; + +export function getRubyParser(): Parser { + if (_parser === null) { + _parser = new Parser(); + _parser.setLanguage(Ruby as Parameters[0]); + } + return _parser; +} + +export function getRubyScopeQuery(): Parser.Query { + if (_query === null) { + _query = new Parser.Query(Ruby as Parameters[0], RUBY_SCOPE_QUERY); + } + return _query; +} diff --git a/gitnexus/src/core/ingestion/languages/ruby/receiver-binding.ts b/gitnexus/src/core/ingestion/languages/ruby/receiver-binding.ts new file mode 100644 index 000000000..fe06f87d5 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/receiver-binding.ts @@ -0,0 +1,72 @@ +/** + * Synthesize `@type-binding.self` captures for Ruby methods. + * + * Every instance method and singleton method inside a class or module body + * gets `self` bound to the enclosing class/module name. Ruby has no + * `@staticmethod` concept — even `def self.foo` dispatches on the class. + * + * For `class << self` (singleton_class) blocks, we walk past the + * singleton_class to the real owning class/module, matching the + * `rubyResolveEnclosingOwner` logic from the legacy provider. + */ + +import type { CaptureMatch } from 'gitnexus-shared'; +import { syntheticCapture } from '../../utils/ast-helpers.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +/** + * Walk up the parent chain from `node` to find the first enclosing + * `class` or `module` ancestor. + * + * - `singleton_class` (`class << self`) is not itself a real type, so we + * skip past it and continue walking to find the true owner. + * - Stops at `program` (never walks past the file root). + * - Returns `null` when no enclosing class/module is found. + */ +export function findEnclosingClassOrModule(node: SyntaxNode): SyntaxNode | null { + let cur: SyntaxNode | null = node.parent; + while (cur !== null) { + if (cur.type === 'program') return null; + if (cur.type === 'class' || cur.type === 'module') return cur; + // singleton_class (`class << self`) is not the real owner — + // keep walking to find the enclosing class/module. + cur = cur.parent; + } + return null; +} + +/** + * Extract the class/module name from the `name` field of a `class` or + * `module` node. The field holds a `constant` node whose text is the + * simple name (e.g. "MyClass"). + */ +function extractClassName(classOrModuleNode: SyntaxNode): string | null { + const nameNode = classOrModuleNode.childForFieldName('name'); + if (nameNode === null) return null; + return nameNode.text; +} + +/** + * Given a method node (`method` or `singleton_method`) and its enclosing + * `class`/`module` node (or null for top-level defs), synthesize a + * `@type-binding.self` capture that binds `self` to the class/module name. + * + * Returns `null` when: + * - `enclosingNode` is null (top-level def has no implicit receiver) + * - the enclosing node's name cannot be extracted + */ +export function synthesizeRubyReceiverBinding( + fnNode: SyntaxNode, + enclosingNode: SyntaxNode | null, +): CaptureMatch | null { + if (enclosingNode === null) return null; + + const className = extractClassName(enclosingNode); + if (className === 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, className), + }; +} diff --git a/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts new file mode 100644 index 000000000..b10c78b74 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts @@ -0,0 +1,235 @@ +import type { ParsedFile, ScopeId } 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 { rubyProvider } from '../ruby.js'; +import { rubyArityCompatibility, rubyMergeBindings, resolveRubyImportTarget } from './index.js'; +import { populateClassOwnedMembers, isClassLike } from '../../scope-resolution/scope/walkers.js'; +import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js'; +import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js'; +import type { KnowledgeGraph } from '../../../graph/types.js'; +import { generateId } from '../../../../lib/utils.js'; + +const HERITAGE_PREFIX = '__heritage__:'; + +function emitRubyMixinEdges( + graph: KnowledgeGraph, + parsedFiles: readonly ParsedFile[], + nodeLookup: GraphNodeLookup, +): void { + const graphIdByName = 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) { + const simpleName = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; + graphIdByName.set(simpleName, graphId); + } + } + } + + const emitted = new Set(); + // Pre-seed with existing IMPLEMENTS edges to avoid duplicates when the + // parse-worker path already produced heritage (worker path for repos + // with >= 15 files). + for (const rel of graph.iterRelationshipsByType('IMPLEMENTS')) { + emitted.add(`${rel.sourceId}->${rel.targetId}:${rel.reason}`); + } + + for (const parsed of parsedFiles) { + for (const imp of parsed.parsedImports) { + if (!imp.targetRaw.startsWith(HERITAGE_PREFIX)) continue; + const parts = imp.targetRaw.slice(HERITAGE_PREFIX.length).split(':'); + if (parts.length < 3) continue; + const [kind, mixinName, className] = parts; + const classGraphId = graphIdByName.get(className!); + const mixinGraphId = graphIdByName.get(mixinName!); + if (classGraphId === undefined || mixinGraphId === undefined) continue; + const edgeKey = `${classGraphId}->${mixinGraphId}:${kind}`; + if (emitted.has(edgeKey)) continue; + emitted.add(edgeKey); + graph.addRelationship({ + id: generateId('IMPLEMENTS', edgeKey), + sourceId: classGraphId, + targetId: mixinGraphId, + type: 'IMPLEMENTS', + confidence: 0.85, + reason: kind!, + }); + } + } + + // Emit Property nodes + HAS_PROPERTY edges from __property__:... imports. + // Skip if the parse-worker already created the property (worker path merges + // Property nodes into the graph before scope-resolution runs). + const existingProps = new Set(); + for (const rel of graph.iterRelationshipsByType('HAS_PROPERTY')) { + const targetNode = graph.getNode(rel.targetId); + if (targetNode !== undefined) { + existingProps.add(`${rel.sourceId}->prop:${targetNode.properties.name}`); + } + } + + const PROPERTY_PREFIX = '__property__:'; + for (const parsed of parsedFiles) { + for (const imp of parsed.parsedImports) { + if (!imp.targetRaw.startsWith(PROPERTY_PREFIX)) continue; + const parts = imp.targetRaw.slice(PROPERTY_PREFIX.length).split(':'); + if (parts.length < 3) continue; + const [_attrKind, propName, className] = parts; + const classGraphId = graphIdByName.get(className!); + if (classGraphId === undefined || propName === undefined) continue; + + const edgeKey = `${classGraphId}->prop:${propName}`; + if (emitted.has(edgeKey) || existingProps.has(edgeKey)) continue; + emitted.add(edgeKey); + + const propId = generateId('Property', `${parsed.filePath}:${className}.${propName}`); + graph.addNode({ + id: propId, + label: 'Property', + properties: { name: propName, filePath: parsed.filePath }, + }); + graph.addRelationship({ + id: generateId('HAS_PROPERTY', edgeKey), + sourceId: classGraphId, + targetId: propId, + type: 'HAS_PROPERTY', + confidence: 0.9, + reason: 'attr', + }); + } + } +} + +function buildRubyMro( + graph: Parameters[0], + parsedFiles: readonly ParsedFile[], + nodeLookup: Parameters[2], +): Map { + // Step 1: EXTENDS chain via the generic MRO builder (direct class inheritance). + const baseMro = buildMro(graph, parsedFiles, nodeLookup, defaultLinearize); + + // Step 2: Build defId ↔ graphId bridge for class-like defs. + 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); + } + } + + // Step 3: Collect IMPLEMENTS edges, partitioned by reason. + const prependByChild = new Map(); + const includeByChild = new Map(); + + 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 reason = rel.reason; + if (reason === 'prepend') { + let list = prependByChild.get(childDefId); + if (list === undefined) { + list = []; + prependByChild.set(childDefId, list); + } + list.push(parentDefId); + } else if (reason === 'include') { + let list = includeByChild.get(childDefId); + if (list === undefined) { + list = []; + includeByChild.set(childDefId, list); + } + list.push(parentDefId); + } + } + + // Step 4: Reorder MRO per Ruby semantics. + // Order: prepend (reversed) → direct extends chain → include (reversed). + // `extend` is excluded — it belongs to singleton dispatch only (legacy + // `getInstanceAncestry` in heritage-map.ts explicitly drops extend entries). + // Reversed because Ruby declaration order means last-declared wins + // (prepend B; prepend A → B checked before A). + for (const defId of defIdByGraphId.values()) { + const extendsChain = baseMro.get(defId) ?? []; + const prepends = prependByChild.get(defId); + const includes = includeByChild.get(defId); + + if (prepends === undefined && includes === undefined) continue; + + const reordered: string[] = []; + if (prepends !== undefined) { + for (let i = prepends.length - 1; i >= 0; i--) reordered.push(prepends[i]); + } + reordered.push(...extendsChain); + if (includes !== undefined) { + for (let i = includes.length - 1; i >= 0; i--) reordered.push(includes[i]); + } + baseMro.set(defId, reordered); + } + + return baseMro; +} + +/** + * Enumerate all names exported from a target module scope's file. + * Ruby's `require` / `require_relative` are wildcard imports — they bring + * every top-level def (class, module, method, constant) from the target + * file into the importer's scope. Without this hook the finalize pass + * cannot materialize individual bindings from wildcard imports, which + * blocks `propagateImportedReturnTypes` from mirroring return-type + * typeBindings across files. + */ +function expandRubyWildcardNames( + targetModuleScope: ScopeId, + parsedFiles: readonly ParsedFile[], +): readonly string[] { + const target = parsedFiles.find((p) => p.moduleScope === targetModuleScope); + if (target === undefined) return []; + + const seen = new Set(); + const names: string[] = []; + for (const def of target.localDefs) { + const qn = def.qualifiedName; + if (qn === undefined || qn.length === 0) continue; + const name = qn.split('.').pop() ?? qn; + if (name === '') continue; + if (seen.has(name)) continue; + seen.add(name); + names.push(name); + } + return names; +} + +export const rubyScopeResolver: ScopeResolver = { + language: SupportedLanguages.Ruby, + languageProvider: rubyProvider, + importEdgeReason: 'ruby-scope: import', + + resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) => + resolveRubyImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig), + + expandsWildcardTo: (targetModuleScope, parsedFiles) => + expandRubyWildcardNames(targetModuleScope, parsedFiles), + + mergeBindings: (existing, incoming, scopeId) => rubyMergeBindings(existing, incoming, scopeId), + + arityCompatibility: (callsite, def) => rubyArityCompatibility(def, callsite), + + buildMro: (graph, parsedFiles, nodeLookup) => buildRubyMro(graph, parsedFiles, nodeLookup), + + populateOwners: (parsed) => populateClassOwnedMembers(parsed), + + isSuperReceiver: (text) => text.trim() === 'super', + + emitHeritageEdges: (graph, parsedFiles, nodeLookup) => + emitRubyMixinEdges(graph, parsedFiles, nodeLookup), + + fieldFallbackOnMethodLookup: true, + propagatesReturnTypesAcrossImports: true, + allowGlobalFreeCallFallback: true, +}; diff --git a/gitnexus/src/core/ingestion/languages/ruby/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/ruby/simple-hooks.ts new file mode 100644 index 000000000..b09bd2616 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/simple-hooks.ts @@ -0,0 +1,63 @@ +import type { + CaptureMatch, + ParsedImport, + Scope, + ScopeId, + ScopeTree, + TypeRef, + NodeLabel, +} from 'gitnexus-shared'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +export function rubyBindingScopeFor( + decl: CaptureMatch, + innermost: Scope, + _tree: ScopeTree, +): ScopeId | null { + // Keep self typeBindings in the method's Function scope so + // populateClassOwnedMembers can match Method defs to their receiver types. + if (decl['@type-binding.self'] !== undefined) { + return innermost.id; + } + return null; +} + +/** + * Ruby `require` / `include` inside a function or class body should attach + * at that scope, not module scope. + */ +export function rubyImportOwningScope( + _imp: ParsedImport, + innermost: Scope, + _tree: ScopeTree, +): ScopeId | null { + if (innermost.kind === 'Function' || innermost.kind === 'Class') { + return innermost.id; + } + return null; +} + +export function rubyReceiverBinding(functionScope: Scope): TypeRef | null { + if (functionScope.kind !== 'Function') return null; + return functionScope.typeBindings.get('self') ?? null; +} + +/** + * Reclassify top-level `def` as `'Method'` when it appears inside a + * `class` or `module` body. Stand-alone defs remain `'Function'`. + */ +export function rubyFunctionDefinitionLabel( + functionNode: SyntaxNode, + defaultLabel: NodeLabel, +): NodeLabel { + if (defaultLabel !== 'Function') return defaultLabel; + let ancestor: SyntaxNode | null = functionNode.parent; + while (ancestor) { + if (ancestor.type === 'program') break; + if (ancestor.type === 'class' || ancestor.type === 'module') { + return 'Method'; + } + ancestor = ancestor.parent; + } + return 'Function'; +} diff --git a/gitnexus/src/core/ingestion/languages/typescript/import-target.ts b/gitnexus/src/core/ingestion/languages/typescript/import-target.ts index d082529f5..7d39e1f64 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/import-target.ts @@ -17,6 +17,7 @@ import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; import { SupportedLanguages } from 'gitnexus-shared'; import { resolveImportPath } from '../../import-resolvers/standard.js'; +import type { SuffixIndex } from '../../import-resolvers/utils.js'; import type { TsconfigPaths } from '../../language-config.js'; export interface TsResolveContext { @@ -31,6 +32,8 @@ export interface TsResolveContext { readonly normalizedFileList?: readonly string[]; /** Per-call resolution cache to dedupe repeated lookups. */ readonly resolveCache?: Map; + /** Prebuilt suffix index for O(1)-style package/absolute import matching. */ + readonly index?: SuffixIndex; /** Parsed tsconfig path-aliases. `null` = no aliases configured. */ readonly tsconfigPaths?: TsconfigPaths | null; /** JavaScript vs TypeScript switch — affects the extensions the @@ -84,6 +87,7 @@ export function resolveTsTarget(targetRaw: string, ctx: TsResolveContext): strin resolveCache, language, ctx.tsconfigPaths ?? null, + ctx.index, ); } diff --git a/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts index 33f49307d..d4b2237dc 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts @@ -19,6 +19,7 @@ import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers. import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; import { typescriptProvider } from '../typescript.js'; import { loadTsconfigPaths, type TsconfigPaths } from '../../language-config.js'; +import { buildSuffixIndex, type SuffixIndex } from '../../import-resolvers/utils.js'; import { typescriptArityCompatibility, typescriptMergeBindings, @@ -50,6 +51,7 @@ function makeTsResolveImportTarget(): ScopeResolver['resolveImportTarget'] { readonly allFilePaths: Set; readonly allFileList: readonly string[]; readonly normalizedFileList: readonly string[]; + readonly index: SuffixIndex; readonly resolveCache: Map; } let cached: PassCache | null = null; @@ -57,11 +59,13 @@ function makeTsResolveImportTarget(): ScopeResolver['resolveImportTarget'] { return (targetRaw, fromFile, allFilePaths, resolutionConfig) => { if (cached === null || cached.key !== allFilePaths) { const allFileList = Array.from(allFilePaths); + const normalizedFileList = allFileList.map((f) => f.toLowerCase()); cached = { key: allFilePaths, allFilePaths: new Set(allFilePaths), allFileList, - normalizedFileList: allFileList.map((f) => f.toLowerCase()), + normalizedFileList, + index: buildSuffixIndex(normalizedFileList, allFileList), resolveCache: new Map(), }; } @@ -72,6 +76,7 @@ function makeTsResolveImportTarget(): ScopeResolver['resolveImportTarget'] { allFilePaths: cached.allFilePaths, allFileList: cached.allFileList, normalizedFileList: cached.normalizedFileList, + index: cached.index, resolveCache: cached.resolveCache, tsconfigPaths: cfg?.tsconfigPaths ?? null, }; diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 6465a9782..39d16461a 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -53,6 +53,7 @@ import type { FileConstructorBindings, FileScopeBindings, ExtractedORMQuery, + FetchWrapperDef, } from './workers/parse-worker.js'; import { getTreeSitterBufferSize, @@ -69,6 +70,7 @@ export interface WorkerExtractedData { heritage: ExtractedHeritage[]; routes: ExtractedRoute[]; fetchCalls: ExtractedFetchCall[]; + fetchWrapperDefs: FetchWrapperDef[]; decoratorRoutes: ExtractedDecoratorRoute[]; toolDefs: ExtractedToolDef[]; ormQueries: ExtractedORMQuery[]; @@ -110,6 +112,7 @@ export const mergeChunkResults = ( const allHeritage: ExtractedHeritage[] = []; const allRoutes: ExtractedRoute[] = []; const allFetchCalls: ExtractedFetchCall[] = []; + const allFetchWrapperDefs: FetchWrapperDef[] = []; const allDecoratorRoutes: ExtractedDecoratorRoute[] = []; const allToolDefs: ExtractedToolDef[] = []; const allORMQueries: ExtractedORMQuery[] = []; @@ -147,6 +150,7 @@ export const mergeChunkResults = ( for (const item of result.heritage) allHeritage.push(item); for (const item of result.routes) allRoutes.push(item); for (const item of result.fetchCalls) allFetchCalls.push(item); + for (const item of result.fetchWrapperDefs ?? []) allFetchWrapperDefs.push(item); for (const item of result.decoratorRoutes) allDecoratorRoutes.push(item); for (const item of result.toolDefs) allToolDefs.push(item); if (result.ormQueries) for (const item of result.ormQueries) allORMQueries.push(item); @@ -163,6 +167,7 @@ export const mergeChunkResults = ( heritage: allHeritage, routes: allRoutes, fetchCalls: allFetchCalls, + fetchWrapperDefs: allFetchWrapperDefs, decoratorRoutes: allDecoratorRoutes, toolDefs: allToolDefs, ormQueries: allORMQueries, @@ -203,6 +208,7 @@ const processParsingWithWorkers = async ( heritage: [], routes: [], fetchCalls: [], + fetchWrapperDefs: [], decoratorRoutes: [], toolDefs: [], ormQueries: [], diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts index 4d06b8d58..a37d040e6 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts @@ -61,6 +61,7 @@ import type { ExtractedRoute, ExtractedToolDef, FileConstructorBindings, + FetchWrapperDef, } from '../workers/parse-worker.js'; import type { ExtractedHeritage } from '../model/heritage-map.js'; import type { KnowledgeGraph } from '../../graph/types.js'; @@ -141,6 +142,7 @@ export async function runChunkedParseAndResolve( ): Promise<{ exportedTypeMap: ExportedTypeMap; allFetchCalls: ExtractedFetchCall[]; + allFetchWrapperDefs: FetchWrapperDef[]; allExtractedRoutes: ExtractedRoute[]; allDecoratorRoutes: ExtractedDecoratorRoute[]; allToolDefs: ExtractedToolDef[]; @@ -352,6 +354,7 @@ export async function runChunkedParseAndResolve( // it, and later wildcard chunks re-run it themselves. let hasSynthesized = false; const allFetchCalls: ExtractedFetchCall[] = []; + const allFetchWrapperDefs: FetchWrapperDef[] = []; const allExtractedRoutes: ExtractedRoute[] = []; const allDecoratorRoutes: ExtractedDecoratorRoute[] = []; const allToolDefs: ExtractedToolDef[] = []; @@ -663,6 +666,9 @@ export async function runChunkedParseAndResolve( if (chunkWorkerData.fetchCalls?.length) { for (const item of chunkWorkerData.fetchCalls) allFetchCalls.push(item); } + if (chunkWorkerData.fetchWrapperDefs?.length) { + for (const item of chunkWorkerData.fetchWrapperDefs) allFetchWrapperDefs.push(item); + } if (chunkWorkerData.routes?.length) { for (const item of chunkWorkerData.routes) allExtractedRoutes.push(item); } @@ -1082,6 +1088,7 @@ export async function runChunkedParseAndResolve( return { exportedTypeMap, allFetchCalls, + allFetchWrapperDefs, allExtractedRoutes, allDecoratorRoutes, allToolDefs, diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse.ts index a3fa81be7..280731e68 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/parse.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/parse.ts @@ -27,6 +27,7 @@ import type { ExtractedDecoratorRoute, ExtractedToolDef, ExtractedORMQuery, + FetchWrapperDef, } from '../workers/parse-worker.js'; import type { createResolutionContext } from '../model/resolution-context.js'; import { runChunkedParseAndResolve } from './parse-impl.js'; @@ -45,6 +46,7 @@ export interface ParseOutput { */ readonly exportedTypeMap: ReadonlyMap>; readonly allFetchCalls: readonly ExtractedFetchCall[]; + readonly allFetchWrapperDefs: readonly FetchWrapperDef[]; readonly allExtractedRoutes: readonly ExtractedRoute[]; readonly allDecoratorRoutes: readonly ExtractedDecoratorRoute[]; readonly allToolDefs: readonly ExtractedToolDef[]; diff --git a/gitnexus/src/core/ingestion/pipeline-phases/routes.ts b/gitnexus/src/core/ingestion/pipeline-phases/routes.ts index a1ee73eea..a87b9576d 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/routes.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/routes.ts @@ -131,6 +131,10 @@ export function normalizeExtractedRoutePath(routePath: string, prefix: string | return joined.replace(/\/+/g, '/') || '/'; } +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + export const routesPhase: PipelinePhase = { name: 'routes', deps: ['parse'], @@ -142,6 +146,7 @@ export const routesPhase: PipelinePhase = { const { allPaths, allFetchCalls: parseFetchCalls, + allFetchWrapperDefs, allExtractedRoutes, allDecoratorRoutes, } = getPhaseOutput(deps, 'parse'); @@ -357,6 +362,35 @@ export const routesPhase: PipelinePhase = { } } + // ── Cross-file fetch wrapper consumer extraction ── + // When the parse phase discovered functions that internally call fetch(), + // scan JS/TS consumer files for calls to those wrapper functions with + // URL-like string arguments and add them to allFetchCalls so + // processNextjsFetchRoutes can create FETCHES edges. + if (allFetchWrapperDefs && allFetchWrapperDefs.length > 0 && routeRegistry.size > 0) { + const wrapperNames = new Set(allFetchWrapperDefs.map((d) => d.functionName)); + const jsFiles = allPaths.filter((p) => /\.[jt]sx?$/.test(p)); + if (jsFiles.length > 0 && wrapperNames.size > 0) { + const jsContents = await readFileContents(ctx.repoPath, jsFiles); + for (const [filePath, content] of jsContents) { + for (const name of wrapperNames) { + const regex = new RegExp( + `\\b${escapeRegex(name)}\\s*\\(\\s*['"\`](/[^'"\`\\s)]+)['"\`]`, + 'g', + ); + let match; + while ((match = regex.exec(content)) !== null) { + allFetchCalls.push({ + filePath, + fetchURL: match[1], + lineNumber: content.substring(0, match.index).split('\n').length, + }); + } + } + } + } + } + if (routeRegistry.size > 0 && allFetchCalls.length > 0) { const routeURLToFile = new Map(); for (const [url, entry] of routeRegistry) routeURLToFile.set(url, entry.filePath); diff --git a/gitnexus/src/core/ingestion/registry-primary-flag.ts b/gitnexus/src/core/ingestion/registry-primary-flag.ts index 72e5f5b3e..e552a4600 100644 --- a/gitnexus/src/core/ingestion/registry-primary-flag.ts +++ b/gitnexus/src/core/ingestion/registry-primary-flag.ts @@ -11,8 +11,9 @@ * ## Contract * * - Env-var name per language: `REGISTRY_PRIMARY_`. - * Example: `SupportedLanguages.Python` → `REGISTRY_PRIMARY_PYTHON`; - * `SupportedLanguages.CPlusPlus` (value `'cpp'`) → `REGISTRY_PRIMARY_CPP`. + * Example: `SupportedLanguages.Python` → `REGISTRY_PRIMARY_PYTHON`; + * `SupportedLanguages.CPlusPlus` (value `'cpp'`) → `REGISTRY_PRIMARY_CPP`. + * `SupportedLanguages.Cobol` (value `'cobol'`) → `REGISTRY_PRIMARY_COBOL`. * - Truthy values: `'true'`, `'1'`, `'yes'` (case-insensitive, * whitespace-trimmed). Anything else — including `undefined`, empty * string, or unknown tokens — is `false`. @@ -79,6 +80,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet = new Set = new Set([ '@declaration.parameter-types', '@declaration.parameter-type-classes', '@declaration.template-constraints', + '@declaration.is-explicit', ]); /** diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index 2d0733684..f0c718bcf 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -437,6 +437,28 @@ export interface ScopeResolver { nodeLookup: GraphNodeLookup, ) => Map; + /** + * Optional pre-MRO hook to emit heritage edges (IMPLEMENTS) that the + * generic `preEmitInheritanceEdges` pass cannot produce. Runs AFTER + * `preEmitInheritanceEdges` (which emits EXTENDS from `@reference.inherits` + * sites) and BEFORE `buildMro` (which reads the graph for EXTENDS + + * IMPLEMENTS). Languages whose heritage declarations are syntactic method + * calls rather than grammar-level heritage clauses (e.g., Ruby + * `include`/`extend`/`prepend`) use this hook to emit IMPLEMENTS edges + * from parsed import or reference data. + * + * Receives the graph (writable), parsedFiles, and nodeLookup — same + * surface as `buildMro`. Must be idempotent (the orchestrator may call + * it more than once during re-resolution). + * + * Default: undefined (no extra heritage edges needed). + */ + readonly emitHeritageEdges?: ( + graph: KnowledgeGraph, + parsedFiles: readonly ParsedFile[], + nodeLookup: GraphNodeLookup, + ) => void; + /** * Mutate `parsed.localDefs[i].ownerId` to point at the structural * owner. Python's rule: methods (Function defs whose parent scope diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts index b89551689..b1ee3d01b 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts @@ -30,9 +30,14 @@ import { } from '../scope/walkers.js'; /** Max depth for compound-receiver chain resolution (`a().b().c().d()`). - * Practical code rarely exceeds 3-4 hops; the cap prevents - * pathological recursion if the receiver text is malformed. */ -const COMPOUND_RECEIVER_MAX_DEPTH = 4; + * Practical code rarely exceeds 3-4 _syntactic_ hops, but languages + * with type-binding-mediated chains (Ruby's `x = obj.method()` binds + * `x → obj.method()` and recurses through the compound resolver) can + * triple the depth count because each intermediate step contributes + * two recursions (bare-ident → compound rawName → call-expr parse). + * 8 covers 3-level chains with headroom while still capping + * pathological recursion. */ +const COMPOUND_RECEIVER_MAX_DEPTH = 8; const MAP_TUPLE_SENTINEL_RE = /^__MAP_TUPLE_(\d+)__:(.+)$/; @@ -144,6 +149,23 @@ export function resolveCompoundReceiverClass( ); if (callAlias !== undefined) return callAlias; } + + // Compound member-call alias: rawName has both `.` and `()` + // (`user = Factory.get_user()` → rawName `Factory.get_user()`). + // Recurse into the compound resolver with the raw compound + // expression so the mixed-chain parser can split at top-level + // `.` and resolve the receiver + method return type. + if (tb.rawName.includes('.') && tb.rawName.includes('(')) { + const compound = resolveCompoundReceiverClass( + tb.rawName, + inScope, + scopes, + index, + options, + depth + 1, + ); + if (compound !== undefined) return compound; + } } return findClassBindingInScope(inScope, text, scopes); } diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index ef1d4a0eb..a7f5c9f85 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -620,15 +620,21 @@ export function emitReceiverBoundCalls( } // ── Case 3b: chain-typebinding (`city → user.get_city`) ────── + // Also handles compound member-call rawNames (`city → addr.get_city()`) + // where the rawName includes both `.` and `()` — Ruby's + // member-call-return captures produce this shape. const chainHead = - typeRef !== undefined && typeRef.rawName.includes('.') && !typeRef.rawName.includes('(') + typeRef !== undefined && typeRef.rawName.includes('.') ? (typeRef.rawName.split('.', 1)[0] ?? '') : undefined; if (typeRef !== undefined && chainHead !== undefined && !namespaceTargets.has(chainHead)) { // Try the plain dotted-field walk first — covers property / // collection-accessor shapes (`.Values`, Kotlin `.size`) and // field chains. Fall back to call-form (`x()`) which treats - // the last segment as a method invocation. + // the last segment as a method invocation. For rawNames that + // already contain `()` (Ruby member-call-return captures), + // pass through directly — the compound resolver handles the + // full expression including the call syntax. let ownerDef = resolveCompoundReceiverClass( typeRef.rawName, typeRef.declaredAtScope, @@ -636,7 +642,7 @@ export function emitReceiverBoundCalls( index, compoundOpts, ); - if (ownerDef === undefined) { + if (ownerDef === undefined && !typeRef.rawName.includes('(')) { ownerDef = resolveCompoundReceiverClass( typeRef.rawName + '()', typeRef.declaredAtScope, diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts index cd98caea7..2a70dd1f6 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts @@ -22,6 +22,8 @@ import { phpScopeResolver } from '../../languages/php/scope-resolver.js'; import { rustScopeResolver } from '../../languages/rust/scope-resolver.js'; import { javascriptScopeResolver } from '../../languages/javascript/scope-resolver.js'; import { kotlinScopeResolver } from '../../languages/kotlin/scope-resolver.js'; +import { rubyScopeResolver } from '../../languages/ruby/scope-resolver.js'; +import { cobolScopeResolver } from '../../languages/cobol/scope-resolver.js'; /** Map of `SupportedLanguages` → `ScopeResolver`. The phase iterates * this map intersected with `MIGRATED_LANGUAGES` (the per-language @@ -42,4 +44,6 @@ export const SCOPE_RESOLVERS: ReadonlyMap = n [SupportedLanguages.Rust, rustScopeResolver], [SupportedLanguages.JavaScript, javascriptScopeResolver], [SupportedLanguages.Kotlin, kotlinScopeResolver], + [SupportedLanguages.Ruby, rubyScopeResolver], + [SupportedLanguages.Cobol, cobolScopeResolver], ]); diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 21bf45e40..a088f3266 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -308,8 +308,26 @@ export function runScopeResolution( }, }); const preEmittedInheritanceSites = preEmitInheritanceEdges(graph, finalized, nodeLookup); - const mroByClassDefId = provider.buildMro(graph, parsedFiles, nodeLookup); - const extendsOnlyMroByClassDefId = provider.buildExtendsOnlyMro?.(graph, parsedFiles, nodeLookup); + // Call-based heritage hook (e.g., Ruby include/extend/prepend) — emits + // IMPLEMENTS edges that `preEmitInheritanceEdges` cannot produce because + // the heritage declarations are syntactic method calls, not grammar-level + // heritage clauses. Must run BEFORE `buildMro` so MRO construction sees + // the freshly-emitted IMPLEMENTS edges. + provider.emitHeritageEdges?.(graph, parsedFiles, nodeLookup); + // Rebuild the node lookup after heritage-edge emission. Languages like + // Ruby create Property graph nodes inside `emitHeritageEdges`; those + // nodes must be visible to downstream passes (`emitReceiverBoundCalls` + // resolves write-access targets via `resolveDefGraphId` which consults + // `nodeLookup`). Without this rebuild, Property nodes added by the + // heritage hook are invisible and ACCESSES edges silently fail to emit. + const postHeritageNodeLookup = + provider.emitHeritageEdges !== undefined ? buildGraphNodeLookup(graph) : nodeLookup; + const mroByClassDefId = provider.buildMro(graph, parsedFiles, postHeritageNodeLookup); + const extendsOnlyMroByClassDefId = provider.buildExtendsOnlyMro?.( + graph, + parsedFiles, + postHeritageNodeLookup, + ); // Replace the empty MethodDispatchIndex that finalizeScopeModel // builds by design with the populated one derived from the @@ -395,7 +413,7 @@ export function runScopeResolution( graph, indexes, parsedFiles, - nodeLookup, + postHeritageNodeLookup, handledSites, provider, workspaceIndex, @@ -410,7 +428,7 @@ export function runScopeResolution( graph, indexes, parsedFiles, - nodeLookup, + postHeritageNodeLookup, handledSites, readonlyModel, ) @@ -419,7 +437,7 @@ export function runScopeResolution( graph, indexes, parsedFiles, - nodeLookup, + postHeritageNodeLookup, referenceIndex, handledSites, readonlyModel, @@ -438,7 +456,7 @@ export function runScopeResolution( graph, indexes, referenceIndex, - nodeLookup, + postHeritageNodeLookup, handledSites, ); const importsEmitted = emitImportEdges( diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index f67823c8c..f27fcc8ce 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -241,6 +241,12 @@ export const TYPESCRIPT_QUERIES = ` [(string (string_fragment) @route.url) (template_string) @route.template_url])) @route.fetch +; Custom fetch wrappers: apiFetch('/path'), fetchJSON('/api/data'), httpGet('/users'), etc. +(call_expression + function: (identifier) @_wrapper_fn (#match? @_wrapper_fn "^(api(Fetch|Get|Post|Put|Delete|Patch|Request)|fetch(API|JSON|Data|Endpoint|Resource|Url)|http(Fetch|Get|Post|Put|Delete|Patch|Request))$") + arguments: (arguments + (string (string_fragment) @route.url))) @route.fetch + ; axios.get/post/put/delete/patch('/path'), $.get/post/ajax({url:'/path'}) (call_expression function: (member_expression @@ -434,6 +440,12 @@ export const JAVASCRIPT_QUERIES = ` [(string (string_fragment) @route.url) (template_string) @route.template_url])) @route.fetch +; Custom fetch wrappers: apiFetch('/path'), fetchJSON('/api/data'), httpGet('/users'), etc. +(call_expression + function: (identifier) @_wrapper_fn (#match? @_wrapper_fn "^(api(Fetch|Get|Post|Put|Delete|Patch|Request)|fetch(API|JSON|Data|Endpoint|Resource|Url)|http(Fetch|Get|Post|Put|Delete|Patch|Request))$") + arguments: (arguments + (string (string_fragment) @route.url))) @route.fetch + ; axios.get/post, $.get/post/ajax (call_expression function: (member_expression diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 69e28b91f..f092d4a1a 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -198,6 +198,11 @@ export interface ExtractedFetchCall { lineNumber: number; } +export interface FetchWrapperDef { + filePath: string; + functionName: string; +} + export interface ExtractedDecoratorRoute { filePath: string; routePath: string; @@ -268,6 +273,7 @@ export interface ParseWorkerResult { heritage: ExtractedHeritage[]; routes: ExtractedRoute[]; fetchCalls: ExtractedFetchCall[]; + fetchWrapperDefs: FetchWrapperDef[]; decoratorRoutes: ExtractedDecoratorRoute[]; toolDefs: ExtractedToolDef[]; ormQueries: ExtractedORMQuery[]; @@ -732,6 +738,7 @@ const processBatch = ( heritage: [], routes: [], fetchCalls: [], + fetchWrapperDefs: [], decoratorRoutes: [], toolDefs: [], ormQueries: [], @@ -842,6 +849,23 @@ const EXPRESS_ROUTE_METHODS = new Set([ 'route', ]); +/** + * Walk a tree-sitter AST subtree looking for a call to the global `fetch()` function. + * Returns `true` if found within `maxDepth` levels of nesting — keeps the check + * lightweight so it doesn't slow down parse-worker on large function bodies. + */ +const checkForFetchCall = (node: SyntaxNode, depth = 0, maxDepth = 5): boolean => { + if (depth > maxDepth) return false; + if (node.type === 'call_expression') { + const fn = node.childForFieldName('function'); + if (fn?.type === 'identifier' && fn.text === 'fetch') return true; + } + for (let i = 0; i < node.childCount; i++) { + if (checkForFetchCall(node.child(i)!, depth + 1, maxDepth)) return true; + } + return false; +}; + // HTTP client methods that are ONLY used by clients, not Express route registration. // Methods like get/post/put/delete/patch overlap with Express — those are captured by // the express_route handler as route definitions, not consumers. The fetch() global @@ -1944,6 +1968,21 @@ const processFileGroup = ( : '', }); } + + // ── Fetch wrapper detection: record functions that call fetch() internally ── + if ( + nodeLabel === 'Function' && + definitionNode && + nameNode && + (language === SupportedLanguages.TypeScript || language === SupportedLanguages.JavaScript) + ) { + if (checkForFetchCall(definitionNode)) { + result.fetchWrapperDefs.push({ + filePath: file.path, + functionName: nameNode.text, + }); + } + } } // Extract framework routes via provider detection (e.g., Laravel routes.php) @@ -1985,6 +2024,7 @@ let accumulated: ParseWorkerResult = { heritage: [], routes: [], fetchCalls: [], + fetchWrapperDefs: [], decoratorRoutes: [], toolDefs: [], ormQueries: [], @@ -2013,6 +2053,7 @@ const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult) => { appendAll(target.heritage, src.heritage); appendAll(target.routes, src.routes); appendAll(target.fetchCalls, src.fetchCalls); + appendAll(target.fetchWrapperDefs, src.fetchWrapperDefs); appendAll(target.decoratorRoutes, src.decoratorRoutes); appendAll(target.toolDefs, src.toolDefs); appendAll(target.ormQueries, src.ormQueries); @@ -2104,6 +2145,7 @@ parentPort!.on('message', (msg: WorkerIncomingMessage) => { heritage: [], routes: [], fetchCalls: [], + fetchWrapperDefs: [], decoratorRoutes: [], toolDefs: [], ormQueries: [], diff --git a/gitnexus/src/core/ingestion/workers/worker-pool.ts b/gitnexus/src/core/ingestion/workers/worker-pool.ts index d29b352ab..5c326e972 100644 --- a/gitnexus/src/core/ingestion/workers/worker-pool.ts +++ b/gitnexus/src/core/ingestion/workers/worker-pool.ts @@ -598,6 +598,14 @@ export const createWorkerPool = ( const poolOptions = resolveWorkerPoolOptions(options, size); const spawnWorker = options?.workerFactory ?? ((url: URL) => new Worker(url)); const workers: (Worker | undefined)[] = new Array(size); + type RetiredWorkerRecord = { + worker: Worker; + workerIndex: number; + reason: string; + cleanup: () => void; + terminate: () => Promise; + }; + const retiredWorkers = new Set(); const respawnCount: number[] = new Array(size).fill(0); const activeSlots: Set = new Set(); // Layer 3 (quarantine): tracked via the dedicated `quarantine.ts` @@ -625,6 +633,17 @@ export const createWorkerPool = ( let poolBroken = false; let poolFailure: Error | undefined; + const terminateTrackedWorkers = async ( + liveWorkers: readonly (Worker | undefined)[], + ): Promise => { + const retired = Array.from(retiredWorkers); + await Promise.all([ + ...liveWorkers.map((worker) => worker?.terminate().catch(() => undefined)), + ...retired.map((record) => record.terminate()), + ]); + retiredWorkers.clear(); + }; + for (let i = 0; i < size; i++) { workers[i] = spawnWorker(workerUrl); activeSlots.add(i); @@ -755,10 +774,88 @@ export const createWorkerPool = ( onProgress(next); }; - const replaceWorker = async (workerIndex: number): Promise => { + type WorkerRemovalMode = 'terminate' | 'retire'; + + const retireWorkerAfterTimeout = ( + worker: Worker, + workerIndex: number, + reason: string, + ): void => { + let cleaned = false; + let terminateStarted = false; + + function cleanupRetired() { + if (cleaned) return; + cleaned = true; + worker.removeListener('message', onRetiredMessage); + worker.removeListener('error', onRetiredError); + worker.removeListener('exit', onRetiredExit); + worker.removeListener('messageerror', onRetiredMessageError); + retiredWorkers.delete(record); + } + + async function terminateRetired() { + if (terminateStarted) return; + terminateStarted = true; + cleanupRetired(); + await worker.terminate().catch(() => undefined); + } + + function terminateWhenBackInJs() { + void terminateRetired(); + } + + function onRetiredMessage(raw: unknown) { + if (raw === null || typeof raw !== 'object') return; + const type = (raw as { type?: unknown }).type; + if (type === 'sub-batch-done' || type === 'result' || type === 'error') { + terminateWhenBackInJs(); + } + } + + const onRetiredError = () => cleanupRetired(); + const onRetiredExit = () => cleanupRetired(); + const onRetiredMessageError = () => terminateWhenBackInJs(); + const record: RetiredWorkerRecord = { + worker, + workerIndex, + reason, + cleanup: cleanupRetired, + terminate: terminateRetired, + }; + retiredWorkers.add(record); + worker.on('message', onRetiredMessage); + worker.once('error', onRetiredError); + worker.once('exit', onRetiredExit); + worker.once('messageerror', onRetiredMessageError); + (worker as Worker & { unref?: () => void }).unref?.(); + logger.warn( + { workerIndex, reason }, + `Worker ${workerIndex} timed out; retiring without immediate terminate to avoid aborting native parser state.`, + ); + }; + + const removeWorkerFromSlot = async ( + workerIndex: number, + mode: WorkerRemovalMode, + reason: string, + ): Promise => { const existing = workers[workerIndex]; - await existing?.terminate().catch(() => undefined); workers[workerIndex] = undefined; + if (!existing) return; + if (mode === 'retire') { + retireWorkerAfterTimeout(existing, workerIndex, reason); + return; + } + await existing.terminate().catch(() => undefined); + }; + + const replaceWorker = async ( + workerIndex: number, + mode: WorkerRemovalMode = 'terminate', + reason = 'replacing worker', + ): Promise => { + await removeWorkerFromSlot(workerIndex, mode, reason); if (stopped) return false; const replacement = spawnWorker(workerUrl); try { @@ -803,7 +900,7 @@ export const createWorkerPool = ( const liveWorkers = workers.slice(); for (let i = 0; i < workers.length; i++) workers[i] = undefined; activeSlots.clear(); - void Promise.all(liveWorkers.map((worker) => worker?.terminate().catch(() => undefined))); + void terminateTrackedWorkers(liveWorkers); }; const maybeDone = () => { @@ -893,6 +990,7 @@ export const createWorkerPool = ( workerIndex: number, reason: string, excludePaths: readonly string[], + removalMode: WorkerRemovalMode = 'terminate', ) => { if (stopped) return; consecutiveFailuresPerSlot[workerIndex]++; @@ -921,9 +1019,7 @@ export const createWorkerPool = ( }, `Worker ${workerIndex} exceeded respawn budget; dropping slot.`, ); - const dead = workers[workerIndex]; - await dead?.terminate().catch(() => undefined); - workers[workerIndex] = undefined; + await removeWorkerFromSlot(workerIndex, removalMode, reason); activeSlots.delete(workerIndex); if (activeSlots.size === 0) { tripBreaker( @@ -945,7 +1041,7 @@ export const createWorkerPool = ( }, `Worker ${workerIndex} died; respawning slot (attempt ${respawnCount[workerIndex]}/${poolOptions.maxRespawnsPerSlot}).`, ); - const respawned = await replaceWorker(workerIndex); + const respawned = await replaceWorker(workerIndex, removalMode, reason); if (!respawned) { activeSlots.delete(workerIndex); if (activeSlots.size === 0) { @@ -1211,7 +1307,12 @@ export const createWorkerPool = ( activeWorkers--; busySlots.delete(workerIndex); requeueRemainder(job, decision.excludePaths); - await handleWorkerDeath(workerIndex, decision.reason, decision.excludePaths); + await handleWorkerDeath( + workerIndex, + decision.reason, + decision.excludePaths, + 'retire', + ); if (stopped) return; if (activeSlots.has(workerIndex)) runWorker(workerIndex); wakeIdleSlots(); @@ -1255,9 +1356,11 @@ export const createWorkerPool = ( }, `Worker ${workerIndex} hit consecutive-failure threshold on idle-timeout retry; tripping circuit breaker.`, ); - const dead = workers[workerIndex]; - await dead?.terminate().catch(() => undefined); - workers[workerIndex] = undefined; + await removeWorkerFromSlot( + workerIndex, + 'retire', + 'idle-timeout retry consecutive-failure threshold', + ); activeSlots.delete(workerIndex); tripBreaker( new WorkerPoolDispatchError( @@ -1278,12 +1381,18 @@ export const createWorkerPool = ( }, `Worker ${workerIndex} exceeded respawn budget during idle-timeout retry; dropping slot.`, ); - const dead = workers[workerIndex]; - await dead?.terminate().catch(() => undefined); - workers[workerIndex] = undefined; + await removeWorkerFromSlot( + workerIndex, + 'retire', + 'idle-timeout retry respawn budget exhausted', + ); activeSlots.delete(workerIndex); } else { - const respawned = await replaceWorker(workerIndex); + const respawned = await replaceWorker( + workerIndex, + 'retire', + 'idle-timeout retry', + ); if (!respawned) { activeSlots.delete(workerIndex); } @@ -1482,7 +1591,7 @@ export const createWorkerPool = ( // exception when this is called from `runChunkedParseAndResolve`'s // finally block — masking the real failure and leaving `workers[]` // populated with dead references because the lines below never run. - await Promise.all(workers.map((w) => w?.terminate().catch(() => undefined))); + await terminateTrackedWorkers(workers); workers.length = 0; activeSlots.clear(); }; diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index b0f1d3ec0..a41a9d201 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -4,6 +4,8 @@ import { createInterface } from 'readline'; import { once } from 'events'; import { finished } from 'stream/promises'; import path from 'path'; +import os from 'os'; +import crypto from 'crypto'; import lbug from '@ladybugdb/core'; import { KnowledgeGraph } from '../graph/types.js'; import { @@ -23,6 +25,7 @@ import { isOpenRetryExhausted, isWalCorruptionError, openLbugConnection, + toNativeSafePath, WAL_RECOVERY_SUGGESTION, waitForWindowsHandleRelease, type LbugConnectionHandle, @@ -386,7 +389,8 @@ const runWithSessionLock = async (operation: () => Promise): Promise => } }; -const normalizeCopyPath = (filePath: string): string => filePath.replace(/\\/g, '/'); +const normalizeCopyPath = (filePath: string): string => + toNativeSafePath(filePath).replace(/\\/g, '/'); const closeQueryResult = async (result: lbug.QueryResult): Promise => { try { @@ -856,7 +860,13 @@ export const loadGraphToLbug = async ( const log = onProgress || (() => {}); - const csvDir = path.join(storagePath, 'csv'); + let csvDir: string; + if (process.platform === 'win32' && /[^\x00-\x7F]/.test(storagePath)) { + const hash = crypto.createHash('sha256').update(storagePath).digest('hex').slice(0, 16); + csvDir = toNativeSafePath(path.join(os.tmpdir(), `gitnexus-csv-${hash}`)); + } else { + csvDir = path.join(storagePath, 'csv'); + } log('Streaming CSVs to disk...'); const csvResult = await streamAllCSVsToDisk(graph, repoPath, csvDir); diff --git a/gitnexus/src/core/lbug/lbug-config.ts b/gitnexus/src/core/lbug/lbug-config.ts index a71b25930..aeba61d6a 100644 --- a/gitnexus/src/core/lbug/lbug-config.ts +++ b/gitnexus/src/core/lbug/lbug-config.ts @@ -1,9 +1,194 @@ import fs from 'fs/promises'; +import fsSync from 'fs'; import os from 'os'; import path from 'path'; +import crypto from 'crypto'; +import { execFileSync } from 'child_process'; +import { isMainThread } from 'worker_threads'; import type lbug from '@ladybugdb/core'; import { logger } from '../logger.js'; +// ─── Windows non-ASCII path workaround (#1811) ─────────────────────────────── +// +// KuzuDB's native C++ layer on Windows uses CreateFileA (ANSI), not +// CreateFileW. Non-ASCII path bytes from Node.js (UTF-8) are +// misinterpreted via the system's Active Code Page (e.g. GBK), producing +// a garbled path — "Error 3: The system cannot find the path." +// +// Layered workaround: +// 1. Try 8.3 short-name form (fast, no persistent state) +// 2. Fall back to an NTFS junction from an ASCII temp path +// 3. If both fail, log a diagnostic and return the original path + +const NON_ASCII_RE = /[^\x00-\x7F]/; +const JUNCTION_PREFIX = 'gitnexus-junction-'; + +const activeJunctions = new Set(); +let cleanupRegistered = false; +let orphanScanDone = false; + +function junctionHash(targetDir: string): string { + return crypto.createHash('sha256').update(targetDir).digest('hex').slice(0, 16); +} + +function tryShortPath(p: string): string | null { + try { + // Pass the path via environment variable so the command string is + // static — avoids CodeQL command-injection taint (the path never + // appears in the shell command text). + const result = execFileSync('cmd.exe', ['/c', 'for %I in ("%GITNEXUS_SP%") do @echo %~sI'], { + encoding: 'utf-8', + timeout: 5000, + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, GITNEXUS_SP: p }, + }); + const shortPath = result.trim(); + if ( + shortPath && + !NON_ASCII_RE.test(shortPath) && + (!shortPath.includes('?') || p.includes('?')) + ) { + return shortPath; + } + } catch { + // 8.3 unavailable or cmd failed + } + return null; +} + +function tryJunction(targetDir: string, leaf: string): string | null { + const hash = junctionHash(targetDir); + const junctionLink = path.join(os.tmpdir(), `${JUNCTION_PREFIX}${hash}`); + + if (fsSync.existsSync(junctionLink)) { + try { + const existing = fsSync.readlinkSync(junctionLink); + if (path.resolve(existing) === path.resolve(targetDir)) { + activeJunctions.add(junctionLink); + return path.join(junctionLink, leaf); + } + fsSync.rmSync(junctionLink, { recursive: true, force: true }); + } catch { + // Stale or broken junction — remove and recreate + try { + fsSync.rmSync(junctionLink, { recursive: true, force: true }); + } catch { + /* best effort */ + } + } + } + + try { + fsSync.symlinkSync(targetDir, junctionLink, 'junction'); + activeJunctions.add(junctionLink); + return path.join(junctionLink, leaf); + } catch (err: any) { + if (err.code === 'EEXIST') { + try { + const existing = fsSync.readlinkSync(junctionLink); + if (path.resolve(existing) === path.resolve(targetDir)) { + activeJunctions.add(junctionLink); + return path.join(junctionLink, leaf); + } + } catch { + /* cannot verify — fall through */ + } + } + } + return null; +} + +function registerCleanupHandlers(): void { + if (cleanupRegistered) return; + cleanupRegistered = true; + + process.on('exit', () => cleanupNativePathJunctions()); + + for (const signal of ['SIGTERM', 'SIGINT'] as const) { + process.on(signal, () => { + cleanupNativePathJunctions(); + if (process.platform === 'win32') { + process.exit(signal === 'SIGINT' ? 130 : 143); + } else { + process.kill(process.pid, signal); + } + }); + } +} + +function scanOrphanedJunctions(): void { + if (orphanScanDone) return; + orphanScanDone = true; + try { + const tmpdir = os.tmpdir(); + const entries = fsSync.readdirSync(tmpdir); + for (const entry of entries) { + if (!entry.startsWith(JUNCTION_PREFIX)) continue; + const junctionPath = path.join(tmpdir, entry); + try { + const target = fsSync.readlinkSync(junctionPath); + try { + fsSync.lstatSync(target); + } catch { + fsSync.rmSync(junctionPath, { recursive: true, force: true }); + } + } catch { + // Not a symlink/junction or unreadable — leave it + } + } + } catch { + // tmpdir unreadable — skip scan + } +} + +export function cleanupNativePathJunctions(): void { + for (const junctionPath of activeJunctions) { + try { + fsSync.rmSync(junctionPath, { recursive: true, force: true }); + } catch { + // Best effort — EPERM on Windows is common during exit + } + } + activeJunctions.clear(); +} + +export function toNativeSafePath(p: string): string { + if (process.platform !== 'win32') return p; + if (!NON_ASCII_RE.test(p)) return p; + + if (isMainThread) { + scanOrphanedJunctions(); + registerCleanupHandlers(); + } + + const shortPath = tryShortPath(p); + if (shortPath) return shortPath; + + if (!isMainThread) { + logger.warn( + `GitNexus: non-ASCII path in worker thread — junction fallback skipped. ` + + `Path: "${p}". 8.3 short names may need to be enabled on this volume.`, + ); + return p; + } + + const targetDir = path.dirname(p); + const leaf = path.basename(p); + if (fsSync.existsSync(targetDir)) { + const junctionResult = tryJunction(targetDir, leaf); + if (junctionResult) return junctionResult; + } + + logger.warn( + `GitNexus: non-ASCII path "${p}" could not be converted to an ASCII-safe form. ` + + 'LadybugDB may fail with "Cannot open file." To fix: move the repo to a path ' + + 'without CJK/Unicode characters, or enable 8.3 short names on this volume ' + + '(fsutil 8dot3name set 0).', + ); + return p; +} + /** * Shared configuration for `@ladybugdb/core` `Database` construction. * @@ -351,12 +536,10 @@ export async function openLbugConnection( databasePath: string, options: LbugDatabaseOptions = {}, ): Promise { + const safePath = toNativeSafePath(databasePath); let db: lbug.Database | undefined; try { - db = await openWithLockRetry( - () => createLbugDatabase(lbugModule, databasePath, options), - databasePath, - ); + db = await openWithLockRetry(() => createLbugDatabase(lbugModule, safePath, options), safePath); return { db, conn: new lbugModule.Connection(db) }; } catch (err) { if (db) await db.close().catch(() => {}); diff --git a/gitnexus/src/core/lbug/native-check.ts b/gitnexus/src/core/lbug/native-check.ts new file mode 100644 index 000000000..54d435bfd --- /dev/null +++ b/gitnexus/src/core/lbug/native-check.ts @@ -0,0 +1,79 @@ +import fs from 'fs'; +import path from 'path'; +import { createRequire } from 'node:module'; + +export interface NativeCheckResult { + ok: boolean; + binaryPath?: string; + message?: string; +} + +export function checkLbugNative(overridePkgDir?: string): NativeCheckResult { + let pkgDir: string; + + if (overridePkgDir) { + pkgDir = overridePkgDir; + } else { + try { + const _require = createRequire(import.meta.url); + const mainEntry = _require.resolve('@ladybugdb/core'); + pkgDir = path.dirname(mainEntry); + } catch { + return { + ok: false, + message: [ + 'LadybugDB package (@ladybugdb/core) is not installed.', + '', + 'Run: npm install', + ].join('\n'), + }; + } + } + + const binaryPath = path.join(pkgDir, 'lbugjs.node'); + if (!fs.existsSync(binaryPath)) { + return { + ok: false, + binaryPath, + message: [ + 'LadybugDB native binary (lbugjs.node) is missing.', + '', + 'This usually happens when the install lifecycle script was skipped.', + '', + 'To repair:', + ` node ${path.join(pkgDir, 'install.js')}`, + '', + 'If using bun, add to package.json and reinstall:', + ' "trustedDependencies": ["@ladybugdb/core"]', + '', + 'Also check that npm is not configured with ignore-scripts=true', + '(in .npmrc or via --ignore-scripts).', + ].join('\n'), + }; + } + + try { + const _require = createRequire(import.meta.url); + _require(binaryPath); + } catch (err: unknown) { + const nativeError = err instanceof Error ? err.message : String(err); + return { + ok: false, + binaryPath, + message: [ + 'LadybugDB native binary (lbugjs.node) exists but failed to load:', + ` ${nativeError}`, + '', + 'This can happen with a truncated file, ABI mismatch, or wrong-platform binary.', + '', + 'To repair:', + ` node ${path.join(pkgDir, 'install.js')}`, + '', + 'If using bun, add to package.json and reinstall:', + ' "trustedDependencies": ["@ladybugdb/core"]', + ].join('\n'), + }; + } + + return { ok: true, binaryPath }; +} diff --git a/gitnexus/src/core/lbug/pool-adapter.ts b/gitnexus/src/core/lbug/pool-adapter.ts index 7eb8e7d14..de478bb00 100644 --- a/gitnexus/src/core/lbug/pool-adapter.ts +++ b/gitnexus/src/core/lbug/pool-adapter.ts @@ -23,6 +23,7 @@ import { isReadOnlyDbError, loadFTSExtension } from './lbug-adapter.js'; import { createLbugDatabase, isWalCorruptionError, + toNativeSafePath, WAL_RECOVERY_SUGGESTION, } from './lbug-config.js'; import { @@ -390,7 +391,7 @@ async function probeDatabaseForShadowReplay(db: lbug.Database): Promise { async function replayShadowPagesWithWritableOpen(dbPath: string): Promise { let db: lbug.Database | undefined; try { - db = createLbugDatabase(lbug, dbPath, { throwOnWalReplayFailure: false }); + db = createLbugDatabase(lbug, toNativeSafePath(dbPath), { throwOnWalReplayFailure: false }); await db.init(); await probeDatabaseForShadowReplay(db); } catch (err) { @@ -415,7 +416,7 @@ async function openReadOnlyDatabase(dbPath: string): Promise { logger: poolSidecarLogger, allowQuarantine: true, }); - db = createLbugDatabase(lbug, dbPath, { + db = createLbugDatabase(lbug, toNativeSafePath(dbPath), { readOnly: true, throwOnWalReplayFailure: false, }); @@ -434,7 +435,7 @@ async function openReadOnlyDatabase(dbPath: string): Promise { logger: poolSidecarLogger, allowQuarantine: true, }); - db = createLbugDatabase(lbug, dbPath, { + db = createLbugDatabase(lbug, toNativeSafePath(dbPath), { readOnly: true, throwOnWalReplayFailure: false, }); @@ -448,7 +449,7 @@ async function openReadOnlyDatabase(dbPath: string): Promise { await db.close().catch(() => {}); db = undefined; await replayShadowPagesWithWritableOpen(dbPath); - db = createLbugDatabase(lbug, dbPath, { + db = createLbugDatabase(lbug, toNativeSafePath(dbPath), { readOnly: true, throwOnWalReplayFailure: false, }); diff --git a/gitnexus/src/core/wiki/generator.ts b/gitnexus/src/core/wiki/generator.ts index aaf7bb048..a86d60d74 100644 --- a/gitnexus/src/core/wiki/generator.ts +++ b/gitnexus/src/core/wiki/generator.ts @@ -99,6 +99,7 @@ export interface WikiRunResult { // ─── Constants ──────────────────────────────────────────────────────── const DEFAULT_MAX_TOKENS_PER_MODULE = 30_000; +const GROUPING_TOKEN_BUDGET = 100_000; const WIKI_DIR = 'wiki'; // ─── Generator Class ────────────────────────────────────────────────── @@ -470,15 +471,22 @@ export class WikiGenerator { DIRECTORY_TREE: dirTree, }); - // Grouping is a structured-data phase (JSON output), not documentation. - // Do NOT apply buildSystemPrompt here — a language instruction would risk - // translating module-name keys, breaking slug stability and JSON parsing. - const response = await this.invokeLLM( - prompt, - GROUPING_SYSTEM_PROMPT, - this.streamOpts('Grouping files', 15, 13), - ); - const grouping = this.parseGroupingResponse(response.content, files); + const promptTokens = estimateTokens(prompt); + let grouping: Record; + + if (promptTokens <= GROUPING_TOKEN_BUDGET) { + // Grouping is a structured-data phase (JSON output), not documentation. + // Do NOT apply buildSystemPrompt here — a language instruction would risk + // translating module-name keys, breaking slug stability and JSON parsing. + const response = await this.invokeLLM( + prompt, + GROUPING_SYSTEM_PROMPT, + this.streamOpts('Grouping files', 15, 13), + ); + grouping = this.parseGroupingResponse(response.content, files); + } else { + grouping = await this.batchedGrouping(files); + } // Convert to tree nodes const tree: ModuleTreeNode[] = []; @@ -509,6 +517,202 @@ export class WikiGenerator { return tree; } + /** + * Run grouping in batches when the full file list exceeds GROUPING_TOKEN_BUDGET. + */ + private async batchedGrouping(files: FileWithExports[]): Promise> { + const batches = this.batchFilesForGrouping(files); + const partials: Record[] = []; + + for (let i = 0; i < batches.length; i++) { + const batch = batches[i]; + this.onProgress( + 'grouping', + 15 + Math.round(((i + 1) / batches.length) * 13), + `Grouping batch ${i + 1}/${batches.length} (LLM)...`, + ); + + const batchFileList = formatFileListForGrouping(batch); + const batchDirTree = formatDirectoryTree(batch.map((f) => f.filePath)); + const batchPrompt = fillTemplate(GROUPING_USER_PROMPT, { + FILE_LIST: batchFileList, + DIRECTORY_TREE: batchDirTree, + }); + + try { + const batchStart = 15 + Math.round((i / batches.length) * 13); + const batchRange = Math.max(1, Math.round(13 / batches.length)); + const response = await this.invokeLLM( + batchPrompt, + GROUPING_SYSTEM_PROMPT, + this.streamOpts(`Grouping batch ${i + 1}/${batches.length}`, batchStart, batchRange), + ); + partials.push(this.parseGroupingResponse(response.content, batch)); + } catch { + this.onProgress( + 'grouping', + 15, + `Batch ${i + 1} failed, falling back to directory grouping`, + ); + return this.fallbackGrouping(files); + } + } + + const merged = this.mergeGroupings(partials); + + const assignedFiles = new Set(Object.values(merged).flat()); + const unassigned = files.map((f) => f.filePath).filter((fp) => !assignedFiles.has(fp)); + if (unassigned.length > 0) { + merged['Other'] = [...(merged['Other'] ?? []), ...unassigned]; + } + + return Object.keys(merged).length > 0 ? merged : this.fallbackGrouping(files); + } + + /** + * Partition files into batches that fit within GROUPING_TOKEN_BUDGET. + * Groups by top-level directory for semantic coherence. + */ + private batchFilesForGrouping(files: FileWithExports[]): FileWithExports[][] { + if (files.length === 0) return []; + + const dirGroups = new Map(); + for (const f of files) { + const parts = f.filePath.replace(/\\/g, '/').split('/'); + const topDir = parts.length > 1 ? parts[0] : 'Root'; + let group = dirGroups.get(topDir); + if (!group) { + group = []; + dirGroups.set(topDir, group); + } + group.push(f); + } + + const batches: FileWithExports[][] = []; + let currentBatch: FileWithExports[] = []; + + for (const dirFiles of dirGroups.values()) { + const dirPromptSize = this.estimateGroupingPromptTokens(dirFiles); + + if (dirPromptSize > GROUPING_TOKEN_BUDGET) { + if (currentBatch.length > 0) { + batches.push(currentBatch); + currentBatch = []; + } + // Sub-batch this large directory by fixed chunks + for (let i = 0; i < dirFiles.length; ) { + const subBatch: FileWithExports[] = []; + while (i < dirFiles.length) { + subBatch.push(dirFiles[i]); + i++; + if ( + this.estimateGroupingPromptTokens(subBatch) > GROUPING_TOKEN_BUDGET && + subBatch.length > 1 + ) { + subBatch.pop(); + i--; + break; + } + } + if ( + subBatch.length === 1 && + this.estimateGroupingPromptTokens(subBatch) > GROUPING_TOKEN_BUDGET + ) { + subBatch[0] = this.trimSymbolsToFit(subBatch[0]); + } + batches.push(subBatch); + } + continue; + } + + const candidateBatch = [...currentBatch, ...dirFiles]; + if (this.estimateGroupingPromptTokens(candidateBatch) > GROUPING_TOKEN_BUDGET) { + if (currentBatch.length > 0) { + batches.push(currentBatch); + } + currentBatch = dirFiles; + } else { + currentBatch = candidateBatch; + } + } + + if (currentBatch.length > 0) { + batches.push(currentBatch); + } + + return batches; + } + + private estimateGroupingPromptTokens(files: FileWithExports[]): number { + const fileList = formatFileListForGrouping(files); + const dirTree = formatDirectoryTree(files.map((f) => f.filePath)); + const prompt = fillTemplate(GROUPING_USER_PROMPT, { + FILE_LIST: fileList, + DIRECTORY_TREE: dirTree, + }); + return estimateTokens(prompt); + } + + private trimSymbolsToFit(file: FileWithExports): FileWithExports { + const symbols = file.symbols; + let lo = 0; + let hi = symbols.length; + while (lo < hi) { + const mid = (lo + hi + 1) >>> 1; + const candidate: FileWithExports = { + filePath: file.filePath, + symbols: [ + ...symbols.slice(0, mid), + { name: `... and ${symbols.length - mid} more`, type: 'truncated' }, + ], + }; + if (this.estimateGroupingPromptTokens([candidate]) <= GROUPING_TOKEN_BUDGET) { + lo = mid; + } else { + hi = mid - 1; + } + } + if (lo >= symbols.length) return file; + return { + filePath: file.filePath, + symbols: + lo > 0 + ? [ + ...symbols.slice(0, lo), + { name: `... and ${symbols.length - lo} more`, type: 'truncated' }, + ] + : [{ name: 'no exports (truncated)', type: 'truncated' }], + }; + } + + /** + * Merge partial groupings from multiple batches. Same module name across + * batches gets file lists concatenated. Deduplicates (first-seen wins). + */ + private mergeGroupings(partials: Record[]): Record { + const merged: Record = {}; + const seen = new Set(); + const slugToCanonical = new Map(); + + for (const partial of partials) { + for (const [mod, paths] of Object.entries(partial)) { + const slug = this.slugify(mod); + const canonical = slugToCanonical.get(slug) ?? mod; + if (!slugToCanonical.has(slug)) slugToCanonical.set(slug, mod); + + for (const fp of paths) { + if (!seen.has(fp)) { + seen.add(fp); + if (!merged[canonical]) merged[canonical] = []; + merged[canonical].push(fp); + } + } + } + } + + return merged; + } + /** * Parse LLM grouping response. Validates all files are assigned. */ diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 2d003038d..884b3af1e 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -304,6 +304,21 @@ export function resolveWorktreeCwd(repoPath: string, launchCwd: string): string */ export const REPO_ID_HASH_LENGTH = 6; +interface ImpactParams { + target: string; + target_uid?: string; + file_path?: string; + kind?: string; + direction: 'upstream' | 'downstream'; + maxDepth?: number; + relationTypes?: string[]; + includeTests?: boolean; + minConfidence?: number; + limit?: number; + offset?: number; + summaryOnly?: boolean; +} + export class LocalBackend { private repos: Map = new Map(); private contextCache: Map = new Map(); @@ -2750,20 +2765,7 @@ export class LocalBackend { }; } - private async impact( - repo: RepoHandle, - params: { - target: string; - target_uid?: string; - file_path?: string; - kind?: string; - direction: 'upstream' | 'downstream'; - maxDepth?: number; - relationTypes?: string[]; - includeTests?: boolean; - minConfidence?: number; - }, - ): Promise { + private async impact(repo: RepoHandle, params: ImpactParams): Promise { try { return await this._impactImpl(repo, params); } catch (err: any) { @@ -2780,20 +2782,7 @@ export class LocalBackend { } } - private async _impactImpl( - repo: RepoHandle, - params: { - target: string; - target_uid?: string; - file_path?: string; - kind?: string; - direction: 'upstream' | 'downstream'; - maxDepth?: number; - relationTypes?: string[]; - includeTests?: boolean; - minConfidence?: number; - }, - ): Promise { + private async _impactImpl(repo: RepoHandle, params: ImpactParams): Promise { await this.ensureInitialized(repo.id); const { target, direction } = params; @@ -2896,6 +2885,9 @@ export class LocalBackend { relationTypes: effectiveRelationTypes, includeTests, minConfidence, + limit: Number.isFinite(params.limit) ? params.limit : 100, + offset: Number.isFinite(params.offset) ? params.offset : 0, + summaryOnly: params.summaryOnly, }); } @@ -2912,9 +2904,20 @@ export class LocalBackend { relationTypes: string[]; includeTests: boolean; minConfidence: number; + limit?: number; + offset?: number; + summaryOnly?: boolean; }, ): Promise { const { maxDepth, relationTypes, includeTests, minConfidence } = opts; + const hasExplicitLimit = typeof opts.limit === 'number' && Number.isFinite(opts.limit); + const paginationLimit = hasExplicitLimit + ? Math.max(1, Math.min(Math.trunc(opts.limit!), 10000)) + : Infinity; + const rawOffset = + typeof opts.offset === 'number' && Number.isFinite(opts.offset) ? opts.offset : 0; + const paginationOffset = Math.max(0, Math.trunc(rawOffset)); + const summaryOnly = opts.summaryOnly ?? false; const relTypeFilter = relationTypes.map((t) => `'${t}'`).join(', '); const confidenceFilter = minConfidence > 0 ? ` AND r.confidence >= ${minConfidence}` : ''; @@ -3326,7 +3329,13 @@ export class LocalBackend { risk = 'MEDIUM'; } - return { + // Build per-depth counts (always included, even in summaryOnly mode) + const byDepthCounts: Record = {}; + for (const [depth, items] of Object.entries(grouped)) { + byDepthCounts[Number(depth)] = items.length; + } + + const base = { target: { id: symId, name: sym.name || sym[1], @@ -3342,9 +3351,37 @@ export class LocalBackend { processes_affected: processCount, modules_affected: moduleCount, }, + byDepthCounts, affected_processes: affectedProcesses, affected_modules: affectedModules, - byDepth: grouped, + }; + + if (summaryOnly) { + return base; + } + + // Apply limit/offset pagination per depth level + const paginatedGrouped: Record = {}; + let anyTruncated = false; + for (const [depth, items] of Object.entries(grouped)) { + const total = items.length; + const sliced = items.slice(paginationOffset, paginationOffset + paginationLimit); + paginatedGrouped[Number(depth)] = sliced; + if (paginationOffset > 0 || paginationOffset + paginationLimit < total) { + anyTruncated = true; + } + } + + return { + ...base, + ...(anyTruncated && { + pagination: { + ...(Number.isFinite(paginationLimit) && { limit: paginationLimit }), + offset: paginationOffset, + truncated: true, + }, + }), + byDepth: paginatedGrouped, }; } @@ -3500,6 +3537,9 @@ export class LocalBackend { if (typeof params.subgroup === 'string') impactArgs.subgroup = params.subgroup; if (params.timeoutMs !== undefined) impactArgs.timeoutMs = params.timeoutMs; if (params.timeout !== undefined) impactArgs.timeout = params.timeout; + // limit/offset/summaryOnly are not forwarded to group-mode impact: + // runGroupImpact uses GROUP_LOCAL_PHASE_LIMIT internally for UID + // collection and does not re-paginate the local result yet. return svc.groupImpact(impactArgs); } if (method === 'query') { diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index bdaeadf7e..2ab80965b 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -336,13 +336,15 @@ Output includes: - summary: direct callers, processes affected, modules affected - affected_processes: which execution flows break and at which step - affected_modules: which functional areas are hit (direct vs indirect) -- byDepth: all affected symbols grouped by traversal depth +- byDepth: affected symbols grouped by traversal depth (paginated by limit/offset; omitted when summaryOnly:true — use byDepthCounts for totals per depth, pagination object when truncated) Depth groups: - d=1: WILL BREAK (direct callers/importers) - d=2: LIKELY AFFECTED (indirect) - d=3: MAY NEED TESTING (transitive) +TIP: For hub symbols (base error classes, shared utilities) with many direct callers, use summaryOnly: true first to see counts and risk, then drill into specific depths with limit/offset. maxDepth alone does not bound output size when most dependents are at depth 1. limit and offset apply independently to each depth level, not to the total result set — use byDepthCounts to see totals per depth. + TIP: Default traversal uses CALLS/IMPORTS/EXTENDS/IMPLEMENTS. For class members, include HAS_METHOD and HAS_PROPERTY in relationTypes. For field access analysis, include ACCESSES in relationTypes. Handles disambiguation: when multiple symbols share the target name, returns ranked candidates (each with a relevance score) instead of silently picking one. Use target_uid for zero-ambiguity lookup, or narrow with file_path and/or kind hints. @@ -422,6 +424,27 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep description: 'Optional group subgroup prefix (member repo paths) limiting which repos participate in cross fan-out.', }, + limit: { + type: 'integer', + description: + 'Max symbols returned in byDepth per depth level (default: 100). Single-repo only; ignored in group mode (@groupName). Use small values for hub symbols to avoid output truncation.', + default: 100, + minimum: 1, + maximum: 10000, + }, + offset: { + type: 'integer', + description: + 'Skip this many symbols per depth level before applying limit. Single-repo only; ignored in group mode (@groupName). Use with limit for pagination.', + default: 0, + minimum: 0, + }, + summaryOnly: { + type: 'boolean', + description: + 'When true, returns target, summary, risk, byDepthCounts, affected_processes, and affected_modules — omits byDepth. Single-repo only; ignored in group mode (@groupName). Use for hub symbols to get actionable signal without output explosion.', + default: false, + }, timeoutMs: { type: 'number', description: diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 680ca1ab2..4139970c6 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -44,7 +44,7 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j * On version mismatch, `loadParseCache` returns an empty cache and the * next save overwrites the on-disk file with the new version baked in. */ -const SCHEMA_BUMP = 1; +const SCHEMA_BUMP = 2; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/fixtures/cobol/AUDITCONST.cpy b/gitnexus/test/fixtures/cobol/AUDITCONST.cpy new file mode 100644 index 000000000..7549829f2 --- /dev/null +++ b/gitnexus/test/fixtures/cobol/AUDITCONST.cpy @@ -0,0 +1,2 @@ + 01 WS-CONSTANT-1 PIC X(10) VALUE 'AUDIT'. + 01 WS-CONSTANT-2 PIC X(10) VALUE 'LOG'. diff --git a/gitnexus/test/fixtures/cobol/AUDITLOG.cbl b/gitnexus/test/fixtures/cobol/AUDITLOG.cbl new file mode 100644 index 000000000..3a294ea3c --- /dev/null +++ b/gitnexus/test/fixtures/cobol/AUDITLOG.cbl @@ -0,0 +1,77 @@ + IDENTIFICATION DIVISION. + PROGRAM-ID. AUDITLOG. + + DATA DIVISION. + WORKING-STORAGE SECTION. + 01 WS-LOG-MESSAGE PIC X(80). + 01 WS-TIMESTAMP PIC X(26). + 01 WS-IDX PIC 9(4). + 01 WS-EOF-FLAG PIC 9 VALUE 0. + 01 WS-PARAM-A PIC X(10). + 01 WS-PARAM-B PIC X(10). + 01 WS-PARAM-C PIC X(10). + 01 WS-INDEX PIC 9(2). + 01 WS-FLAG PIC 9. + COPY AUDITCONST. + COPY AUDITVARS. + + LINKAGE SECTION. + 01 LS-CUST-ID PIC 9(8). + 01 LS-AMOUNT PIC 9(7)V99. + + PROCEDURE DIVISION USING LS-CUST-ID LS-AMOUNT. + MAIN-PARAGRAPH. + PERFORM WRITE-LOG + PERFORM VARYING-TEST + PERFORM UNTIL-TEST + GOBACK. + + WRITE-LOG. + STRING 'Customer ' LS-CUST-ID ' amount ' LS-AMOUNT + DELIMITED BY SIZE INTO WS-LOG-MESSAGE + DISPLAY WS-LOG-MESSAGE. + + * PERFORM VARYING I FROM 1 BY 1 UNTIL I > 10 + VARYING-TEST. + PERFORM VARYING WS-IDX FROM 1 BY 1 + UNTIL WS-IDX > 10 + DISPLAY 'COUNT ' WS-IDX + END-PERFORM. + + * PERFORM UNTIL EOF-FLAG = 1 + UNTIL-TEST. + PERFORM UNTIL WS-EOF-FLAG = 1 + DISPLAY 'LOOPING' + END-PERFORM. + + * CALL with OMITTED (3 args: WS-PARAM-A, OMITTED, WS-PARAM-C) + CALL-OMITTED-TEST. + CALL 'PROCESS' USING WS-PARAM-A OMITTED WS-PARAM-C. + + * Nested IF with CALL inside + NESTED-IF-CALL. + IF WS-FLAG = 1 + IF WS-INDEX > 5 + CALL 'DEEPPROC' + ELSE + CALL 'SHALLOW' + END-IF + END-IF. + + * GO TO DEPENDING ON with 3 targets + GOTO-DEPENDING. + GO TO PARA-ONE PARA-TWO PARA-THREE + DEPENDING ON WS-INDEX. + + PARA-ONE. + DISPLAY 'ONE'. + + PARA-TWO. + DISPLAY 'TWO'. + + PARA-THREE. + DISPLAY 'THREE'. + + ENTRY "AUDITLOG-BATCH" USING LS-CUST-ID. + DISPLAY 'Batch audit for ' LS-CUST-ID + GOBACK. diff --git a/gitnexus/test/fixtures/cobol/AUDITVARS.cpy b/gitnexus/test/fixtures/cobol/AUDITVARS.cpy new file mode 100644 index 000000000..781bd7566 --- /dev/null +++ b/gitnexus/test/fixtures/cobol/AUDITVARS.cpy @@ -0,0 +1,2 @@ + 01 WS-VAR-1 PIC X(10). + 01 WS-VAR-2 PIC X(10). diff --git a/gitnexus/test/fixtures/cobol/COPYLIB.cpy b/gitnexus/test/fixtures/cobol/COPYLIB.cpy new file mode 100644 index 000000000..e78840d89 --- /dev/null +++ b/gitnexus/test/fixtures/cobol/COPYLIB.cpy @@ -0,0 +1,3 @@ + 01 PREFIX-RECORD. + 05 PREFIX-CODE PIC X(10). + 05 PREFIX-NAME PIC X(30). diff --git a/gitnexus/test/fixtures/cobol/CUSTDAT.cpy b/gitnexus/test/fixtures/cobol/CUSTDAT.cpy new file mode 100644 index 000000000..52428837a --- /dev/null +++ b/gitnexus/test/fixtures/cobol/CUSTDAT.cpy @@ -0,0 +1,6 @@ + 01 WS-CUSTOMER-DATA. + 05 WS-CUST-CODE PIC X(10). + 05 WS-CUST-TYPE PIC X(3). + 88 PREMIUM-CUSTOMER VALUE 'PRM'. + 88 REGULAR-CUSTOMER VALUE 'REG'. + 05 WS-CUST-ADDR PIC X(50). diff --git a/gitnexus/test/fixtures/cobol/CUSTUPDT.cbl b/gitnexus/test/fixtures/cobol/CUSTUPDT.cbl new file mode 100644 index 000000000..978e289d1 --- /dev/null +++ b/gitnexus/test/fixtures/cobol/CUSTUPDT.cbl @@ -0,0 +1,74 @@ + IDENTIFICATION DIVISION. + PROGRAM-ID. CUSTUPDT. + AUTHOR. TEST. + + ENVIRONMENT DIVISION. + INPUT-OUTPUT SECTION. + FILE-CONTROL. + SELECT CUSTOMER-FILE ASSIGN TO 'CUSTFILE' + ORGANIZATION IS INDEXED + ACCESS IS DYNAMIC + RECORD KEY IS CUST-ID + FILE STATUS IS WS-FILE-STATUS. + + DATA DIVISION. + FILE SECTION. + FD CUSTOMER-FILE. + 01 CUSTOMER-RECORD. + 05 CUST-ID PIC 9(8). + 05 CUST-NAME PIC X(30). + 05 CUST-BALANCE PIC 9(7)V99. + + WORKING-STORAGE SECTION. + 01 WS-FILE-STATUS PIC XX. + 01 WS-CUSTOMER-NAME PIC X(30). + 01 WS-AMOUNT PIC 9(7)V99. + 01 WS-EOF PIC 9 VALUE 0. + 88 END-OF-FILE VALUE 1. + 01 WS-AMT PIC 9(5)V99. + 01 WS-PROG-NAME PIC X(8). + 01 FIELD-A PIC 9(5)V99. + 01 FIELD-B PIC 9(5)V99. + COPY COPYLIB REPLACING ==PREFIX-== BY ==WS-==. + + LINKAGE SECTION. + 01 LS-PARAM PIC X(20). + + PROCEDURE DIVISION. + INIT-SECTION SECTION. + MAIN-PARAGRAPH. + PERFORM INIT-PARAGRAPH + PERFORM PROCESS-PARAGRAPH + PERFORM CLEANUP-PARAGRAPH + STOP RUN. + + INIT-PARAGRAPH. + OPEN I-O CUSTOMER-FILE + MOVE SPACES TO WS-CUSTOMER-NAME. + + PROCESSING-SECTION SECTION. + PROCESS-PARAGRAPH. + PERFORM READ-CUSTOMER THRU WRITE-CUSTOMER + CALL "AUDITLOG" USING CUST-ID WS-AMOUNT + CALL WS-PROG-NAME. + + READ-CUSTOMER. + READ CUSTOMER-FILE + NOT AT END + MOVE CUST-NAME TO WS-CUSTOMER-NAME + END-READ. + + UPDATE-BALANCE. + ADD WS-AMOUNT TO CUST-BALANCE + MOVE WS-AMOUNT TO CUST-BALANCE + MOVE WS-AMT TO FIELD-A FIELD-B. + + WRITE-CUSTOMER. + REWRITE CUSTOMER-RECORD. + + CLEANUP-PARAGRAPH. + CLOSE CUSTOMER-FILE. + + ENTRY 'ALTENTRY' USING LS-PARAM. + DISPLAY 'ALTERNATE ENTRY POINT' + GOBACK. diff --git a/gitnexus/test/fixtures/cobol/NESTED.cbl b/gitnexus/test/fixtures/cobol/NESTED.cbl new file mode 100644 index 000000000..60af79ef7 --- /dev/null +++ b/gitnexus/test/fixtures/cobol/NESTED.cbl @@ -0,0 +1,33 @@ + IDENTIFICATION DIVISION. + PROGRAM-ID. OUTER-PROG. + + DATA DIVISION. + WORKING-STORAGE SECTION. + 01 WS-OUTER-FLAG PIC 9 VALUE 0. + + PROCEDURE DIVISION. + OUTER-MAIN. + PERFORM OUTER-PROCESS + CALL "INNER-PROG" + STOP RUN. + + OUTER-PROCESS. + DISPLAY 'OUTER PROCESSING'. + + IDENTIFICATION DIVISION. + PROGRAM-ID. INNER-PROG. + + DATA DIVISION. + WORKING-STORAGE SECTION. + 01 WS-INNER-CODE PIC X(5). + + PROCEDURE DIVISION. + INNER-MAIN. + PERFORM INNER-PROCESS + GOBACK. + + INNER-PROCESS. + DISPLAY 'INNER PROCESSING'. + + END PROGRAM INNER-PROG. + END PROGRAM OUTER-PROG. diff --git a/gitnexus/test/fixtures/cobol/RPTGEN.cbl b/gitnexus/test/fixtures/cobol/RPTGEN.cbl new file mode 100644 index 000000000..3ef2c137f --- /dev/null +++ b/gitnexus/test/fixtures/cobol/RPTGEN.cbl @@ -0,0 +1,94 @@ + IDENTIFICATION DIVISION. + PROGRAM-ID. RPTGEN. + + DATA DIVISION. + WORKING-STORAGE SECTION. + COPY CUSTDAT. + 01 WS-REPORT-LINE PIC X(132). + 01 WS-SQL-CODE PIC S9(9) COMP. + 01 WS-COUNT PIC 9(4). + 01 WS-MAP-NAME PIC X(8). + 01 WS-SORT-FILE PIC X(8). + 01 WS-QUEUE-NAME PIC X(16). + 01 WS-NEXT-PGM PIC X(8). + + PROCEDURE DIVISION. + MAIN-PARAGRAPH. + PERFORM FETCH-DATA + PERFORM FORMAT-REPORT + PERFORM SEND-SCREEN + CALL "CUSTUPDT" + GO TO EXIT-PARAGRAPH. + + FETCH-DATA. + EXEC SQL + SELECT CUST_NAME, CUST_BALANCE + FROM CUSTOMER + WHERE CUST_ID = :WS-CUST-CODE + END-EXEC. + + FORMAT-REPORT. + PERFORM WS-COUNT TIMES + MOVE WS-CUST-CODE TO WS-REPORT-LINE + END-PERFORM + PERFORM MAIN-PARAGRAPH THRU FORMAT-REPORT + IF WS-COUNT > 0 PERFORM FETCH-DATA + ELSE PERFORM SEND-SCREEN + END-IF + SORT WS-SORT-FILE USING CUSTOMER-DATA + GIVING WS-REPORT-LINE. + SORT WS-SORT-FILE ON ASCENDING KEY WS-COUNT + INPUT PROCEDURE IS BUILD-SORT-INPUT + OUTPUT PROCEDURE IS WRITE-SORTED. + MOVE CORR WS-CUSTOMER-DATA TO WS-REPORT-LINE + SEARCH WS-CUSTOMER-DATA + GO TO FETCH-DATA FORMAT-REPORT SEND-SCREEN + DEPENDING ON WS-COUNT. + + SEND-SCREEN. + EXEC CICS + SEND MAP(WS-MAP-NAME) MAPSET('CUSTSET') + FROM(WS-REPORT-LINE) + END-EXEC. + + EXEC CICS + LINK PROGRAM('AUDITLOG') + END-EXEC. + + EXEC CICS + XCTL PROGRAM('CUSTUPDT') + END-EXEC. + + EXEC CICS + READ FILE('CUSTFILE') + INTO(WS-CUSTOMER-DATA) + END-EXEC. + + EXEC CICS + WRITEQ TS QUEUE('RPTQUEUE') + FROM(WS-REPORT-LINE) + END-EXEC. + + EXEC CICS + HANDLE ABEND LABEL(ABEND-HANDLER) + END-EXEC. + + EXEC CICS + RETURN TRANSID('RPTG') + END-EXEC. + + EXEC CICS + XCTL PROGRAM(WS-NEXT-PGM) + END-EXEC. + + BUILD-SORT-INPUT. + DISPLAY 'BUILDING SORT INPUT'. + + WRITE-SORTED. + DISPLAY 'WRITING SORTED OUTPUT'. + + ABEND-HANDLER. + DISPLAY 'ABEND OCCURRED'. + + EXIT-PARAGRAPH. + STOP RUN. diff --git a/gitnexus/test/fixtures/cobol/empty-file.cbl b/gitnexus/test/fixtures/cobol/empty-file.cbl new file mode 100644 index 000000000..e69de29bb diff --git a/gitnexus/test/fixtures/cobol/fixed-format.cbl b/gitnexus/test/fixtures/cobol/fixed-format.cbl new file mode 100644 index 000000000..8ccbb1837 --- /dev/null +++ b/gitnexus/test/fixtures/cobol/fixed-format.cbl @@ -0,0 +1,21 @@ + IDENTIFICATION DIVISION. + PROGRAM-ID. FIXEDFORMAT. + * Fixed-format COBOL with sequence numbers in cols 1-6 +000100 ENVIRONMENT DIVISION. +000200 DATA DIVISION. +000300 WORKING-STORAGE SECTION. +000400 01 WS-COUNTER PIC 9(4) VALUE 0. +000500 01 WS-NAME PIC X(20). +000600 +000700 PROCEDURE DIVISION. +000800 MAIN-PARA. +000900 PERFORM INIT-PARA +001000 PERFORM PROCESS-PARA +001100 STOP RUN. +001200 +001300 INIT-PARA. +001400 MOVE 1 TO WS-COUNTER. +001500 +001600 PROCESS-PARA. +001700 CALL "LOGGER" +001800 GOBACK. diff --git a/gitnexus/test/fixtures/cobol/malformed-multiline.cbl b/gitnexus/test/fixtures/cobol/malformed-multiline.cbl new file mode 100644 index 000000000..f023d870c --- /dev/null +++ b/gitnexus/test/fixtures/cobol/malformed-multiline.cbl @@ -0,0 +1,20 @@ + IDENTIFICATION DIVISION. + PROGRAM-ID. MALFORMED. + DATA DIVISION. + WORKING-STORAGE SECTION. + 01 WS-PGM PIC X(8) VALUE "OTHER". + PROCEDURE DIVISION. + MAIN. + * Incomplete statement (no period) + MOVE "TEST" TO WS-PGM + * CALL USING on separate lines + CALL "TARGET" + USING WS-PGM + RETURNING WS-PGM + * CALL without END-CALL across lines + CALL "MULTILINE" + USING WS-PGM + * GO TO with multiple targets + GO TO MAIN EXIT-PARA. + EXIT-PARA. + GOBACK. diff --git a/gitnexus/test/fixtures/cobol/whitespace-only.cbl b/gitnexus/test/fixtures/cobol/whitespace-only.cbl new file mode 100644 index 000000000..fd40910d9 --- /dev/null +++ b/gitnexus/test/fixtures/cobol/whitespace-only.cbl @@ -0,0 +1,4 @@ + + + + diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-overload-udc-namespace-collision/lib.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-overload-udc-namespace-collision/lib.cpp new file mode 100644 index 000000000..380679ba2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overload-udc-namespace-collision/lib.cpp @@ -0,0 +1,15 @@ +#include "lib.h" + +namespace alpha { + +Other::Other(int value) {} +void Service::f(Token value) {} +void Service::f(Other value) {} + +} // namespace alpha + +namespace beta { + +Token::Token(int value) {} + +} // namespace beta diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-overload-udc-namespace-collision/lib.h b/gitnexus/test/fixtures/lang-resolution/cpp-overload-udc-namespace-collision/lib.h new file mode 100644 index 000000000..10517d6d7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overload-udc-namespace-collision/lib.h @@ -0,0 +1,31 @@ +#pragma once + +namespace alpha { + +class Token {}; + +class Other { +public: + Other(int value); +}; + +class Service { +public: + void f(Token value); + void f(Other value); + + void run() { + f(42); + } +}; + +} // namespace alpha + +namespace beta { + +class Token { +public: + Token(int value); +}; + +} // namespace beta diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-overload-user-defined-conversion/lib.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-overload-user-defined-conversion/lib.cpp new file mode 100644 index 000000000..30111c7f3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overload-user-defined-conversion/lib.cpp @@ -0,0 +1,14 @@ +#include "lib.h" + +Wrap::Wrap(int value) {} +WrapA::WrapA(int value) {} +WrapB::WrapB(int value) {} +ExplicitWrap::ExplicitWrap(int value) {} + +void Service::f(Wrap value) {} +void Service::f(double value) {} +void Service::g(Wrap value) {} +void Service::h(WrapA value) {} +void Service::h(WrapB value) {} +void Service::e(Wrap value) {} +void Service::e(ExplicitWrap value) {} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-overload-user-defined-conversion/lib.h b/gitnexus/test/fixtures/lang-resolution/cpp-overload-user-defined-conversion/lib.h new file mode 100644 index 000000000..f6e117241 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overload-user-defined-conversion/lib.h @@ -0,0 +1,39 @@ +#pragma once + +class Wrap { +public: + Wrap(int value); +}; + +class WrapA { +public: + WrapA(int value); +}; + +class WrapB { +public: + WrapB(int value); +}; + +class ExplicitWrap { +public: + explicit ExplicitWrap(int value); +}; + +class Service { +public: + void f(Wrap value); + void f(double value); + void g(Wrap value); + void h(WrapA value); + void h(WrapB value); + void e(Wrap value); + void e(ExplicitWrap value); + + void run() { + f(42); + g(42); + h(42); + e(42); + } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/fastapi-depends/app/api/calls.py b/gitnexus/test/fixtures/lang-resolution/fastapi-depends/app/api/calls.py new file mode 100644 index 000000000..6232ec756 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/fastapi-depends/app/api/calls.py @@ -0,0 +1,12 @@ +from fastapi import Depends, APIRouter +from app.dependencies import get_current_user_record, get_db, User, Session + +router = APIRouter() + + +@router.get("/calls") +async def list_calls( + user: User = Depends(get_current_user_record), + db: Session = Depends(get_db), +): + return [] diff --git a/gitnexus/test/fixtures/lang-resolution/fastapi-depends/app/api/users.py b/gitnexus/test/fixtures/lang-resolution/fastapi-depends/app/api/users.py new file mode 100644 index 000000000..3f3813d82 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/fastapi-depends/app/api/users.py @@ -0,0 +1,14 @@ +from fastapi import Depends, APIRouter +from app.dependencies import get_current_user_record, get_db, User, Session + +router = APIRouter() + + +@router.get("/users") +async def get_user(user: User = Depends(get_current_user_record)): + return user + + +@router.post("/users") +async def create_user(db=Depends(get_db)): + return {} diff --git a/gitnexus/test/fixtures/lang-resolution/fastapi-depends/app/dependencies.py b/gitnexus/test/fixtures/lang-resolution/fastapi-depends/app/dependencies.py new file mode 100644 index 000000000..0afba9edf --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/fastapi-depends/app/dependencies.py @@ -0,0 +1,22 @@ +from typing import Optional + + +class Session: + pass + + +class User: + id: int + username: str + + +async def get_db() -> Session: + db = Session() + try: + yield db + finally: + pass + + +async def get_current_user_record(db: Session) -> User: + return User() diff --git a/gitnexus/test/fixtures/lang-resolution/fastapi-depends/app/models.py b/gitnexus/test/fixtures/lang-resolution/fastapi-depends/app/models.py new file mode 100644 index 000000000..bbed6d889 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/fastapi-depends/app/models.py @@ -0,0 +1,4 @@ +class CallRecord: + id: int + caller: str + callee: str diff --git a/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/app/api/grants/route.ts b/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/app/api/grants/route.ts new file mode 100644 index 000000000..6acf5e471 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/app/api/grants/route.ts @@ -0,0 +1,6 @@ +import { NextResponse } from 'next/server'; + +export async function GET() { + const grants = [{ id: 1, name: 'Research Grant' }]; + return NextResponse.json(grants); +} diff --git a/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/app/api/users/route.ts b/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/app/api/users/route.ts new file mode 100644 index 000000000..253184d83 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/app/api/users/route.ts @@ -0,0 +1,6 @@ +import { NextResponse } from 'next/server'; + +export async function GET() { + const users = [{ id: 1, username: 'admin' }]; + return NextResponse.json(users); +} diff --git a/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/lib/api-client.ts b/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/lib/api-client.ts new file mode 100644 index 000000000..0932cc094 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/lib/api-client.ts @@ -0,0 +1,5 @@ +const API_BASE = process.env.API_BASE || ''; + +export async function apiFetch(path: string, opts?: RequestInit) { + return fetch(`${API_BASE}${path}`, opts); +} diff --git a/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/pages/GrantsList.tsx b/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/pages/GrantsList.tsx new file mode 100644 index 000000000..d10f682c2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/pages/GrantsList.tsx @@ -0,0 +1,9 @@ +import { apiFetch } from '../lib/api-client'; + +export default function GrantsList() { + const loadGrants = async () => { + const res = await apiFetch('/api/grants'); + return res.json(); + }; + return null; +} diff --git a/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/pages/UserList.tsx b/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/pages/UserList.tsx new file mode 100644 index 000000000..c3ab4b15a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/pages/UserList.tsx @@ -0,0 +1,9 @@ +import { apiFetch } from '../lib/api-client'; + +export default function UserList() { + const loadUsers = async () => { + const res = await apiFetch('/api/users'); + return res.json(); + }; + return null; +} diff --git a/gitnexus/test/helpers/test-db.ts b/gitnexus/test/helpers/test-db.ts index 5be063a2c..3c845dbe0 100644 --- a/gitnexus/test/helpers/test-db.ts +++ b/gitnexus/test/helpers/test-db.ts @@ -21,6 +21,9 @@ const cleanupBackoffMs = (attempt: number): number => 100 * (attempt + 1); const shouldSwallowCleanupError = (err: unknown): boolean => { const code = (err as NodeJS.ErrnoException | undefined)?.code; + // ENOTEMPTY can race on any platform (macOS node-gyp cache, Linux + // parallel test teardown) — swallow after retries are exhausted. + if (code === 'ENOTEMPTY') return true; return process.platform === 'win32' && WINDOWS_NATIVE_LOCK_CODES.has(code ?? ''); }; 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..617214c0c --- /dev/null +++ b/gitnexus/test/integration/antigravity-hook-e2e.test.ts @@ -0,0 +1,431 @@ +/** + * 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 { cleanupTempDir, cleanupTempDirSync } from '../helpers/test-db.js'; +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 cleanupTempDir(tempHome); + if (tmpDir) cleanupTempDirSync(tmpDir); +}); + +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(() => { + cleanupTempDirSync(cleanupRoot); + }); + + 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/lbug-non-ascii-path.test.ts b/gitnexus/test/integration/lbug-non-ascii-path.test.ts new file mode 100644 index 000000000..b5f8243fa --- /dev/null +++ b/gitnexus/test/integration/lbug-non-ascii-path.test.ts @@ -0,0 +1,80 @@ +/** + * Integration Tests: Non-ASCII path handling (#1811) + * + * Verifies that LadybugDB can open a database and run COPY commands when + * the storage path contains CJK (or other non-ASCII) characters. + * + * The primary failure mode is on Windows, where KuzuDB's native layer + * uses ANSI file APIs and the Active Code Page mangles UTF-8 bytes. + * The fix converts paths to 8.3 short-name form on Windows. On + * Linux/macOS the conversion is a no-op since POSIX APIs handle UTF-8 + * natively — but locale misconfiguration or filesystem encoding + * mismatches could still surface, so the test runs on all platforms. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; +import { createMinimalTestGraph } from '../helpers/test-graph.js'; + +let tmpBase: string; +let storagePath: string; +let dbPath: string; + +beforeAll(async () => { + // Create a temp directory with CJK characters in the name. + // This reproduces the user's scenario: repo at C:\Project\中文\code + tmpBase = path.join(os.tmpdir(), `gitnexus-lbug-非ASCII路径-${Date.now()}-${process.pid}`); + storagePath = path.join(tmpBase, '.gitnexus'); + dbPath = path.join(storagePath, 'lbug'); + await fs.mkdir(dbPath, { recursive: true }); +}); + +afterAll(async () => { + // Close the adapter before cleanup to release native file handles. + try { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + await adapter.closeLbug(); + } catch { + // May not have been opened + } + + if (tmpBase) { + // Retry cleanup — LadybugDB on Windows holds handles briefly after close. + for (let attempt = 0; attempt < 5; attempt++) { + try { + await fs.rm(tmpBase, { recursive: true, force: true }); + return; + } catch { + if (attempt < 4) await new Promise((r) => setTimeout(r, 200 * (attempt + 1))); + } + } + } +}); + +describe('LadybugDB with non-ASCII storage path (#1811)', () => { + it('initLbug succeeds with CJK characters in the database path', async () => { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + await expect(adapter.initLbug(dbPath)).resolves.not.toThrow(); + }); + + it('loadGraphToLbug COPY succeeds with CJK characters in CSV paths', async () => { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + const graph = createMinimalTestGraph(); + + await expect(adapter.loadGraphToLbug(graph, tmpBase, storagePath)).resolves.not.toThrow(); + }); + + it('data is queryable after loading through non-ASCII paths', async () => { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + + const files = await adapter.executeQuery('MATCH (n:File) RETURN n.id AS id'); + expect(files).toHaveLength(2); + + const functions = await adapter.executeQuery('MATCH (n:Function) RETURN n.id AS id'); + expect(functions).toHaveLength(2); + + const rels = await adapter.executeQuery('MATCH ()-[r:CodeRelation]->() RETURN count(r) AS cnt'); + expect(rels[0].cnt).toBe(4); + }); +}); diff --git a/gitnexus/test/integration/parse-impl-quarantine-cache-skip.test.ts b/gitnexus/test/integration/parse-impl-quarantine-cache-skip.test.ts index 6b1998a2b..1c5e59816 100644 --- a/gitnexus/test/integration/parse-impl-quarantine-cache-skip.test.ts +++ b/gitnexus/test/integration/parse-impl-quarantine-cache-skip.test.ts @@ -129,6 +129,7 @@ const accumulated = { heritage: [], routes: [], fetchCalls: [], + fetchWrapperDefs: [], decoratorRoutes: [], toolDefs: [], ormQueries: [], diff --git a/gitnexus/test/integration/resolvers/cobol-scope.test.ts b/gitnexus/test/integration/resolvers/cobol-scope.test.ts new file mode 100644 index 000000000..bd57656c9 --- /dev/null +++ b/gitnexus/test/integration/resolvers/cobol-scope.test.ts @@ -0,0 +1,553 @@ +/** + * COBOL scope-capture integration tests. + * + * These test that `emitCobolScopeCaptures` produces correct `CaptureMatch[]` + * from real COBOL source files, covering all 11 fixture classes. + * + * The test verifies capture output directly — scope-resolution pipeline + * integration (REGISTRY_PRIMARY_COBOL) will be tested in a follow-up phase + * after the pipeline wiring is complete. + */ + +import { describe, expect, it } from 'vitest'; +import path from 'path'; +import fs from 'fs'; +import { emitCobolScopeCaptures } from '../../../src/core/ingestion/languages/cobol/captures.js'; + +const FIXTURES = path.resolve(process.cwd(), 'test/fixtures/cobol'); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function readFixture(name: string): string { + const p = path.join(FIXTURES, name); + return fs.readFileSync(p, 'utf-8'); +} + +/** Count captures with exact name */ +function countByName(captures: readonly Record[], name: string): number { + return captures.filter((m) => name in m).length; +} + +/** Find a capture match where @declaration.name.text equals a value */ +function findDecl( + captures: readonly Record[], + name: string, +): Record | undefined { + return captures.find((m) => { + const n = (m as Record)['@declaration.name']; + return n?.text === name; + }); +} + +/** Find a capture match where @reference.name.text equals a value */ +function findRef( + captures: readonly Record[], + name: string, +): Record | undefined { + return captures.find((m) => { + const n = (m as Record)['@reference.name']; + return n?.text === name; + }); +} + +/** Find a capture match with @import.name.text */ +function findImport( + captures: readonly Record[], + name: string, +): Record | undefined { + return captures.find((m) => { + const n = (m as Record)['@import.name']; + return n?.text.toUpperCase() === name.toUpperCase(); + }); +} + +// =========================================================================== +// Class 1: Basic program structure +// =========================================================================== + +describe('Class 1: Basic program structure — PROGRAM-ID + paragraphs + CALL + PERFORM', () => { + it('AUDITLOG.cbl: PROGRAM-ID, PROCEDURE DIVISION USING, PERFORM', () => { + const result = emitCobolScopeCaptures(readFixture('AUDITLOG.cbl'), 'AUDITLOG.cbl'); + expect(result.length).toBeGreaterThan(0); + + // Should have a @scope.module for AUDITLOG + const moduleCount = countByName(result, '@scope.module'); + expect(moduleCount).toBe(1); + + // Should have @declaration.name = 'AUDITLOG' + const auditlog = findDecl(result, 'AUDITLOG'); + expect(auditlog).toBeDefined(); + + // Should have functions for paragraphs: MAIN-PARAGRAPH, WRITE-LOG + const funcCount = countByName(result, '@scope.function'); + expect(funcCount).toBeGreaterThanOrEqual(2); + + // Should have PERFORM references + const perfCount = countByName(result, '@reference.call'); + expect(perfCount).toBeGreaterThanOrEqual(1); + + // WRITE-LOG paragraph should be a function scope + const writeLog = findDecl(result, 'WRITE-LOG'); + expect(writeLog).toBeDefined(); + + // Gap 1: PERFORM VARYING captures target paragraph + expect(findDecl(result, 'VARYING-TEST')).toBeDefined(); + + // Gap 2: PERFORM UNTIL captures target paragraph + expect(findDecl(result, 'UNTIL-TEST')).toBeDefined(); + + // Gap 3: GO TO DEPENDING ON with 3 branches + expect(findRef(result, 'PARA-ONE')).toBeDefined(); + expect(findRef(result, 'PARA-TWO')).toBeDefined(); + expect(findRef(result, 'PARA-THREE')).toBeDefined(); + + // Gap 4: Multiple COPY statements + const copyConst = findImport(result, 'AUDITCONST'); + expect(copyConst).toBeDefined(); + const copyVars = findImport(result, 'AUDITVARS'); + expect(copyVars).toBeDefined(); + expect(copyConst).not.toBe(copyVars); + + // Gap 5: CALL with OMITTED — arity counts actual params, OMITTED is a keyword placeholder + const procRef = findRef(result, 'PROCESS'); + expect(procRef).toBeDefined(); + const procMatch = procRef as Record; + // CALL 'PROCESS' USING WS-PARAM-A OMITTED WS-PARAM-C has 2 actual params + expect(procMatch['@reference.arity']).toBeDefined(); + expect(procMatch['@reference.arity']!.text).toBe('2'); + + // Gap 6: CALLs inside nested IF blocks + expect(findRef(result, 'DEEPPROC')).toBeDefined(); + expect(findRef(result, 'SHALLOW')).toBeDefined(); + }); + + it('RPTGEN.cbl: PROGRAM-ID, PERFORM, GO TO, SORT INPUT/OUTPUT PROCEDURE', () => { + const result = emitCobolScopeCaptures(readFixture('RPTGEN.cbl'), 'RPTGEN.cbl'); + expect(result.length).toBeGreaterThan(0); + + // PROGRAM-ID + expect(countByName(result, '@scope.module')).toBe(1); + expect(findDecl(result, 'RPTGEN')).toBeDefined(); + + // Paragraphs: MAIN-PARAGRAPH, FETCH-DATA, FORMAT-REPORT, etc. + const funcCount = countByName(result, '@scope.function'); + expect(funcCount).toBeGreaterThanOrEqual(6); + + // REFERENCES for PERFORM and GO TO + const refCount = countByName(result, '@reference.call'); + expect(refCount).toBeGreaterThanOrEqual(5); + + // GO TO DEPENDING ON should create multiple reference targets + const gotoFetch = findRef(result, 'FETCH-DATA'); + expect(gotoFetch).toBeDefined(); + const gotoFormat = findRef(result, 'FORMAT-REPORT'); + expect(gotoFormat).toBeDefined(); + }); +}); + +// =========================================================================== +// Class 2: COPY import +// =========================================================================== + +describe('Class 2: COPY import — COPY bookname, COPY REPLACING', () => { + it('RPTGEN.cbl: COPY CUSTDAT without REPLACING', () => { + const result = emitCobolScopeCaptures(readFixture('RPTGEN.cbl'), 'RPTGEN.cbl'); + const imp = findImport(result, 'CUSTDAT'); + expect(imp).toBeDefined(); + }); + + it('CUSTUPDT.cbl: COPY COPYLIB REPLACING ==PREFIX-== BY ==WS-==', () => { + const result = emitCobolScopeCaptures(readFixture('CUSTUPDT.cbl'), 'CUSTUPDT.cbl'); + const imp = findImport(result, 'COPYLIB'); + expect(imp).toBeDefined(); + }); +}); + +// =========================================================================== +// Class 3: CALL USING +// =========================================================================== + +describe('Class 3: CALL USING — match/mismatch arity', () => { + it('AUDITLOG.cbl: PROCEDURE DIVISION USING with 2 params', () => { + const result = emitCobolScopeCaptures(readFixture('AUDITLOG.cbl'), 'AUDITLOG.cbl'); + // AUDITLOG has PROCEDURE DIVISION USING LS-CUST-ID LS-AMOUNT (2 params) + const auditlog = findDecl(result, 'AUDITLOG'); + expect(auditlog).toBeDefined(); + const match = auditlog as Record; + // Parameter count should be captured + expect(match['@declaration.parameter-count']).toBeDefined(); + expect(match['@declaration.parameter-count']!.text).toBe('2'); + }); + + it('CUSTUPDT.cbl: CALL "AUDITLOG" USING CUST-ID WS-AMOUNT (2 args)', () => { + const result = emitCobolScopeCaptures(readFixture('CUSTUPDT.cbl'), 'CUSTUPDT.cbl'); + // Should have CALL reference with arity=2 + const callRef = findRef(result, 'AUDITLOG'); + expect(callRef).toBeDefined(); + const match = callRef as Record; + expect(match['@reference.arity']).toBeDefined(); + expect(match['@reference.arity']!.text).toBe('2'); + }); +}); + +// =========================================================================== +// Class 4: Dynamic CALL +// =========================================================================== + +describe('Class 4: Dynamic CALL — CALL WS-VAR, stays unresolved', () => { + it('CUSTUPDT.cbl: CALL WS-PROG-NAME (dynamic, no quotes)', () => { + const result = emitCobolScopeCaptures(readFixture('CUSTUPDT.cbl'), 'CUSTUPDT.cbl'); + // WS-PROG-NAME should appear as a CALL reference + const dynCall = findRef(result, 'WS-PROG-NAME'); + expect(dynCall).toBeDefined(); + }); +}); + +// =========================================================================== +// Class 5: Nested programs +// =========================================================================== + +describe('Class 5: Nested programs — multiple PROGRAM-IDs, scope isolation', () => { + it('NESTED.cbl: OUTER-PROG + INNER-PROG in one file', () => { + const result = emitCobolScopeCaptures(readFixture('NESTED.cbl'), 'NESTED.cbl'); + // Two PROGRAM-IDs → 2 @scope.module captures + const moduleCount = countByName(result, '@scope.module'); + expect(moduleCount).toBe(2); + + // Both program names should appear + expect(findDecl(result, 'OUTER-PROG')).toBeDefined(); + expect(findDecl(result, 'INNER-PROG')).toBeDefined(); + + // Paragraphs in both programs + expect(findDecl(result, 'OUTER-MAIN')).toBeDefined(); + expect(findDecl(result, 'OUTER-PROCESS')).toBeDefined(); + expect(findDecl(result, 'INNER-MAIN')).toBeDefined(); + expect(findDecl(result, 'INNER-PROCESS')).toBeDefined(); + + // CALL "INNER-PROG" reference + expect(findRef(result, 'INNER-PROG')).toBeDefined(); + }); +}); + +// =========================================================================== +// Class 6: SECTION vs PARAGRAPH +// =========================================================================== + +describe('Class 6: SECTION vs PARAGRAPH — both map to Function', () => { + it('CUSTUPDT.cbl: Sections (INIT-SECTION, PROCESSING-SECTION) + paragraphs', () => { + const result = emitCobolScopeCaptures(readFixture('CUSTUPDT.cbl'), 'CUSTUPDT.cbl'); + // Sections declared as functions + expect(findDecl(result, 'INIT-SECTION')).toBeDefined(); + expect(findDecl(result, 'PROCESSING-SECTION')).toBeDefined(); + + // Paragraphs inside sections + expect(findDecl(result, 'MAIN-PARAGRAPH')).toBeDefined(); + expect(findDecl(result, 'INIT-PARAGRAPH')).toBeDefined(); + expect(findDecl(result, 'PROCESS-PARAGRAPH')).toBeDefined(); + + // Both are @scope.function + const funcCount = countByName(result, '@scope.function'); + expect(funcCount).toBeGreaterThanOrEqual(5); + }); +}); + +// =========================================================================== +// Class 7: Single-quoted CALL/COPY +// =========================================================================== + +describe('Class 7: Single-quoted CALL/COPY — the #500 regression case', () => { + it('CUSTUPDT.cbl: ENTRY with single quotes ALTENTRY', () => { + const result = emitCobolScopeCaptures(readFixture('CUSTUPDT.cbl'), 'CUSTUPDT.cbl'); + // ENTRY 'ALTENTRY' uses single quotes — should still produce captures + // (ENTRY points are recognized by the regex tagger) + // Verify the file processed without error + expect(result.length).toBeGreaterThan(0); + }); +}); + +// =========================================================================== +// Class 8: Fixed-format with sequence numbers +// =========================================================================== + +describe('Class 8: Fixed-format — sequence numbers in cols 1-6, Area A/B detection', () => { + it('fixed-format.cbl: sequence numbers 000100-001800, Area A paragraphs', () => { + const result = emitCobolScopeCaptures(readFixture('fixed-format.cbl'), 'fixed-format.cbl'); + expect(result.length).toBeGreaterThan(0); + + // Should detect PROGRAM-ID FIXEDFORMAT + expect(findDecl(result, 'FIXEDFORMAT')).toBeDefined(); + expect(countByName(result, '@scope.module')).toBe(1); + + // Paragraphs MAIN-PARA, INIT-PARA, PROCESS-PARA + expect(findDecl(result, 'MAIN-PARA')).toBeDefined(); + expect(findDecl(result, 'INIT-PARA')).toBeDefined(); + expect(findDecl(result, 'PROCESS-PARA')).toBeDefined(); + + // CALL "LOGGER" reference + expect(findRef(result, 'LOGGER')).toBeDefined(); + }); +}); + +// =========================================================================== +// Class 9: Edge: malformed/multiline +// =========================================================================== + +describe('Class 9: Edge: malformed/multiline — incomplete statements, CALL USING on separate lines', () => { + it('malformed-multiline.cbl: multiline CALL, incomplete statements', () => { + const result = emitCobolScopeCaptures( + readFixture('malformed-multiline.cbl'), + 'malformed-multiline.cbl', + ); + expect(result.length).toBeGreaterThan(0); + + // Should still detect PROGRAM-ID + expect(findDecl(result, 'MALFORMED')).toBeDefined(); + + // Paragraphs MAIN, EXIT-PARA + expect(findDecl(result, 'MAIN')).toBeDefined(); + expect(findDecl(result, 'EXIT-PARA')).toBeDefined(); + + // CALL "TARGET" should be captured (multi-line CALL with USING) + const target = findRef(result, 'TARGET'); + expect(target).toBeDefined(); + + // CALL "MULTILINE" should also be captured + const multi = findRef(result, 'MULTILINE'); + expect(multi).toBeDefined(); + + // GO TO EXIT-PARA reference + const exitRef = findRef(result, 'EXIT-PARA'); + expect(exitRef).toBeDefined(); + }); +}); + +// =========================================================================== +// Class 10: Edge: empty/whitespace file +// =========================================================================== + +describe('Class 10: Edge: empty/whitespace file — must not throw', () => { + it('empty-file.cbl: empty file produces empty captures', () => { + const result = emitCobolScopeCaptures(readFixture('empty-file.cbl'), 'empty-file.cbl'); + expect(result).toEqual([]); + }); + + it('whitespace-only.cbl: whitespace-only file produces empty captures', () => { + const result = emitCobolScopeCaptures( + readFixture('whitespace-only.cbl'), + 'whitespace-only.cbl', + ); + expect(result).toEqual([]); + }); +}); + +// =========================================================================== +// Class 11: Legacy parity — legacy processor doesn't crash on fixtures +// =========================================================================== + +describe('Class 11: Legacy parity — legacy processor handles fixtures', () => { + it('all fixtures can be processed without error', () => { + const files = fs.readdirSync(FIXTURES).filter((f) => f.endsWith('.cbl')); + expect(files.length).toBeGreaterThan(5); + + for (const file of files) { + const source = readFixture(file); + // Just running the regex tagger should not throw + expect(() => emitCobolScopeCaptures(source, file)).not.toThrow(); + } + }); +}); + +// =========================================================================== +// Cross-cutting: all fixtures produce expected structure +// =========================================================================== + +describe('Cross-cutting structure verification', () => { + it('every non-empty COBOL file produces at least one @scope.module', () => { + const files = fs + .readdirSync(FIXTURES) + .filter((f) => f.endsWith('.cbl') && f !== 'empty-file.cbl' && f !== 'whitespace-only.cbl'); + for (const file of files) { + const result = emitCobolScopeCaptures(readFixture(file), file); + const modCount = countByName(result, '@scope.module'); + expect(modCount).toBeGreaterThanOrEqual(1); + } + }); + + it('every @scope.function has a matching @declaration.name', () => { + const files = fs + .readdirSync(FIXTURES) + .filter((f) => f.endsWith('.cbl') && f !== 'empty-file.cbl' && f !== 'whitespace-only.cbl'); + for (const file of files) { + const result = emitCobolScopeCaptures(readFixture(file), file); + const funcScopes = result.filter((m) => '@scope.function' in m); + const funcDecls = result.filter((m) => '@declaration.function' in m); + expect(funcDecls.length).toBe(funcScopes.length); + } + }); +}); + +// =========================================================================== +// Reviewer Check 1: Program-ID scope isolation +// =========================================================================== + +describe('Reviewer Check 1: Program-ID scope isolation — CALLs in different PROGRAM-IDs', () => { + it('NESTED.cbl: OUTER-PROG and INNER-PROG each have isolated paragraphs', () => { + const result = emitCobolScopeCaptures(readFixture('NESTED.cbl'), 'NESTED.cbl'); + + // Both programs produce separate @scope.module captures + const mods = result.filter((m) => '@scope.module' in m); + expect(mods.length).toBe(2); + + // OUTER-PROG-related paragraphs + expect(findDecl(result, 'OUTER-MAIN')).toBeDefined(); + expect(findDecl(result, 'OUTER-PROCESS')).toBeDefined(); + // INNER-PROG-related paragraphs + expect(findDecl(result, 'INNER-MAIN')).toBeDefined(); + expect(findDecl(result, 'INNER-PROCESS')).toBeDefined(); + + // CALL "INNER-PROG" from OUTER-PROG + expect(findRef(result, 'INNER-PROG')).toBeDefined(); + }); + + it('CUSTUPDT.cbl: CALLs in one program do not cross-contaminate paragraphs', () => { + const result = emitCobolScopeCaptures(readFixture('CUSTUPDT.cbl'), 'CUSTUPDT.cbl'); + // CUSTUPDT is a single PROGRAM-ID; all paragraphs belong to it + expect(countByName(result, '@scope.module')).toBe(1); + // Verify several distinct paragraphs exist + expect(findDecl(result, 'MAIN-PARAGRAPH')).toBeDefined(); + expect(findDecl(result, 'INIT-PARAGRAPH')).toBeDefined(); + expect(findDecl(result, 'PROCESS-PARAGRAPH')).toBeDefined(); + expect(findDecl(result, 'READ-CUSTOMER')).toBeDefined(); + expect(findDecl(result, 'UPDATE-BALANCE')).toBeDefined(); + expect(findDecl(result, 'WRITE-CUSTOMER')).toBeDefined(); + expect(findDecl(result, 'CLEANUP-PARAGRAPH')).toBeDefined(); + }); +}); + +// =========================================================================== +// Reviewer Check 2: COPY REPLACING capture range consistency +// =========================================================================== + +describe('Reviewer Check 2: COPY REPLACING — capture ranges from transformed source', () => { + it('CUSTUPDT.cbl: COPY COPYLIB REPLACING capture range matches source line', () => { + const source = readFixture('CUSTUPDT.cbl'); + const lines = source.split('\n'); + const result = emitCobolScopeCaptures(source, 'CUSTUPDT.cbl'); + + const copyMatch = findImport(result, 'COPYLIB'); + expect(copyMatch).toBeDefined(); + + // Find '@import.statement' within the match + const importCap = copyMatch as Record< + string, + { name: string; range: { startLine: number }; text: string } + >; + const stmt = importCap['@import.statement']; + expect(stmt).toBeDefined(); + // Range should reference a valid line in the source + const lineIdx = stmt.range.startLine - 1; + expect(lineIdx).toBeGreaterThanOrEqual(0); + expect(lineIdx).toBeLessThan(lines.length); + // The line should contain COPY...COPYLIB + expect(lines[lineIdx].toUpperCase()).toContain('COPY'); + expect(lines[lineIdx].toUpperCase()).toContain('COPYLIB'); + }); + + it('RPTGEN.cbl: COPY CUSTDAT capture range matches source line', () => { + const source = readFixture('RPTGEN.cbl'); + const lines = source.split('\n'); + const result = emitCobolScopeCaptures(source, 'RPTGEN.cbl'); + + const copyMatch = findImport(result, 'CUSTDAT'); + expect(copyMatch).toBeDefined(); + + const importCap = copyMatch as Record< + string, + { name: string; range: { startLine: number }; text: string } + >; + const stmt = importCap['@import.statement']; + expect(stmt).toBeDefined(); + const lineIdx = stmt.range.startLine - 1; + expect(lineIdx).toBeGreaterThanOrEqual(0); + expect(lineIdx).toBeLessThan(lines.length); + expect(lines[lineIdx].toUpperCase()).toContain('COPY'); + expect(lines[lineIdx].toUpperCase()).toContain('CUSTDAT'); + }); +}); + +// =========================================================================== +// Reviewer Check 3: Import ownership scope +// =========================================================================== + +describe('Reviewer Check 3: importOwningScope returns Module scope for COPY', () => { + it('importOwningScope walks from paragraph to enclosing Module', async () => { + // Test the importOwningScope function directly + const { cobolImportOwningScope } = + await import('../../../src/core/ingestion/languages/cobol/interpret.js'); + // Simulate an import at paragraph (Function) scope: + // the function should walk up to find the Module. + const mockTree = { + getScope: (id: string) => { + if (id === 'func:test') return { id: 'func:test', kind: 'Function' } as any; + if (id === 'mod:test') return { id: 'mod:test', kind: 'Module' } as any; + return undefined; + }, + getAncestors: (_id: string) => ['mod:test'], + getParent: (_id: string) => undefined, + getChildren: (_id: string) => [], + has: (_id: string) => true, + byId: new Map(), + size: 2, + }; + + const paraScope = { + id: 'func:test', + kind: 'Function', + name: 'TEST-PARA', + range: { startLine: 1, startCol: 0, endLine: 2, endCol: 0 }, + } as any; + const result = cobolImportOwningScope(null as any, paraScope, mockTree); + expect(result).toBe('mod:test'); + }); + + it('importOwningScope returns innermost when already in Module scope', async () => { + const { cobolImportOwningScope } = + await import('../../../src/core/ingestion/languages/cobol/interpret.js'); + const mockTree = { + getScope: () => undefined, + getAncestors: () => [], + getParent: () => undefined, + getChildren: () => [], + has: () => true, + byId: new Map(), + size: 1, + }; + const modScope = { + id: 'mod:test', + kind: 'Module', + name: 'MYPROG', + range: { startLine: 1, startCol: 0, endLine: 100, endCol: 0 }, + } as any; + const result = cobolImportOwningScope(null as any, modScope, mockTree); + expect(result).toBe('mod:test'); + }); +}); + +// =========================================================================== +// Reviewer Check 5: Dynamic CALL produces no CALLS edge, CodeElement annotation +// =========================================================================== + +describe('Reviewer Check 5: Dynamic CALL — CALL WS-VAR captures as reference', () => { + it('CUSTUPDT.cbl: CALL WS-PROG-NAME is captured as a dynamic reference', () => { + const result = emitCobolScopeCaptures(readFixture('CUSTUPDT.cbl'), 'CUSTUPDT.cbl'); + // WS-PROG-NAME should appear as a CALL reference (dynamic, no quotes) + const dynRef = findRef(result, 'WS-PROG-NAME'); + expect(dynRef).toBeDefined(); + + // Verify it's a @reference.call, not a resolved edge + const ref = dynRef as Record; + expect(ref['@reference.call']).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index b00aa9632..babdaf569 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -1983,6 +1983,71 @@ describe('C++ overload resolution — pointer/nullptr/ellipsis ranks (#1637)', ( }); }); +describe('C++ overload resolution — user-defined conversion rank (#1631)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-overload-user-defined-conversion'), + () => {}, + ); + }, 60000); + + it('f(42) resolves to f(double) because standard conversion beats constructor UDC', () => { + const calls = getRelationships(result, 'CALLS'); + const fCalls = calls.filter((c) => c.source === 'run' && c.target === 'f'); + + expect(fCalls.length).toBe(1); + const target = result.graph.getNode(fCalls[0].rel.targetId); + expect(target?.properties.parameterTypes).toEqual(['double']); + }); + + it('g(42) keeps a single constructor UDC viable when no standard conversion overload exists', () => { + const calls = getRelationships(result, 'CALLS'); + const gCalls = calls.filter((c) => c.source === 'run' && c.target === 'g'); + + expect(gCalls.length).toBe(1); + const target = result.graph.getNode(gCalls[0].rel.targetId); + expect(target?.properties.parameterTypes).toEqual(['Wrap']); + }); + + it('h(42) emits zero CALLS edges when two single-step constructor UDCs tie', () => { + const calls = getRelationships(result, 'CALLS'); + const hCalls = calls.filter((c) => c.source === 'run' && c.target === 'h'); + + expect(hCalls.length).toBe(0); + }); + + it('e(42) ignores the explicit-constructor overload and keeps the implicit UDC viable', () => { + const calls = getRelationships(result, 'CALLS'); + const eCalls = calls.filter((c) => c.source === 'run' && c.target === 'e'); + + expect(eCalls.length).toBe(1); + const target = result.graph.getNode(eCalls[0].rel.targetId); + expect(target?.properties.parameterTypes).toEqual(['Wrap']); + }); +}); + +describe('C++ overload resolution — UDC namespace collision guard (#1631)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-overload-udc-namespace-collision'), + () => {}, + ); + }, 60000); + + it('does not let beta::Token(int) tie the valid alpha::Other(int) conversion', () => { + const calls = getRelationships(result, 'CALLS'); + const fCalls = calls.filter((c) => c.source === 'run' && c.target === 'f'); + + expect(fCalls.length).toBe(1); + const target = result.graph.getNode(fCalls[0].rel.targetId); + expect(target?.properties.parameterTypes).toEqual(['Other']); + }); +}); + // --------------------------------------------------------------------------- // U3: anonymous-namespace symbols MUST NOT leak across translation units // (full-pipeline integration test; unit-level coverage exists separately) diff --git a/gitnexus/test/integration/resolvers/fastapi-depends.test.ts b/gitnexus/test/integration/resolvers/fastapi-depends.test.ts new file mode 100644 index 000000000..93394528e --- /dev/null +++ b/gitnexus/test/integration/resolvers/fastapi-depends.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { FIXTURES, getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js'; + +describe('FastAPI Depends() CALLS edge extraction', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'fastapi-depends'), () => {}); + }, 60000); + + it('emits CALLS edges from route handlers to get_current_user_record via Depends()', () => { + const edges = getRelationships(result, 'CALLS'); + const dependsEdges = edges.filter((e) => e.target === 'get_current_user_record'); + expect(dependsEdges.length).toBe(2); + const sources = dependsEdges.map((e) => e.source).sort(); + expect(sources).toContain('list_calls'); + expect(sources).toContain('get_user'); + }); + + it('emits CALLS edges from route handlers to get_db via Depends()', () => { + const edges = getRelationships(result, 'CALLS'); + const dependsEdges = edges.filter((e) => e.target === 'get_db'); + expect(dependsEdges.length).toBe(2); + const sources = dependsEdges.map((e) => e.source).sort(); + expect(sources).toContain('list_calls'); + expect(sources).toContain('create_user'); + }); + + it('traces typed default parameter: user: User = Depends(get_current_user_record)', () => { + const edges = getRelationships(result, 'CALLS'); + const edge = edges.find( + (e) => e.target === 'get_current_user_record' && e.sourceFilePath.includes('calls.py'), + ); + expect(edge).toBeDefined(); + }); + + it('traces untyped default parameter: db=Depends(get_db)', () => { + const edges = getRelationships(result, 'CALLS'); + const edge = edges.find((e) => e.target === 'get_db' && e.sourceFilePath.includes('users.py')); + expect(edge).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/fetch-wrapper-consumers.test.ts b/gitnexus/test/integration/resolvers/fetch-wrapper-consumers.test.ts new file mode 100644 index 000000000..12d3a7b69 --- /dev/null +++ b/gitnexus/test/integration/resolvers/fetch-wrapper-consumers.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { + FIXTURES, + getRelationships, + getNodesByLabel, + runPipelineFromRepo, + type PipelineResult, +} from './helpers.js'; + +describe('Fetch wrapper consumer FETCHES edge extraction', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'fetch-wrapper-consumers'), () => {}); + }, 60000); + + it('creates Route nodes for API endpoints', () => { + const routes = getNodesByLabel(result, 'Route'); + expect(routes).toContain('/api/grants'); + expect(routes).toContain('/api/users'); + }); + + it('creates FETCHES edge from GrantsList via apiFetch wrapper', () => { + const edges = getRelationships(result, 'FETCHES'); + const grantsEdge = edges.find( + (e) => e.sourceFilePath.includes('GrantsList') && e.target === '/api/grants', + ); + expect(grantsEdge).toBeDefined(); + }); + + it('creates FETCHES edge from UserList via apiFetch wrapper', () => { + const edges = getRelationships(result, 'FETCHES'); + const usersEdge = edges.find( + (e) => e.sourceFilePath.includes('UserList') && e.target === '/api/users', + ); + expect(usersEdge).toBeDefined(); + }); + + it('produces the correct total number of FETCHES edges', () => { + const edges = getRelationships(result, 'FETCHES'); + expect(edges.length).toBe(2); + }); +}); diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index 26c586ad1..bd17483ba 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -200,6 +200,12 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly([ + // Ruby scope-resolution currently achieves 89/127 parity. + // Tests listed here are scope-resolver-only correctness wins + // (pass under registry-primary, fail under legacy). Currently + // empty — all 127 tests pass under legacy mode. + ]), cpp: new Set([ // The legacy DAG path has no scope-aware filtering on the global // free-call fallback, so `#include`d headers still leak class @@ -307,6 +313,14 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly` overloads // guarded by mutually-exclusive `enable_if_t` predicates collapse diff --git a/gitnexus/test/integration/resolvers/ruby-scope.test.ts b/gitnexus/test/integration/resolvers/ruby-scope.test.ts new file mode 100644 index 000000000..ef44acf6a --- /dev/null +++ b/gitnexus/test/integration/resolvers/ruby-scope.test.ts @@ -0,0 +1,481 @@ +/** + * Ruby scope-resolution integration tests (U8). + * + * These tests run with REGISTRY_PRIMARY_RUBY=true to exercise the + * scope-based resolution path. They validate class methods, module mixins, + * singleton methods, require_relative imports, constructor inference, + * block scope, class inheritance, and super 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_RUBY']; + process.env['REGISTRY_PRIMARY_RUBY'] = 'true'; +}); + +afterAll(() => { + if (savedEnv === undefined) delete process.env['REGISTRY_PRIMARY_RUBY']; + else process.env['REGISTRY_PRIMARY_RUBY'] = savedEnv; +}); + +// --------------------------------------------------------------------------- +// 1. Basic class method resolution +// --------------------------------------------------------------------------- + +describe('Ruby scope: basic class method resolution', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-basic-')); + writeFixtureRepo(tmpDir, { + 'models/user.rb': ` +class User + def save + true + end + + def greet + "hello" + end +end +`, + 'app.rb': ` +require_relative 'models/user' + +def main + u = User.new + u.save + u.greet +end +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects User class', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + }); + + it('detects save and greet as Method nodes', () => { + const methods = getNodesByLabel(result, 'Method'); + expect(methods).toContain('save'); + expect(methods).toContain('greet'); + }); + + it('emits HAS_METHOD edges from User to save and greet', () => { + const edges = getRelationships(result, 'HAS_METHOD'); + const userSave = edges.find((e) => e.source === 'User' && e.target === 'save'); + const userGreet = edges.find((e) => e.source === 'User' && e.target === 'greet'); + expect(userSave).toBeDefined(); + expect(userGreet).toBeDefined(); + }); + + it('resolves main → u.save() as CALLS edge', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => c.target === 'save' && c.source === 'main' && c.targetFilePath?.includes('user.rb'), + ); + expect(saveCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 2. Module mixin with include +// --------------------------------------------------------------------------- + +describe('Ruby scope: module mixin with include', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-mixin-')); + writeFixtureRepo(tmpDir, { + 'serializable.rb': ` +module Serializable + def serialize + to_json + end +end +`, + 'user.rb': ` +require_relative 'serializable' + +class User + include Serializable + + def save + serialize + end +end +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects User as Class and Serializable as Trait', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Trait')).toContain('Serializable'); + }); + + it('emits IMPLEMENTS edge from User to Serializable', () => { + const impls = getRelationships(result, 'IMPLEMENTS'); + const edge = impls.find((e) => e.source === 'User' && e.target === 'Serializable'); + expect(edge).toBeDefined(); + }); + + it('resolves save → serialize as CALLS edge', () => { + const calls = getRelationships(result, 'CALLS'); + const serializeCall = calls.find((c) => c.target === 'serialize' && c.source === 'save'); + expect(serializeCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 3. Singleton method (def self.foo) +// --------------------------------------------------------------------------- + +describe('Ruby scope: singleton method (def self.foo)', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-singleton-')); + writeFixtureRepo(tmpDir, { + 'config.rb': ` +class Config + def self.load + new + end + + def validate + true + end +end +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects Config class', () => { + expect(getNodesByLabel(result, 'Class')).toContain('Config'); + }); + + it('detects load (singleton) and validate (instance) as Method nodes', () => { + const methods = getNodesByLabel(result, 'Method'); + expect(methods).toContain('load'); + expect(methods).toContain('validate'); + }); + + it('emits HAS_METHOD edges from Config to both methods', () => { + const edges = getRelationships(result, 'HAS_METHOD'); + const configLoad = edges.find((e) => e.source === 'Config' && e.target === 'load'); + const configValidate = edges.find((e) => e.source === 'Config' && e.target === 'validate'); + expect(configLoad).toBeDefined(); + expect(configValidate).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 4. Require/require_relative import resolution +// --------------------------------------------------------------------------- + +describe('Ruby scope: require_relative import resolution', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-imports-')); + writeFixtureRepo(tmpDir, { + 'lib/utils.rb': ` +class Utils + def format(text) + text.strip + end +end +`, + 'app.rb': ` +require_relative 'lib/utils' + +def run + u = Utils.new + u.format("hello") +end +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('emits IMPORTS edge from app.rb to lib/utils.rb', () => { + const imports = getRelationships(result, 'IMPORTS'); + const imp = imports.find( + (e) => e.sourceFilePath?.includes('app.rb') && e.targetFilePath?.includes('utils.rb'), + ); + expect(imp).toBeDefined(); + }); + + it('resolves run → u.format() as CALLS edge to utils.rb', () => { + const calls = getRelationships(result, 'CALLS'); + const formatCall = calls.find( + (c) => c.target === 'format' && c.source === 'run' && c.targetFilePath?.includes('utils.rb'), + ); + expect(formatCall).toBeDefined(); + }); + + it('detects Utils class and format method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('Utils'); + expect(getNodesByLabel(result, 'Method')).toContain('format'); + }); +}); + +// --------------------------------------------------------------------------- +// 5. Constructor inference (User.new) +// --------------------------------------------------------------------------- + +describe('Ruby scope: constructor inference via .new', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-ctor-')); + writeFixtureRepo(tmpDir, { + 'formatter.rb': ` +class Formatter + def format(text) + text.upcase + end +end + +def main + f = Formatter.new + f.format("hello") +end +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects Formatter class and format method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('Formatter'); + expect(getNodesByLabel(result, 'Method')).toContain('format'); + }); + + it('emits HAS_METHOD edge from Formatter to format', () => { + const edges = getRelationships(result, 'HAS_METHOD'); + const fmtEdge = edges.find((e) => e.source === 'Formatter' && e.target === 'format'); + expect(fmtEdge).toBeDefined(); + }); + + it('resolves main → f.format() to Formatter#format via constructor inference', () => { + const calls = getRelationships(result, 'CALLS'); + const formatCall = calls.find( + (c) => + c.target === 'format' && c.source === 'main' && c.targetFilePath?.includes('formatter.rb'), + ); + expect(formatCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 6. Block scope (do...end with params) +// --------------------------------------------------------------------------- + +describe('Ruby scope: block scope with do...end', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-block-')); + writeFixtureRepo(tmpDir, { + 'processor.rb': ` +class Processor + def run + items = [1, 2, 3] + items.each do |item| + process(item) + end + end + + def process(x) + x * 2 + end +end +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects Processor class', () => { + expect(getNodesByLabel(result, 'Class')).toContain('Processor'); + }); + + it('detects run and process as Method nodes on Processor', () => { + const methods = getNodesByLabel(result, 'Method'); + expect(methods).toContain('run'); + expect(methods).toContain('process'); + }); + + it('emits HAS_METHOD edges from Processor to run and process', () => { + const edges = getRelationships(result, 'HAS_METHOD'); + const procRun = edges.find((e) => e.source === 'Processor' && e.target === 'run'); + const procProcess = edges.find((e) => e.source === 'Processor' && e.target === 'process'); + expect(procRun).toBeDefined(); + expect(procProcess).toBeDefined(); + }); + + it('resolves run → process() as CALLS edge inside block scope', () => { + const calls = getRelationships(result, 'CALLS'); + const processCall = calls.find((c) => c.target === 'process' && c.source === 'run'); + expect(processCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 7. Class inheritance (EXTENDS) +// --------------------------------------------------------------------------- + +describe('Ruby scope: class inheritance via <', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-inherit-')); + writeFixtureRepo(tmpDir, { + 'animals.rb': ` +class Animal + def speak + "..." + end +end + +class Dog < Animal + def bark + speak + end +end +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects Animal and Dog as Class nodes', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('Animal'); + expect(classes).toContain('Dog'); + }); + + it('emits EXTENDS edge from Dog to Animal', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + const edge = extends_.find((e) => e.source === 'Dog' && e.target === 'Animal'); + expect(edge).toBeDefined(); + }); + + it('resolves bark → speak as CALLS edge', () => { + const calls = getRelationships(result, 'CALLS'); + const speakCall = calls.find((c) => c.target === 'speak' && c.source === 'bark'); + expect(speakCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 8. Super resolution +// --------------------------------------------------------------------------- + +describe('Ruby scope: super resolution in subclass', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-super-')); + writeFixtureRepo(tmpDir, { + 'hierarchy.rb': ` +class Base + def greet + "hello" + end +end + +class Child < Base + def greet + super + end +end + +def main + c = Child.new + c.greet +end +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects Base and Child as Class nodes', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('Base'); + expect(classes).toContain('Child'); + }); + + it('emits EXTENDS edge from Child to Base', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + const edge = extends_.find((e) => e.source === 'Child' && e.target === 'Base'); + expect(edge).toBeDefined(); + }); + + it('resolves main → c.greet() as CALLS edge', () => { + const calls = getRelationships(result, 'CALLS'); + const greetCall = calls.find((c) => c.target === 'greet' && c.source === 'main'); + expect(greetCall).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/ruby-sequential-mixin.test.ts b/gitnexus/test/integration/resolvers/ruby-sequential-mixin.test.ts index c3e105a15..36957fc09 100644 --- a/gitnexus/test/integration/resolvers/ruby-sequential-mixin.test.ts +++ b/gitnexus/test/integration/resolvers/ruby-sequential-mixin.test.ts @@ -102,8 +102,15 @@ function resolvedMethodOwners( describe('Ruby mixin heritage: sequential vs worker parity', () => { let sequential: PipelineResult; let workers: PipelineResult; + let savedEnv: string | undefined; beforeAll(async () => { + // Force legacy mode — these tests exercise inferImplicitReceiver + + // selectDispatch hooks which live in the legacy call-processor. + // With Ruby in MIGRATED_LANGUAGES the call-processor is gated off + // under registry-primary, so pin legacy for this suite. + savedEnv = process.env['REGISTRY_PRIMARY_RUBY']; + process.env['REGISTRY_PRIMARY_RUBY'] = '0'; sequential = await runMode({ skipWorkers: true }); // Force the worker pool to spawn even though the fixture is tiny. // Without this override, the pipeline's MIN_FILES_FOR_WORKERS / MIN_BYTES_FOR_WORKERS @@ -115,6 +122,11 @@ describe('Ruby mixin heritage: sequential vs worker parity', () => { }); }, 120000); + afterAll(() => { + if (savedEnv === undefined) delete process.env['REGISTRY_PRIMARY_RUBY']; + else process.env['REGISTRY_PRIMARY_RUBY'] = savedEnv; + }); + it('exercises both pipeline paths (sequential and worker)', () => { // If either of these assertions fails, every downstream parity check // below is meaningless — both modes would be running the same path. diff --git a/gitnexus/test/integration/resolvers/ruby.test.ts b/gitnexus/test/integration/resolvers/ruby.test.ts index 82806aac5..c60fe25b1 100644 --- a/gitnexus/test/integration/resolvers/ruby.test.ts +++ b/gitnexus/test/integration/resolvers/ruby.test.ts @@ -3,9 +3,10 @@ * calls, member calls, ambiguous disambiguation, local shadow, * constructor-inferred type resolution */ -import { describe, it, expect, beforeAll } from 'vitest'; +import { describe, expect, beforeAll } from 'vitest'; import path from 'path'; import { + createResolverParityIt, FIXTURES, CROSS_FILE_FIXTURES, getRelationships, @@ -16,6 +17,8 @@ import { type PipelineResult, } from './helpers.js'; +const pit = createResolverParityIt('ruby'); + // --------------------------------------------------------------------------- // Heritage: require_relative imports + include heritage + attr_* properties + calls // --------------------------------------------------------------------------- @@ -29,11 +32,11 @@ describe('Ruby require_relative, heritage & property resolution', () => { // --- Node detection --- - it('detects 3 classes', () => { + pit('detects 3 classes', () => { expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'User', 'UserService']); }); - it('detects 3 modules (labeled as Trait for class-like registry lookup)', () => { + pit('detects 3 modules (labeled as Trait for class-like registry lookup)', () => { // Ruby `module` declarations are relabeled to `Trait` during ingestion so // they participate in `lookupClassByName` and `buildHeritageMap`. This is // the single source of truth for Ruby module detection in the graph. @@ -41,7 +44,7 @@ describe('Ruby require_relative, heritage & property resolution', () => { expect(getNodesByLabel(result, 'Module')).toEqual([]); }); - it('detects methods on classes and modules', () => { + pit('detects methods on classes and modules', () => { const methods = getNodesByLabel(result, 'Method'); expect(methods).toContain('persist'); expect(methods).toContain('run_validations'); @@ -50,12 +53,12 @@ describe('Ruby require_relative, heritage & property resolution', () => { expect(methods).toContain('create_user'); }); - it('detects singleton method (def self.factory) as Method', () => { + pit('detects singleton method (def self.factory) as Method', () => { const methods = getNodesByLabel(result, 'Method'); expect(methods).toContain('factory'); }); - it('emits CALLS from singleton method: factory → run_validations', () => { + pit('emits CALLS from singleton method: factory → run_validations', () => { const calls = getRelationships(result, 'CALLS').filter( (e) => e.source === 'factory' && e.target === 'run_validations', ); @@ -65,7 +68,7 @@ describe('Ruby require_relative, heritage & property resolution', () => { // --- Import resolution via require_relative --- - it('resolves 5 require_relative imports to IMPORTS edges', () => { + pit('resolves 5 require_relative imports to IMPORTS edges', () => { const imports = getRelationships(result, 'IMPORTS'); const importEdges = edgeSet(imports); expect(importEdges).toContain('user.rb → base_model.rb'); @@ -75,7 +78,7 @@ describe('Ruby require_relative, heritage & property resolution', () => { expect(importEdges).toContain('service.rb → user.rb'); }); - it('resolves bare require to IMPORTS edge', () => { + pit('resolves bare require to IMPORTS edge', () => { const imports = getRelationships(result, 'IMPORTS'); const bareRequire = imports.find( (e) => @@ -86,21 +89,21 @@ describe('Ruby require_relative, heritage & property resolution', () => { // --- Heritage: include → IMPLEMENTS --- - it('emits IMPLEMENTS edge for include Serializable with reason "include"', () => { + pit('emits IMPLEMENTS edge for include Serializable with reason "include"', () => { const implements_ = getRelationships(result, 'IMPLEMENTS'); const edge = implements_.find((e) => e.source === 'User' && e.target === 'Serializable'); expect(edge).toBeDefined(); expect(edge!.rel.reason).toBe('include'); }); - it('emits IMPLEMENTS edge for extend Loggable with reason "extend"', () => { + pit('emits IMPLEMENTS edge for extend Loggable with reason "extend"', () => { const implements_ = getRelationships(result, 'IMPLEMENTS'); const edge = implements_.find((e) => e.source === 'User' && e.target === 'Loggable'); expect(edge).toBeDefined(); expect(edge!.rel.reason).toBe('extend'); }); - it('emits IMPLEMENTS edge for prepend Cacheable with reason "prepend"', () => { + pit('emits IMPLEMENTS edge for prepend Cacheable with reason "prepend"', () => { const implements_ = getRelationships(result, 'IMPLEMENTS'); const edge = implements_.find((e) => e.source === 'User' && e.target === 'Cacheable'); expect(edge).toBeDefined(); @@ -109,7 +112,7 @@ describe('Ruby require_relative, heritage & property resolution', () => { // --- Extends: class inheritance --- - it('emits EXTENDS edge: User → BaseModel', () => { + pit('emits EXTENDS edge: User → BaseModel', () => { const extends_ = getRelationships(result, 'EXTENDS'); expect(extends_.length).toBe(1); const edges = edgeSet(extends_); @@ -118,25 +121,25 @@ describe('Ruby require_relative, heritage & property resolution', () => { // --- Property nodes: attr_accessor, attr_reader, attr_writer --- - it('creates Property nodes for attr_accessor :id and :created_at', () => { + pit('creates Property nodes for attr_accessor :id and :created_at', () => { const props = getNodesByLabel(result, 'Property'); expect(props).toContain('id'); expect(props).toContain('created_at'); }); - it('creates Property nodes for attr_reader :name and attr_writer :email', () => { + pit('creates Property nodes for attr_reader :name and attr_writer :email', () => { const props = getNodesByLabel(result, 'Property'); expect(props).toContain('name'); expect(props).toContain('email'); }); - it('emits HAS_PROPERTY from User to attr_reader :name', () => { + pit('emits HAS_PROPERTY from User to attr_reader :name', () => { const hasProperty = getRelationships(result, 'HAS_PROPERTY'); const edge = hasProperty.find((e) => e.source === 'User' && e.target === 'name'); expect(edge).toBeDefined(); }); - it('emits HAS_PROPERTY from BaseModel to attr_accessor :id', () => { + pit('emits HAS_PROPERTY from BaseModel to attr_accessor :id', () => { const hasProperty = getRelationships(result, 'HAS_PROPERTY'); const edge = hasProperty.find((e) => e.source === 'BaseModel' && e.target === 'id'); expect(edge).toBeDefined(); @@ -144,7 +147,7 @@ describe('Ruby require_relative, heritage & property resolution', () => { // --- Call resolution: method-level attribution --- - it('emits method-level CALLS: create_user → persist (member call)', () => { + pit('emits method-level CALLS: create_user → persist (member call)', () => { const calls = getRelationships(result, 'CALLS').filter( (e) => e.source === 'create_user' && e.target === 'persist', ); @@ -153,7 +156,7 @@ describe('Ruby require_relative, heritage & property resolution', () => { expect(calls[0].targetLabel).toBe('Method'); }); - it('emits method-level CALLS: create_user → greet_user (member call)', () => { + pit('emits method-level CALLS: create_user → greet_user (member call)', () => { const calls = getRelationships(result, 'CALLS').filter( (e) => e.source === 'create_user' && e.target === 'greet_user', ); @@ -162,21 +165,21 @@ describe('Ruby require_relative, heritage & property resolution', () => { expect(calls[0].targetLabel).toBe('Method'); }); - it('emits method-level CALLS: greet_user → persist (bare call)', () => { + pit('emits method-level CALLS: greet_user → persist (bare call)', () => { const calls = getRelationships(result, 'CALLS').filter( (e) => e.source === 'greet_user' && e.target === 'persist', ); expect(calls.length).toBe(1); }); - it('emits method-level CALLS: greet_user → serialize_data (bare call)', () => { + pit('emits method-level CALLS: greet_user → serialize_data (bare call)', () => { const calls = getRelationships(result, 'CALLS').filter( (e) => e.source === 'greet_user' && e.target === 'serialize_data', ); expect(calls.length).toBe(1); }); - it('emits method-level CALLS: persist → run_validations (bare call)', () => { + pit('emits method-level CALLS: persist → run_validations (bare call)', () => { const calls = getRelationships(result, 'CALLS').filter( (e) => e.source === 'persist' && e.target === 'run_validations', ); @@ -185,7 +188,7 @@ describe('Ruby require_relative, heritage & property resolution', () => { // --- Heritage edges point to real graph nodes --- - it('all heritage edges point to real graph nodes', () => { + pit('all heritage edges point to real graph nodes', () => { for (const edge of [ ...getRelationships(result, 'EXTENDS'), ...getRelationships(result, 'IMPLEMENTS'), @@ -197,7 +200,7 @@ describe('Ruby require_relative, heritage & property resolution', () => { // --- No OVERRIDES edges target Property nodes --- - it('no OVERRIDES edges target Property nodes', () => { + pit('no OVERRIDES edges target Property nodes', () => { const overrides = getRelationships(result, 'METHOD_OVERRIDES'); for (const edge of overrides) { const target = result.graph.getNode(edge.rel.targetId); @@ -218,7 +221,7 @@ describe('Ruby call resolution with arity filtering', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-calls'), () => {}); }, 60000); - it('resolves run_task → write_audit to one_arg.rb via arity narrowing', () => { + pit('resolves run_task → write_audit to one_arg.rb via arity narrowing', () => { const calls = getRelationships(result, 'CALLS'); const auditCall = calls.find((c) => c.target === 'write_audit'); expect(auditCall).toBeDefined(); @@ -239,7 +242,7 @@ describe('Ruby member-call resolution', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-member-calls'), () => {}); }, 60000); - it('resolves process_user → persist_record as a member call on User', () => { + pit('resolves process_user → persist_record as a member call on User', () => { const calls = getRelationships(result, 'CALLS'); const saveCall = calls.find((c) => c.target === 'persist_record'); expect(saveCall).toBeDefined(); @@ -247,12 +250,12 @@ describe('Ruby member-call resolution', () => { expect(saveCall!.targetFilePath).toContain('user.rb'); }); - it('detects User class and persist_record method', () => { + pit('detects User class and persist_record method', () => { expect(getNodesByLabel(result, 'Class')).toContain('User'); expect(getNodesByLabel(result, 'Method')).toContain('persist_record'); }); - it('emits HAS_METHOD edge from User to persist_record', () => { + pit('emits HAS_METHOD edge from User to persist_record', () => { const hasMethod = getRelationships(result, 'HAS_METHOD'); const edge = hasMethod.find((e) => e.source === 'User' && e.target === 'persist_record'); expect(edge).toBeDefined(); @@ -266,7 +269,7 @@ describe('Ruby qualified class names', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-qualified-types'), () => {}); }, 60000); - it('stores distinct qualified names for same-named classes across modules', () => { + pit('stores distinct qualified names for same-named classes across modules', () => { const users = getNodesByLabelFull(result, 'Class').filter((node) => node.name === 'User'); expect(users).toHaveLength(2); expect(users.map((node) => node.properties.qualifiedName).sort()).toEqual([ @@ -287,13 +290,13 @@ describe('Ruby ambiguous symbol resolution', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-ambiguous'), () => {}); }, 60000); - it('detects 2 Handler classes', () => { + pit('detects 2 Handler classes', () => { const classes = getNodesByLabel(result, 'Class'); expect(classes.filter((n) => n === 'Handler').length).toBe(2); expect(classes).toContain('UserHandler'); }); - it('resolves EXTENDS to models/handler.rb (not other/handler.rb)', () => { + pit('resolves EXTENDS to models/handler.rb (not other/handler.rb)', () => { const extends_ = getRelationships(result, 'EXTENDS'); expect(extends_.length).toBe(1); expect(extends_[0].source).toBe('UserHandler'); @@ -301,13 +304,13 @@ describe('Ruby ambiguous symbol resolution', () => { expect(extends_[0].targetFilePath).toBe('models/handler.rb'); }); - it('import edge points to models/ not other/', () => { + pit('import edge points to models/ not other/', () => { const imports = getRelationships(result, 'IMPORTS'); expect(imports.length).toBe(1); expect(imports[0].targetFilePath).toBe('models/handler.rb'); }); - it('all heritage edges point to real graph nodes', () => { + pit('all heritage edges point to real graph nodes', () => { for (const edge of getRelationships(result, 'EXTENDS')) { const target = result.graph.getNode(edge.rel.targetId); expect(target).toBeDefined(); @@ -326,7 +329,7 @@ describe('Ruby local definition shadows import', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-local-shadow'), () => {}); }, 60000); - it('resolves run_app → do_work to same-file definition, not the imported one', () => { + pit('resolves run_app → do_work to same-file definition, not the imported one', () => { const calls = getRelationships(result, 'CALLS'); const doWorkCall = calls.find((c) => c.target === 'do_work' && c.source === 'run_app'); expect(doWorkCall).toBeDefined(); @@ -348,19 +351,19 @@ describe('Ruby constructor-inferred type resolution', () => { ); }, 60000); - it('detects User, Repo, and AppService classes', () => { + pit('detects User, Repo, and AppService classes', () => { expect(getNodesByLabel(result, 'Class')).toContain('User'); expect(getNodesByLabel(result, 'Class')).toContain('Repo'); expect(getNodesByLabel(result, 'Class')).toContain('AppService'); }); - it('detects save on User and Repo, cleanup on all three', () => { + pit('detects save on User and Repo, cleanup on all three', () => { const methods = getNodesByLabel(result, 'Method'); expect(methods.filter((m) => m === 'save').length).toBe(2); expect(methods.filter((m) => m === 'cleanup').length).toBe(3); }); - it('resolves user.save to models/user.rb via constructor-inferred type', () => { + pit('resolves user.save to models/user.rb via constructor-inferred type', () => { const calls = getRelationships(result, 'CALLS'); const userSave = calls.find( (c) => c.target === 'save' && c.targetFilePath === 'models/user.rb', @@ -369,7 +372,7 @@ describe('Ruby constructor-inferred type resolution', () => { expect(userSave!.source).toBe('process_entities'); }); - it('resolves repo.save to models/repo.rb via constructor-inferred type', () => { + pit('resolves repo.save to models/repo.rb via constructor-inferred type', () => { const calls = getRelationships(result, 'CALLS'); const repoSave = calls.find( (c) => c.target === 'save' && c.targetFilePath === 'models/repo.rb', @@ -378,20 +381,20 @@ describe('Ruby constructor-inferred type resolution', () => { expect(repoSave!.source).toBe('process_entities'); }); - it('emits exactly 2 save CALLS edges (one per receiver type)', () => { + pit('emits exactly 2 save CALLS edges (one per receiver type)', () => { const calls = getRelationships(result, 'CALLS'); const saveCalls = calls.filter((c) => c.target === 'save'); expect(saveCalls.length).toBe(2); }); - it('resolves self.process_entities to services/app.rb (unique method)', () => { + pit('resolves self.process_entities to services/app.rb (unique method)', () => { const calls = getRelationships(result, 'CALLS'); const selfCall = calls.find((c) => c.source === 'greet' && c.target === 'process_entities'); expect(selfCall).toBeDefined(); expect(selfCall!.targetFilePath).toContain('app.rb'); }); - it('resolves self.cleanup to services/app.rb, not models/user.rb or models/repo.rb', () => { + pit('resolves self.cleanup to services/app.rb, not models/user.rb or models/repo.rb', () => { const calls = getRelationships(result, 'CALLS'); const selfCleanup = calls.find((c) => c.source === 'greet' && c.target === 'cleanup'); expect(selfCleanup).toBeDefined(); @@ -410,13 +413,13 @@ describe('Ruby self resolution', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-self-this-resolution'), () => {}); }, 60000); - it('detects User and Repo classes, each with a save method', () => { + pit('detects User and Repo classes, each with a save method', () => { expect(getNodesByLabel(result, 'Class')).toEqual(['Repo', 'User']); const saveMethods = getNodesByLabel(result, 'Method').filter((m) => m === 'save'); expect(saveMethods.length).toBe(2); }); - it('resolves self.save inside User#process to User#save, not Repo#save', () => { + pit('resolves self.save inside User#process to User#save, not Repo#save', () => { const calls = getRelationships(result, 'CALLS'); const saveCall = calls.find((c) => c.target === 'save' && c.source === 'process'); expect(saveCall).toBeDefined(); @@ -435,20 +438,20 @@ describe('Ruby parent resolution', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-parent-resolution'), () => {}); }, 60000); - it('detects BaseModel and User classes plus Serializable module (Trait)', () => { + pit('detects BaseModel and User classes plus Serializable module (Trait)', () => { expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'User']); // Ruby modules are labeled Trait — see the "detects 3 modules" test above. expect(getNodesByLabel(result, 'Trait')).toEqual(['Serializable']); }); - it('emits EXTENDS edge: User < BaseModel', () => { + pit('emits EXTENDS edge: User < BaseModel', () => { const extends_ = getRelationships(result, 'EXTENDS'); expect(extends_.length).toBe(1); expect(extends_[0].source).toBe('User'); expect(extends_[0].target).toBe('BaseModel'); }); - it('emits IMPLEMENTS edge: User includes Serializable', () => { + pit('emits IMPLEMENTS edge: User includes Serializable', () => { const implements_ = getRelationships(result, 'IMPLEMENTS'); const includeEdge = implements_.find((e) => e.source === 'User' && e.target === 'Serializable'); expect(includeEdge).toBeDefined(); @@ -467,18 +470,18 @@ describe('Ruby super resolution', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-super-resolution'), () => {}); }, 60000); - it('detects BaseModel, User, and Repo classes', () => { + pit('detects BaseModel, User, and Repo classes', () => { expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'Repo', 'User']); }); - it('emits EXTENDS edge: User < BaseModel', () => { + pit('emits EXTENDS edge: User < BaseModel', () => { const extends_ = getRelationships(result, 'EXTENDS'); expect(extends_.length).toBe(1); expect(extends_[0].source).toBe('User'); expect(extends_[0].target).toBe('BaseModel'); }); - it('detects save methods on all three classes', () => { + pit('detects save methods on all three classes', () => { const saveMethods = getNodesByLabel(result, 'Method').filter((m) => m === 'save'); expect(saveMethods.length).toBe(3); }); @@ -495,13 +498,13 @@ describe('Ruby constant constructor binding resolution', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-constant-constructor'), () => {}); }, 60000); - it('detects UserService class with process and validate methods', () => { + pit('detects UserService class with process and validate methods', () => { expect(getNodesByLabel(result, 'Class')).toContain('UserService'); expect(getNodesByLabel(result, 'Method')).toContain('process'); expect(getNodesByLabel(result, 'Method')).toContain('validate'); }); - it('resolves SERVICE.process() via constant constructor binding', () => { + pit('resolves SERVICE.process() via constant constructor binding', () => { const calls = getRelationships(result, 'CALLS'); const processCall = calls.find( (c) => c.target === 'process' && c.targetFilePath === 'models.rb', @@ -509,7 +512,7 @@ describe('Ruby constant constructor binding resolution', () => { expect(processCall).toBeDefined(); }); - it('resolves SERVICE.validate() via constant constructor binding', () => { + pit('resolves SERVICE.validate() via constant constructor binding', () => { const calls = getRelationships(result, 'CALLS'); const validateCall = calls.find( (c) => c.target === 'validate' && c.targetFilePath === 'models.rb', @@ -529,13 +532,13 @@ describe('Ruby YARD annotation type resolution', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-yard-annotations'), () => {}); }, 60000); - it('detects UserRepo, User, and UserService classes', () => { + pit('detects UserRepo, User, and UserService classes', () => { expect(getNodesByLabel(result, 'Class')).toContain('UserRepo'); expect(getNodesByLabel(result, 'Class')).toContain('User'); expect(getNodesByLabel(result, 'Class')).toContain('UserService'); }); - it('detects save, find_by_name, greet, and create methods', () => { + pit('detects save, find_by_name, greet, and create methods', () => { const methods = getNodesByLabel(result, 'Method'); expect(methods).toContain('save'); expect(methods).toContain('find_by_name'); @@ -543,14 +546,14 @@ describe('Ruby YARD annotation type resolution', () => { expect(methods).toContain('create'); }); - it('resolves repo.save to UserRepo#save via YARD @param annotation', () => { + pit('resolves repo.save to UserRepo#save via YARD @param annotation', () => { const calls = getRelationships(result, 'CALLS'); const saveCall = calls.find((c) => c.target === 'save' && c.source === 'create'); expect(saveCall).toBeDefined(); expect(saveCall!.targetFilePath).toContain('models.rb'); }); - it('resolves user.greet to User#greet via YARD @param annotation', () => { + pit('resolves user.greet to User#greet via YARD @param annotation', () => { const calls = getRelationships(result, 'CALLS'); const greetCall = calls.find((c) => c.target === 'greet' && c.source === 'create'); expect(greetCall).toBeDefined(); @@ -573,14 +576,14 @@ describe('Ruby namespaced constructor resolution (Models::UserService.new)', () ); }, 60000); - it('detects UserService class with process and validate methods', () => { + pit('detects UserService class with process and validate methods', () => { expect(getNodesByLabel(result, 'Class')).toContain('UserService'); const methods = getNodesByLabel(result, 'Method'); expect(methods).toContain('process'); expect(methods).toContain('validate'); }); - it('resolves svc.process() via namespaced constructor Models::UserService.new', () => { + pit('resolves svc.process() via namespaced constructor Models::UserService.new', () => { const calls = getRelationships(result, 'CALLS'); const processCall = calls.find( (c) => c.target === 'process' && c.targetFilePath.includes('user_service.rb'), @@ -588,7 +591,7 @@ describe('Ruby namespaced constructor resolution (Models::UserService.new)', () expect(processCall).toBeDefined(); }); - it('resolves svc.validate() via namespaced constructor Models::UserService.new', () => { + pit('resolves svc.validate() via namespaced constructor Models::UserService.new', () => { const calls = getRelationships(result, 'CALLS'); const validateCall = calls.find( (c) => c.target === 'validate' && c.targetFilePath.includes('user_service.rb'), @@ -613,23 +616,23 @@ describe('Ruby return type inference via function call', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-return-type'), () => {}); }, 60000); - it('detects User and Repo classes', () => { + pit('detects User and Repo classes', () => { expect(getNodesByLabel(result, 'Class')).toContain('User'); expect(getNodesByLabel(result, 'Class')).toContain('Repo'); }); - it('detects get_user and get_repo methods', () => { + pit('detects get_user and get_repo methods', () => { expect(getNodesByLabel(result, 'Method')).toContain('get_user'); expect(getNodesByLabel(result, 'Method')).toContain('get_repo'); }); - it('detects save method on both User and Repo (disambiguation required)', () => { + pit('detects save method on both User and Repo (disambiguation required)', () => { const methods = getNodesByLabel(result, 'Method'); // Both classes have save — fuzzy match alone cannot resolve this expect(methods.filter((m) => m === 'save').length).toBe(2); }); - it('resolves user.save to User#save via YARD @return [User] on get_user()', () => { + pit('resolves user.save to User#save via YARD @return [User] on get_user()', () => { // With both User#save and Repo#save in scope, resolving user.save // requires return type inference: get_user() → @return [User] → user is User const calls = getRelationships(result, 'CALLS'); @@ -642,7 +645,7 @@ describe('Ruby return type inference via function call', () => { expect(saveCall).toBeDefined(); }); - it('resolves repo.save to Repo#save via YARD @return [Repo] on get_repo()', () => { + pit('resolves repo.save to Repo#save via YARD @return [Repo] on get_repo()', () => { const calls = getRelationships(result, 'CALLS'); const saveCall = calls.find( (c) => @@ -665,14 +668,14 @@ describe('Ruby constant factory call resolution (SERVICE = build_service())', () result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-constant-factory-call'), () => {}); }, 60000); - it('detects UserService and AdminService classes with process and validate methods', () => { + pit('detects UserService and AdminService classes with process and validate methods', () => { expect(getNodesByLabel(result, 'Class')).toContain('UserService'); expect(getNodesByLabel(result, 'Class')).toContain('AdminService'); expect(getNodesByLabel(result, 'Method')).toContain('process'); expect(getNodesByLabel(result, 'Method')).toContain('validate'); }); - it('resolves SERVICE.process() to UserService#process via constant factory call', () => { + pit('resolves SERVICE.process() to UserService#process via constant factory call', () => { const calls = getRelationships(result, 'CALLS'); const processCall = calls.find( (c) => c.target === 'process' && c.targetFilePath.includes('user_service.rb'), @@ -687,7 +690,7 @@ describe('Ruby constant factory call resolution (SERVICE = build_service())', () expect(wrongCall).toBeUndefined(); }); - it('resolves SERVICE.validate() to UserService#validate via constant factory call', () => { + pit('resolves SERVICE.validate() to UserService#validate via constant factory call', () => { const calls = getRelationships(result, 'CALLS'); const validateCall = calls.find( (c) => c.target === 'validate' && c.targetFilePath.includes('user_service.rb'), @@ -710,13 +713,13 @@ describe('Ruby YARD generic type annotations (Hash)', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-yard-generics'), () => {}); }, 60000); - it('detects UserRepo, AdminRepo, and DataService classes', () => { + pit('detects UserRepo, AdminRepo, and DataService classes', () => { expect(getNodesByLabel(result, 'Class')).toContain('UserRepo'); expect(getNodesByLabel(result, 'Class')).toContain('AdminRepo'); expect(getNodesByLabel(result, 'Class')).toContain('DataService'); }); - it('detects save and find_all on both repos, plus sync and audit methods', () => { + pit('detects save and find_all on both repos, plus sync and audit methods', () => { const methods = getNodesByLabel(result, 'Method'); expect(methods).toContain('save'); expect(methods).toContain('find_all'); @@ -724,7 +727,7 @@ describe('Ruby YARD generic type annotations (Hash)', () => { expect(methods).toContain('audit'); }); - it('resolves repo.save in sync() to UserRepo#save via @param repo [UserRepo]', () => { + pit('resolves repo.save in sync() to UserRepo#save via @param repo [UserRepo]', () => { const calls = getRelationships(result, 'CALLS'); const saveCall = calls.find( (c) => c.target === 'save' && c.source === 'sync' && c.targetFilePath.includes('models.rb'), @@ -732,26 +735,32 @@ describe('Ruby YARD generic type annotations (Hash)', () => { expect(saveCall).toBeDefined(); }); - it('does NOT resolve cache param to a class (Hash is a generic container)', () => { - // The @param cache [Hash] should extract type "Hash" — not "UserRepo". - // Since Hash is not a class in the fixture, no type binding is created for cache. - // This verifies the bracket-balanced split doesn't break on the inner comma. - const calls = getRelationships(result, 'CALLS'); - // No calls should originate from cache.* since cache has no resolved type - const cacheCall = calls.find( - (c) => c.source === 'sync' && c.target === 'save' && c.targetFilePath.includes('admin'), - ); - expect(cacheCall).toBeUndefined(); - }); + pit( + 'does NOT resolve cache param to a class (Hash is a generic container)', + () => { + // The @param cache [Hash] should extract type "Hash" — not "UserRepo". + // Since Hash is not a class in the fixture, no type binding is created for cache. + // This verifies the bracket-balanced split doesn't break on the inner comma. + const calls = getRelationships(result, 'CALLS'); + // No calls should originate from cache.* since cache has no resolved type + const cacheCall = calls.find( + (c) => c.source === 'sync' && c.target === 'save' && c.targetFilePath.includes('admin'), + ); + expect(cacheCall).toBeUndefined(); + }, + ); - it('resolves admin_repo.save in audit() to AdminRepo#save via alternate @param [AdminRepo] order', () => { - const calls = getRelationships(result, 'CALLS'); - // audit() calls admin_repo.save — should resolve via the alternate YARD format - const saveCall = calls.find((c) => c.target === 'save' && c.source === 'audit'); - expect(saveCall).toBeDefined(); - }); + pit( + 'resolves admin_repo.save in audit() to AdminRepo#save via alternate @param [AdminRepo] order', + () => { + const calls = getRelationships(result, 'CALLS'); + // audit() calls admin_repo.save — should resolve via the alternate YARD format + const saveCall = calls.find((c) => c.target === 'save' && c.source === 'audit'); + expect(saveCall).toBeDefined(); + }, + ); - it('resolves admin_repo.find_all in audit() to AdminRepo#find_all', () => { + pit('resolves admin_repo.find_all in audit() to AdminRepo#find_all', () => { const calls = getRelationships(result, 'CALLS'); const findCall = calls.find((c) => c.target === 'find_all' && c.source === 'audit'); expect(findCall).toBeDefined(); @@ -771,7 +780,7 @@ describe('Ruby chained method call resolution (Phase 5 review fix)', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-chain-call'), () => {}); }, 60000); - it('detects User, Repo, UserService and App classes', () => { + pit('detects User, Repo, UserService and App classes', () => { const classes = getNodesByLabel(result, 'Class'); expect(classes).toContain('User'); expect(classes).toContain('Repo'); @@ -779,18 +788,18 @@ describe('Ruby chained method call resolution (Phase 5 review fix)', () => { expect(classes).toContain('App'); }); - it('detects save methods on both User and Repo', () => { + pit('detects save methods on both User and Repo', () => { const methods = getNodesByLabel(result, 'Method'); const saveMethods = methods.filter((m) => m === 'save'); expect(saveMethods.length).toBe(2); }); - it('detects get_user method on UserService', () => { + pit('detects get_user method on UserService', () => { const methods = getNodesByLabel(result, 'Method'); expect(methods).toContain('get_user'); }); - it('resolves svc.get_user.save to User#save via chain resolution', () => { + pit('resolves svc.get_user.save to User#save via chain resolution', () => { const calls = getRelationships(result, 'CALLS'); const userSave = calls.find( (c) => c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('user.rb'), @@ -798,7 +807,7 @@ describe('Ruby chained method call resolution (Phase 5 review fix)', () => { expect(userSave).toBeDefined(); }); - it('does NOT resolve svc.get_user.save to Repo#save', () => { + pit('does NOT resolve svc.get_user.save to Repo#save', () => { const calls = getRelationships(result, 'CALLS'); const repoSave = calls.find( (c) => c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('repo.rb'), @@ -818,11 +827,11 @@ describe('Ruby for-in loop resolution', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-for-in-loop'), () => {}); }, 60000); - it('detects User class with save method', () => { + pit('detects User class with save method', () => { expect(getNodesByLabel(result, 'Class')).toContain('User'); }); - it('resolves user.save in for-in to User#save', () => { + pit('resolves user.save in for-in to User#save', () => { const calls = getRelationships(result, 'CALLS'); const userSave = calls.find( (c) => @@ -831,7 +840,7 @@ describe('Ruby for-in loop resolution', () => { expect(userSave).toBeDefined(); }); - it('does NOT resolve user.save to Repo#save (negative)', () => { + pit('does NOT resolve user.save to Repo#save (negative)', () => { const calls = getRelationships(result, 'CALLS'); const wrongSave = calls.find( (c) => @@ -852,18 +861,18 @@ describe('Field type resolution (Ruby)', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-field-types'), () => {}); }, 60000); - it('detects classes: Address, User', () => { + pit('detects classes: Address, User', () => { expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'User']); }); - it('detects Property nodes for attr_accessor fields', () => { + pit('detects Property nodes for attr_accessor fields', () => { const properties = getNodesByLabel(result, 'Property'); expect(properties).toContain('address'); expect(properties).toContain('name'); expect(properties).toContain('city'); }); - it('emits HAS_PROPERTY edges linking properties to classes', () => { + pit('emits HAS_PROPERTY edges linking properties to classes', () => { const propEdges = getRelationships(result, 'HAS_PROPERTY'); expect(propEdges.length).toBe(3); expect(edgeSet(propEdges)).toContain('User → address'); @@ -871,7 +880,7 @@ describe('Field type resolution (Ruby)', () => { expect(edgeSet(propEdges)).toContain('Address → city'); }); - it('resolves user.address.save → Address#save via YARD @return [Address]', () => { + pit('resolves user.address.save → Address#save via YARD @return [Address]', () => { const calls = getRelationships(result, 'CALLS'); const saveCalls = calls.filter((e) => e.target === 'save'); const addressSave = saveCalls.find( @@ -880,7 +889,7 @@ describe('Field type resolution (Ruby)', () => { expect(addressSave).toBeDefined(); }); - it('Property nodes contain expected field names', () => { + pit('Property nodes contain expected field names', () => { const properties = getNodesByLabelFull(result, 'Property'); const city = properties.find((p) => p.name === 'city'); @@ -905,13 +914,13 @@ describe('Field type disambiguation (Ruby)', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-field-type-disambig'), () => {}); }, 60000); - it('detects both User#save and Address#save', () => { + pit('detects both User#save and Address#save', () => { const methods = getNodesByLabel(result, 'Method'); const saveMethods = methods.filter((m) => m === 'save'); expect(saveMethods.length).toBe(2); }); - it('resolves user.address.save → Address#save (not User#save)', () => { + pit('resolves user.address.save → Address#save (not User#save)', () => { const calls = getRelationships(result, 'CALLS'); const saveCalls = calls.filter((e) => e.target === 'save' && e.source === 'process_user'); expect(saveCalls.length).toBe(1); @@ -931,7 +940,7 @@ describe('Write access tracking (Ruby)', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-write-access'), () => {}); }, 60000); - it('emits ACCESSES write edges for setter assignments', () => { + pit('emits ACCESSES write edges for setter assignments', () => { const accesses = getRelationships(result, 'ACCESSES'); const writes = accesses.filter((e) => e.rel.reason === 'write'); expect(writes.length).toBe(3); @@ -946,7 +955,7 @@ describe('Write access tracking (Ruby)', () => { expect(scoreWrite!.source).toBe('update_user'); }); - it('emits ACCESSES write edge for compound assignment (operator_assignment)', () => { + pit('emits ACCESSES write edge for compound assignment (operator_assignment)', () => { const accesses = getRelationships(result, 'ACCESSES'); const writes = accesses.filter((e) => e.rel.reason === 'write'); const scoreWrite = writes.find((e) => e.target === 'score'); @@ -954,7 +963,7 @@ describe('Write access tracking (Ruby)', () => { expect(scoreWrite!.source).toBe('update_user'); }); - it('write ACCESSES edges have confidence 1.0', () => { + pit('write ACCESSES edges have confidence 1.0', () => { const accesses = getRelationships(result, 'ACCESSES'); const writes = accesses.filter((e) => e.rel.reason === 'write'); for (const edge of writes) { @@ -974,7 +983,7 @@ describe('Ruby call-result variable binding (Tier 2b)', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-call-result-binding'), () => {}); }, 60000); - it('resolves user.save to User#save via call-result binding', () => { + pit('resolves user.save to User#save via call-result binding', () => { const calls = getRelationships(result, 'CALLS'); const saveCall = calls.find( (c) => c.target === 'save' && c.source === 'process_user' && c.targetFilePath.includes('app'), @@ -994,7 +1003,7 @@ describe('Ruby method chain binding via unified fixpoint (Phase 9C)', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-method-chain-binding'), () => {}); }, 60000); - it('resolves city.save to City#save via method chain', () => { + pit('resolves city.save to City#save via method chain', () => { const calls = getRelationships(result, 'CALLS'); const saveCall = calls.find( (c) => @@ -1019,7 +1028,7 @@ describe('Ruby grandparent method resolution via MRO (Phase B)', () => { ); }, 60000); - it('detects A, B, C, Greeting classes', () => { + pit('detects A, B, C, Greeting classes', () => { const classes = getNodesByLabel(result, 'Class'); expect(classes).toContain('A'); expect(classes).toContain('B'); @@ -1027,13 +1036,13 @@ describe('Ruby grandparent method resolution via MRO (Phase B)', () => { expect(classes).toContain('Greeting'); }); - it('emits EXTENDS edges: B→A, C→B', () => { + pit('emits EXTENDS edges: B→A, C→B', () => { const extends_ = getRelationships(result, 'EXTENDS'); expect(edgeSet(extends_)).toContain('B → A'); expect(edgeSet(extends_)).toContain('C → B'); }); - it('resolves c.greet.save to Greeting#save via depth-2 MRO lookup', () => { + pit('resolves c.greet.save to Greeting#save via depth-2 MRO lookup', () => { const calls = getRelationships(result, 'CALLS'); const saveCall = calls.find( (c) => c.target === 'save' && c.targetFilePath.includes('greeting'), @@ -1041,7 +1050,7 @@ describe('Ruby grandparent method resolution via MRO (Phase B)', () => { expect(saveCall).toBeDefined(); }); - it('resolves c.greet to A#greet (method found via MRO walk)', () => { + pit('resolves c.greet to A#greet (method found via MRO walk)', () => { const calls = getRelationships(result, 'CALLS'); const greetCall = calls.find((c) => c.target === 'greet' && c.targetFilePath.includes('a.rb')); expect(greetCall).toBeDefined(); @@ -1059,7 +1068,7 @@ describe('Ruby default parameter arity resolution', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-default-params'), () => {}); }, 60000); - it('resolves greet("Alice") with 1 arg to greet with 2 params (1 default)', () => { + pit('resolves greet("Alice") with 1 arg to greet with 2 params (1 default)', () => { const calls = getRelationships(result, 'CALLS'); const greetCalls = calls.filter((c) => c.source === 'process' && c.target === 'greet'); expect(greetCalls.length).toBe(1); @@ -1081,18 +1090,18 @@ describe('Ruby cross-file binding propagation', () => { result = await runPipelineFromRepo(path.join(CROSS_FILE_FIXTURES, 'rb-cross-file'), () => {}); }, 60000); - it('detects User class with save and get_name methods', () => { + pit('detects User class with save and get_name methods', () => { expect(getNodesByLabel(result, 'Class')).toContain('User'); expect(getNodesByLabel(result, 'Method')).toContain('save'); expect(getNodesByLabel(result, 'Method')).toContain('get_name'); }); - it('detects UserFactory class and get_user method', () => { + pit('detects UserFactory class and get_user method', () => { expect(getNodesByLabel(result, 'Class')).toContain('UserFactory'); expect(getNodesByLabel(result, 'Method')).toContain('get_user'); }); - it('emits IMPORTS edge from app.rb to models', () => { + pit('emits IMPORTS edge from app.rb to models', () => { const imports = getRelationships(result, 'IMPORTS'); const edge = imports.find( (e) => e.sourceFilePath.includes('app') && e.targetFilePath.includes('models'), @@ -1100,7 +1109,7 @@ describe('Ruby cross-file binding propagation', () => { expect(edge).toBeDefined(); }); - it('resolves user.save in process to User#save via cross-file propagation', () => { + pit('resolves user.save in process to User#save via cross-file propagation', () => { const calls = getRelationships(result, 'CALLS'); const saveCall = calls.find( (c) => c.target === 'save' && c.source === 'process' && c.targetFilePath.includes('models'), @@ -1108,7 +1117,7 @@ describe('Ruby cross-file binding propagation', () => { expect(saveCall).toBeDefined(); }); - it('resolves user.get_name in process to User#get_name via cross-file propagation', () => { + pit('resolves user.get_name in process to User#get_name via cross-file propagation', () => { const calls = getRelationships(result, 'CALLS'); const getNameCall = calls.find( (c) => @@ -1117,7 +1126,7 @@ describe('Ruby cross-file binding propagation', () => { expect(getNameCall).toBeDefined(); }); - it('emits HAS_METHOD edges linking save and get_name to User', () => { + pit('emits HAS_METHOD edges linking save and get_name to User', () => { const hasMethod = getRelationships(result, 'HAS_METHOD'); const saveEdge = hasMethod.find((e) => e.source === 'User' && e.target === 'save'); const getNameEdge = hasMethod.find((e) => e.source === 'User' && e.target === 'get_name'); @@ -1138,11 +1147,11 @@ describe('Ruby method enrichment (visibility, isStatic, parameters)', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-method-enrichment'), () => {}); }, 60000); - it('detects Animal and Dog classes', () => { + pit('detects Animal and Dog classes', () => { expect(getNodesByLabel(result, 'Class')).toEqual(['Animal', 'Dog']); }); - it('detects all methods including singleton', () => { + pit('detects all methods including singleton', () => { const methods = getNodesByLabel(result, 'Method'); expect(methods).toContain('speak'); expect(methods).toContain('classify'); @@ -1151,7 +1160,7 @@ describe('Ruby method enrichment (visibility, isStatic, parameters)', () => { expect(methods).toContain('energy_level'); }); - it('emits HAS_METHOD edges for Animal and Dog', () => { + pit('emits HAS_METHOD edges for Animal and Dog', () => { const hasMethod = getRelationships(result, 'HAS_METHOD'); // Animal has speak, classify, from_habitat, internal_state expect(hasMethod.find((e) => e.source === 'Animal' && e.target === 'speak')).toBeDefined(); @@ -1167,7 +1176,7 @@ describe('Ruby method enrichment (visibility, isStatic, parameters)', () => { expect(hasMethod.find((e) => e.source === 'Dog' && e.target === 'energy_level')).toBeDefined(); }); - it('marks internal_state as private (when enriched)', () => { + pit('marks internal_state as private (when enriched)', () => { const methods = getNodesByLabelFull(result, 'Method'); const internalState = methods.find( (m) => m.name === 'internal_state' && m.properties.filePath?.includes('animal'), @@ -1180,7 +1189,7 @@ describe('Ruby method enrichment (visibility, isStatic, parameters)', () => { } }); - it('marks energy_level as protected (when enriched)', () => { + pit('marks energy_level as protected (when enriched)', () => { const methods = getNodesByLabelFull(result, 'Method'); const energyLevel = methods.find( (m) => m.name === 'energy_level' && m.properties.filePath?.includes('animal'), @@ -1191,7 +1200,7 @@ describe('Ruby method enrichment (visibility, isStatic, parameters)', () => { } }); - it('marks classify as static (when enriched)', () => { + pit('marks classify as static (when enriched)', () => { const methods = getNodesByLabelFull(result, 'Method'); const classify = methods.find( (m) => m.name === 'classify' && m.properties.filePath?.includes('animal'), @@ -1202,7 +1211,7 @@ describe('Ruby method enrichment (visibility, isStatic, parameters)', () => { } }); - it('marks from_habitat (class << self) as static and public (when enriched)', () => { + pit('marks from_habitat (class << self) as static and public (when enriched)', () => { const methods = getNodesByLabelFull(result, 'Method'); const fromHabitat = methods.find( (m) => m.name === 'from_habitat' && m.properties.filePath?.includes('animal'), @@ -1216,7 +1225,7 @@ describe('Ruby method enrichment (visibility, isStatic, parameters)', () => { } }); - it('extracts parameterCount for from_habitat(habitat)', () => { + pit('extracts parameterCount for from_habitat(habitat)', () => { const methods = getNodesByLabelFull(result, 'Method'); const fromHabitat = methods.find( (m) => m.name === 'from_habitat' && m.properties.filePath?.includes('animal'), @@ -1225,7 +1234,7 @@ describe('Ruby method enrichment (visibility, isStatic, parameters)', () => { expect(fromHabitat!.properties.parameterCount).toBe(1); }); - it('marks speak as public (when enriched)', () => { + pit('marks speak as public (when enriched)', () => { const methods = getNodesByLabelFull(result, 'Method'); const speak = methods.find( (m) => m.name === 'speak' && m.properties.filePath?.includes('animal'), @@ -1237,7 +1246,7 @@ describe('Ruby method enrichment (visibility, isStatic, parameters)', () => { } }); - it('extracts parameterCount for classify(name)', () => { + pit('extracts parameterCount for classify(name)', () => { const methods = getNodesByLabelFull(result, 'Method'); const classify = methods.find( (m) => m.name === 'classify' && m.properties.filePath?.includes('animal'), @@ -1246,7 +1255,7 @@ describe('Ruby method enrichment (visibility, isStatic, parameters)', () => { expect(classify!.properties.parameterCount).toBe(1); }); - it('resolves dog.speak member call from main to Dog#speak', () => { + pit('resolves dog.speak member call from main to Dog#speak', () => { const calls = getRelationships(result, 'CALLS'); const speakCall = calls.find( (c) => c.source === 'main' && c.target === 'speak' && c.targetFilePath.includes('animal'), @@ -1254,13 +1263,13 @@ describe('Ruby method enrichment (visibility, isStatic, parameters)', () => { expect(speakCall).toBeDefined(); }); - it('emits EXTENDS edge from Dog to Animal', () => { + pit('emits EXTENDS edge from Dog to Animal', () => { const extends_ = getRelationships(result, 'EXTENDS'); const edge = extends_.find((e) => e.source === 'Dog' && e.target === 'Animal'); expect(edge).toBeDefined(); }); - it('detects main as top-level Method in app.rb', () => { + pit('detects main as top-level Method in app.rb', () => { // Ruby top-level def is parsed as a method node (tree-sitter `method` type) const methods = getNodesByLabel(result, 'Method'); expect(methods).toContain('main'); @@ -1276,14 +1285,14 @@ describe('Ruby singleton_class handling via sequential path (skipWorkers)', () = }); }, 60000); - it('keeps Animal as the owner for class << self methods', () => { + pit('keeps Animal as the owner for class << self methods', () => { const hasMethod = getRelationships(result, 'HAS_METHOD'); expect( hasMethod.find((e) => e.source === 'Animal' && e.target === 'from_habitat'), ).toBeDefined(); }); - it('marks from_habitat as static in the sequential path', () => { + pit('marks from_habitat as static in the sequential path', () => { const methods = getNodesByLabelFull(result, 'Method'); const fromHabitat = methods.find( (m) => m.name === 'from_habitat' && m.properties.filePath?.includes('animal'), @@ -1305,17 +1314,17 @@ describe('Ruby overload dispatch (format vs format_with_prefix)', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-overload-dispatch'), () => {}); }, 60000); - it('detects Formatter class', () => { + pit('detects Formatter class', () => { expect(getNodesByLabel(result, 'Class')).toContain('Formatter'); }); - it('detects format and format_with_prefix methods', () => { + pit('detects format and format_with_prefix methods', () => { const methods = getNodesByLabel(result, 'Method'); expect(methods).toContain('format'); expect(methods).toContain('format_with_prefix'); }); - it('emits HAS_METHOD edges for both methods on Formatter', () => { + pit('emits HAS_METHOD edges for both methods on Formatter', () => { const hasMethod = getRelationships(result, 'HAS_METHOD'); expect(hasMethod.find((e) => e.source === 'Formatter' && e.target === 'format')).toBeDefined(); expect( @@ -1323,21 +1332,21 @@ describe('Ruby overload dispatch (format vs format_with_prefix)', () => { ).toBeDefined(); }); - it('extracts arity for format(value) — 1 parameter', () => { + pit('extracts arity for format(value) — 1 parameter', () => { const methods = getNodesByLabelFull(result, 'Method'); const format = methods.find((m) => m.name === 'format'); expect(format).toBeDefined(); expect(format!.properties.parameterCount).toBe(1); }); - it('extracts arity for format_with_prefix(value, prefix) — 2 parameters', () => { + pit('extracts arity for format_with_prefix(value, prefix) — 2 parameters', () => { const methods = getNodesByLabelFull(result, 'Method'); const fwp = methods.find((m) => m.name === 'format_with_prefix'); expect(fwp).toBeDefined(); expect(fwp!.properties.parameterCount).toBe(2); }); - it('resolves f.format call from run to Formatter#format', () => { + pit('resolves f.format call from run to Formatter#format', () => { const calls = getRelationships(result, 'CALLS'); const formatCall = calls.find( (c) => c.source === 'run' && c.target === 'format' && c.targetFilePath.includes('formatter'), @@ -1345,7 +1354,7 @@ describe('Ruby overload dispatch (format vs format_with_prefix)', () => { expect(formatCall).toBeDefined(); }); - it('resolves f.format_with_prefix call from run to Formatter#format_with_prefix', () => { + pit('resolves f.format_with_prefix call from run to Formatter#format_with_prefix', () => { const calls = getRelationships(result, 'CALLS'); const fwpCall = calls.find( (c) => @@ -1356,7 +1365,7 @@ describe('Ruby overload dispatch (format vs format_with_prefix)', () => { expect(fwpCall).toBeDefined(); }); - it('detects run as top-level Method in app.rb', () => { + pit('detects run as top-level Method in app.rb', () => { // Ruby top-level def is parsed as a method node (tree-sitter `method` type) const methods = getNodesByLabel(result, 'Method'); expect(methods).toContain('run'); @@ -1374,13 +1383,13 @@ describe('Ruby Child extends Parent — inherited method resolution (SM-9)', () result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-child-extends-parent'), () => {}); }, 60000); - it('detects Parent and Child classes', () => { + pit('detects Parent and Child classes', () => { const classes = getNodesByLabel(result, 'Class'); expect(classes).toContain('Parent'); expect(classes).toContain('Child'); }); - it('resolves c.parent_method to Parent#parent_method via first-wins MRO walk', () => { + pit('resolves c.parent_method to Parent#parent_method via first-wins MRO walk', () => { const calls = getRelationships(result, 'CALLS'); const parentMethodCall = calls.find( (c) => c.target === 'parent_method' && c.targetFilePath.includes('parent.rb'), diff --git a/gitnexus/test/integration/ruby-pipeline-benchmark.test.ts b/gitnexus/test/integration/ruby-pipeline-benchmark.test.ts new file mode 100644 index 000000000..3f3b10ee8 --- /dev/null +++ b/gitnexus/test/integration/ruby-pipeline-benchmark.test.ts @@ -0,0 +1,305 @@ +/** + * Ruby ingestion pipeline benchmark. + * + * Generates synthetic Ruby codebases at increasing scales and measures + * wall-clock time and peak heap through the full pipeline — parsing, + * scope extraction, heritage (include/extend/prepend), MRO construction, + * and call resolution via the registry-primary scope-resolution path. + * + * Run: GITNEXUS_BENCH=1 npx vitest run test/integration/ruby-pipeline-benchmark.test.ts + * + * The benchmark uses workers (production path) by default. Set + * skipWorkers to test the sequential fallback path. + */ +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; + classCount: number; + moduleCount: number; + mixinModuleCount: number; + elapsedMs: number; + peakHeapMB: number; + nodeCount: number; + edgeCount: number; + implementsCount: number; + hasPropertyCount: number; + extendsCount: number; +} + +function generateRubyFixture( + fileCount: number, + modulesPerLevel: number, +): { dir: string; classCount: number; moduleCount: number; mixinModuleCount: number } { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `ruby-bench-${fileCount}-`)); + + // Three families of mixins: one for include, one for extend, one for prepend. + // Each family has modulesPerLevel² modules so the MRO partitioning logic is + // exercised with all three heritage kinds and varied orderings. + const includeMixins: string[] = []; + const extendMixins: string[] = []; + const prependMixins: string[] = []; + + for (let i = 0; i < modulesPerLevel; i++) { + for (let j = 0; j < modulesPerLevel; j++) { + includeMixins.push(`Includable${i}x${j}`); + extendMixins.push(`Extendable${i}x${j}`); + prependMixins.push(`Prependable${i}x${j}`); + } + } + + const allMixins = [...includeMixins, ...extendMixins, ...prependMixins]; + const moduleCount = allMixins.length; + const classCount = fileCount; + + // Generate mixin module files — each module includes a shared base module + // to create diamond mixin patterns (class includes A and B, both include Base). + const concernsDir = path.join(dir, 'lib', 'concerns'); + fs.mkdirSync(concernsDir, { recursive: true }); + + // Shared base modules that other mixins include (diamond pattern) + const baseModuleCount = Math.max(2, Math.floor(modulesPerLevel / 2)); + for (let b = 0; b < baseModuleCount; b++) { + const baseName = `BaseMixin${b}`; + const content = [ + `module ${baseName}`, + ` def base${b}_check`, + ' true', + ' end', + 'end', + '', + ].join('\n'); + fs.writeFileSync(path.join(concernsDir, `${baseName.toLowerCase()}.rb`), content); + } + + for (let m = 0; m < allMixins.length; m++) { + const moduleName = allMixins[m]; + const baseIdx = m % baseModuleCount; + const baseName = `BaseMixin${baseIdx}`; + const content = [ + `require_relative '${baseName.toLowerCase()}'`, + '', + `module ${moduleName}`, + ` include ${baseName}`, + '', + ` def ${moduleName.toLowerCase()}_action`, + ` base${baseIdx}_check`, + ' end', + 'end', + '', + ].join('\n'); + fs.writeFileSync(path.join(concernsDir, `${moduleName.toLowerCase()}.rb`), content); + } + + // Generate class files — each class uses include + extend + prepend with + // different modules, creating a rich MRO that exercises all three + // heritage-kind partitions in buildRubyMro. + const modelsDir = path.join(dir, 'lib', 'models'); + fs.mkdirSync(modelsDir, { recursive: true }); + + for (let f = 0; f < fileCount; f++) { + const className = `Model${f}`; + + // Pick one mixin of each kind (rotating through the pools) + const incMixin = includeMixins[f % includeMixins.length]; + const extMixin = extendMixins[f % extendMixins.length]; + const preMixin = prependMixins[f % prependMixins.length]; + // Second include mixin for diamond-overlap testing + const incMixin2 = includeMixins[(f + 1) % includeMixins.length]; + + const siblingIdx = (f + 1) % fileCount; + const siblingClass = `Model${siblingIdx}`; + + const crossIdx = (f + Math.floor(fileCount / 3)) % fileCount; + const crossClass = `Model${crossIdx}`; + + const requireLines = [ + `require_relative '../concerns/${incMixin.toLowerCase()}'`, + `require_relative '../concerns/${incMixin2.toLowerCase()}'`, + `require_relative '../concerns/${extMixin.toLowerCase()}'`, + `require_relative '../concerns/${preMixin.toLowerCase()}'`, + f !== siblingIdx ? `require_relative '${siblingClass.toLowerCase()}'` : '', + f !== crossIdx ? `require_relative '${crossClass.toLowerCase()}'` : '', + ].filter(Boolean); + + const content = [ + ...requireLines, + '', + `class ${className}`, + ` include ${incMixin}`, + ` include ${incMixin2}`, + ` extend ${extMixin}`, + ` prepend ${preMixin}`, + '', + ` attr_accessor :id, :name, :status`, + '', + ` # @param other [${siblingClass}]`, + ` # @return [${siblingClass}]`, + ` def process(other)`, + ` other.save`, + ` ${incMixin.toLowerCase()}_action`, + ` other`, + ' end', + '', + ' def save', + ' true', + ' end', + '', + ` # @return [${crossClass}]`, + ` def build_cross`, + ` ${crossClass}.new`, + ' end', + '', + ` def self.class_action`, + ` ${extMixin.toLowerCase()}_action`, + ' end', + 'end', + '', + ].join('\n'); + + fs.writeFileSync(path.join(modelsDir, `${className.toLowerCase()}.rb`), content); + } + + return { + dir, + classCount, + moduleCount: moduleCount + baseModuleCount, + mixinModuleCount: moduleCount, + }; +} + +async function runBenchmark( + fileCount: number, + moduleLevels: number, + budgetMs: number, +): Promise { + const { dir, classCount, moduleCount, mixinModuleCount } = generateRubyFixture( + fileCount, + moduleLevels, + ); + + 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; + + let implementsCount = 0; + let hasPropertyCount = 0; + let extendsCount = 0; + for (const rel of result.graph.iterRelationshipsByType('IMPLEMENTS')) { + implementsCount++; + void rel; + } + for (const rel of result.graph.iterRelationshipsByType('HAS_PROPERTY')) { + hasPropertyCount++; + void rel; + } + for (const rel of result.graph.iterRelationshipsByType('EXTENDS')) { + extendsCount++; + void rel; + } + + return { + fileCount, + classCount, + moduleCount, + mixinModuleCount, + elapsedMs, + peakHeapMB: Math.round(peakHeapMB), + nodeCount: result.graph.nodeCount, + edgeCount: result.graph.relationshipCount, + implementsCount, + hasPropertyCount, + extendsCount, + }; + } 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 │ Classes │ Modules │ Time (ms) │ Heap MB │ Nodes │ Edges │ IMPL │ PROPS │ EXT │', + ); + console.log( + '├──────────┼─────────┼──────────┼───────────┼──────────┼───────┼───────┼──────┼───────┼─────┤', + ); + for (const r of results) { + console.log( + `│ ${String(r.fileCount).padStart(8)} │ ${String(r.classCount).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)} │ ${String(r.implementsCount).padStart(4)} │ ${String(r.hasPropertyCount).padStart(5)} │ ${String(r.extendsCount).padStart(3)} │`, + ); + } + 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)('Ruby pipeline benchmark', () => { + it('scales with file count (workers enabled)', async () => { + const scales = [100, 250, 500]; + const results: BenchResult[] = []; + + for (const fileCount of scales) { + const moduleLevels = Math.max(2, Math.ceil(Math.sqrt(fileCount / 4))); + const result = await runBenchmark(fileCount, moduleLevels, 180_000); + results.push(result); + console.log( + ` ${fileCount} files: ${result.elapsedMs}ms, ${result.peakHeapMB}MB heap, ${result.nodeCount} nodes, ${result.edgeCount} edges`, + ); + } + + printResults('Ruby 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); + } + + // Verify heritage emission produces exact expected counts. + // Each class: 2x include + 1x extend + 1x prepend = 4 IMPLEMENTS. + // Each mixin module (non-base) includes one BaseMixin = 1 IMPLEMENTS. + // Each class: attr_accessor :id, :name, :status = 3 HAS_PROPERTY. + for (const r of results) { + expect(r.implementsCount).toBe(r.classCount * 4 + r.mixinModuleCount); + expect(r.hasPropertyCount).toBe(r.classCount * 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/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index 000403bce..f76215879 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -217,6 +217,621 @@ public class UserController { expect(getByIdRoute).toBeDefined(); }); + // ─── #1834 — Spring named annotation arguments ────────────────── + // Spring annotations accept both positional shorthand + // (`@GetMapping("/users")`) and named arguments + // (`@GetMapping(value = "/users")` or `@GetMapping(path = "/users")`). + // The two AST shapes produced by tree-sitter-java differ: + // @GetMapping("/users") → annotation_argument_list > string_literal + // @GetMapping(value = "/users") → annotation_argument_list > element_value_pair + // The named-arg pattern in `http-patterns/java.ts` MUST constrain + // the `key` field to `path`/`value`; without that constraint the + // query also captures other string-valued attributes such as + // `produces`, `consumes`, `headers`, `name`, `params` (see PR #1834 + // review). The tests below pin both the positive cases and the + // negative anti-regression cases. + it('extracts Spring class-level @RequestMapping(path = "/api")', async () => { + const dir = path.join(tmpDir, 'spring-class-named-path'); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/controller/UserController.java'), + ` +package com.example; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping(path = "/api/v3") +public class UserController { + @GetMapping("/users") + public List list() { return service.findAll(); } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + const route = providers.find((c) => c.contractId === 'http::GET::/api/v3/users'); + expect(route).toBeDefined(); + expect(route!.meta.path).toBe('/api/v3/users'); + }); + + it('extracts Spring class-level @RequestMapping(value = "/api")', async () => { + const dir = path.join(tmpDir, 'spring-class-named-value'); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/controller/OrderController.java'), + ` +package com.example; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping(value = "/orders") +public class OrderController { + @GetMapping("/list") + public List list() { return service.findAll(); } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + const route = providers.find((c) => c.contractId === 'http::GET::/orders/list'); + expect(route).toBeDefined(); + }); + + it('extracts Spring method-level @GetMapping(value = "/users") (named value)', async () => { + const dir = path.join(tmpDir, 'spring-method-named-value'); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/controller/UserController.java'), + ` +package com.example; +import org.springframework.web.bind.annotation.*; + +@RestController +public class UserController { + @GetMapping(value = "/users") + public List list() { return service.findAll(); } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + const route = providers.find((c) => c.contractId === 'http::GET::/users'); + expect(route).toBeDefined(); + expect(route!.symbolName).toBe('list'); + }); + + it('extracts Spring method-level @GetMapping(path = "/users") (named path)', async () => { + const dir = path.join(tmpDir, 'spring-method-named-path-get'); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/controller/UserController.java'), + ` +package com.example; +import org.springframework.web.bind.annotation.*; + +@RestController +public class UserController { + @GetMapping(path = "/users") + public List list() { return service.findAll(); } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + const route = providers.find((c) => c.contractId === 'http::GET::/users'); + expect(route).toBeDefined(); + expect(route!.symbolName).toBe('list'); + }); + + it('extracts Spring method-level @PostMapping(path = "/users") (named path)', async () => { + const dir = path.join(tmpDir, 'spring-method-named-path-post'); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/controller/UserController.java'), + ` +package com.example; +import org.springframework.web.bind.annotation.*; + +@RestController +public class UserController { + @PostMapping(path = "/users") + public User create(@RequestBody User user) { return service.save(user); } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + const route = providers.find((c) => c.contractId === 'http::POST::/users'); + expect(route).toBeDefined(); + expect(route!.symbolName).toBe('create'); + }); + + it('combines class named-arg prefix with method positional path', async () => { + const dir = path.join(tmpDir, 'spring-mixed-class-named-method-pos'); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/controller/UserController.java'), + ` +package com.example; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping(path = "/api") +public class UserController { + @GetMapping("/users") + public List list() { return service.findAll(); } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + const route = providers.find((c) => c.contractId === 'http::GET::/api/users'); + expect(route).toBeDefined(); + }); + + it('combines class positional prefix with method named-arg path', async () => { + const dir = path.join(tmpDir, 'spring-mixed-class-pos-method-named'); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/controller/UserController.java'), + ` +package com.example; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api") +public class UserController { + @GetMapping(value = "/users") + public List list() { return service.findAll(); } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + const route = providers.find((c) => c.contractId === 'http::GET::/api/users'); + expect(route).toBeDefined(); + }); + + it('does NOT emit a provider for @GetMapping(produces = ...) without path/value', async () => { + // Anti-regression: without the `key:` constraint, the named-arg + // query would capture `produces = "application/json"` and emit + // a bogus `http::GET::/application/json` contract. + const dir = path.join(tmpDir, 'spring-produces-only'); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/controller/MisleadingController.java'), + ` +package com.example; +import org.springframework.web.bind.annotation.*; + +@RestController +public class MisleadingController { + @GetMapping(produces = "application/json") + public List list() { return service.findAll(); } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + // No GET provider should be emitted for this method — the only + // string literal in the annotation is a non-route attribute. + expect( + providers.find((c) => c.contractId === 'http::GET::/application/json'), + ).toBeUndefined(); + // And the controller has no other route, so providers list for + // this file should be empty. + const fromThisFile = providers.filter((c) => + c.symbolRef.filePath.endsWith('MisleadingController.java'), + ); + expect(fromThisFile).toHaveLength(0); + }); + + it('emits exactly one provider for @GetMapping(name = "...", value = "/users")', async () => { + // Anti-regression: without the `key:` constraint, the named-arg + // query would capture both string literals and emit two + // contracts (`/listUsers` + `/users`). With the constraint, only + // `/users` is emitted. + const dir = path.join(tmpDir, 'spring-name-and-value'); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/controller/UserController.java'), + ` +package com.example; +import org.springframework.web.bind.annotation.*; + +@RestController +public class UserController { + @GetMapping(name = "listUsers", value = "/users") + public List list() { return service.findAll(); } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + const usersRoute = providers.find((c) => c.contractId === 'http::GET::/users'); + expect(usersRoute).toBeDefined(); + expect(usersRoute!.symbolName).toBe('list'); + + // The non-route `name` attribute must NOT produce a route. + expect(providers.find((c) => c.contractId === 'http::GET::/listUsers')).toBeUndefined(); + + const fromThisFile = providers.filter((c) => + c.symbolRef.filePath.endsWith('UserController.java'), + ); + expect(fromThisFile).toHaveLength(1); + }); + + it('uses `path` (not non-route key) as class prefix when both appear', async () => { + // Anti-regression: without the `key:` constraint, the LAST + // element_value_pair in the annotation wins because + // prefixByClassId.set is called per match, in document order. So + // `@RequestMapping(path = "/api", name = "myApi")` would mistakenly + // set the prefix to `myApi`. With the constraint, only the + // `path`/`value` pair is captured and the prefix stays `/api`. + const dir = path.join(tmpDir, 'spring-class-prefix-last-wins'); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/controller/UserController.java'), + ` +package com.example; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping(path = "/api", name = "myApi") +public class UserController { + @GetMapping("/users") + public List list() { return service.findAll(); } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + const route = providers.find((c) => c.contractId === 'http::GET::/api/users'); + expect(route).toBeDefined(); + + // Must NOT have used `myApi` as the class prefix. + expect(providers.find((c) => c.contractId === 'http::GET::/myApi/users')).toBeUndefined(); + }); + + // ─── #1834 follow-up — Spring on Kotlin ────────────────────────── + // The same positional / named-argument distinction applies to + // Kotlin Spring Boot controllers. The Kotlin tree-sitter grammar + // (fwcd/tree-sitter-kotlin) produces a different AST shape than + // tree-sitter-java — both forms share `value_argument`, with the + // optional leading `simple_identifier "="` distinguishing named + // from positional. The plugin in `http-patterns/kotlin.ts` mirrors + // the safety bar from java.ts: positional uses `.` to anchor the + // string_literal as the first named child of `value_argument`, + // and the named pattern restricts the `simple_identifier` key to + // `^(path|value)$` to avoid capturing `produces`, `consumes`, + // `headers`, `name`, `params`, etc. + // + // tree-sitter-kotlin is an optionalDependency. If the binding is + // unavailable in the current test environment, `getPluginForFile` + // returns undefined for `.kt` files and we skip the suite. + const kotlinAvailable = getPluginForFile('Probe.kt') !== undefined; + const itKotlin = kotlinAvailable ? it : it.skip; + + itKotlin('extracts Kotlin @RequestMapping("/api/v1") (positional class prefix)', async () => { + const dir = path.join(tmpDir, 'kotlin-spring-class-positional'); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/controller/UserController.kt'), + `package com.example +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v1") +class UserController { + @GetMapping("/users") fun list() {} +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + const route = providers.find((c) => c.contractId === 'http::GET::/api/v1/users'); + expect(route).toBeDefined(); + expect(route!.symbolName).toBe('list'); + expect(route!.meta.framework).toBe('spring'); + }); + + itKotlin('extracts Kotlin @RequestMapping(path = "/api/v2") (named class prefix)', async () => { + const dir = path.join(tmpDir, 'kotlin-spring-class-named-path'); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/controller/UserController.kt'), + `package com.example +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping(path = "/api/v2") +class UserController { + @GetMapping("/users") fun list() {} +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + const route = providers.find((c) => c.contractId === 'http::GET::/api/v2/users'); + expect(route).toBeDefined(); + }); + + itKotlin( + 'extracts Kotlin @RequestMapping(value = "/orders") (named class prefix)', + async () => { + const dir = path.join(tmpDir, 'kotlin-spring-class-named-value'); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/controller/OrderController.kt'), + `package com.example +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping(value = "/orders") +class OrderController { + @GetMapping("/list") fun list() {} +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(providers.find((c) => c.contractId === 'http::GET::/orders/list')).toBeDefined(); + }, + ); + + itKotlin('extracts Kotlin method-level @GetMapping(value = "/users")', async () => { + const dir = path.join(tmpDir, 'kotlin-spring-method-named-value'); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/controller/UserController.kt'), + `package com.example +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +class UserController { + @GetMapping(value = "/users") fun list() {} +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + const route = providers.find((c) => c.contractId === 'http::GET::/users'); + expect(route).toBeDefined(); + expect(route!.symbolName).toBe('list'); + }); + + itKotlin('extracts Kotlin method-level @GetMapping(path = "/users")', async () => { + const dir = path.join(tmpDir, 'kotlin-spring-method-named-path-get'); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/controller/UserController.kt'), + `package com.example +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +class UserController { + @GetMapping(path = "/users") fun list() {} +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(providers.find((c) => c.contractId === 'http::GET::/users')).toBeDefined(); + }); + + itKotlin('extracts Kotlin method-level @PostMapping(path = "/users")', async () => { + const dir = path.join(tmpDir, 'kotlin-spring-method-named-path-post'); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/controller/UserController.kt'), + `package com.example +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +class UserController { + @PostMapping(path = "/users") fun create() {} +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + const route = providers.find((c) => c.contractId === 'http::POST::/users'); + expect(route).toBeDefined(); + expect(route!.symbolName).toBe('create'); + }); + + itKotlin('combines Kotlin class named-arg prefix with method positional path', async () => { + const dir = path.join(tmpDir, 'kotlin-spring-mixed-class-named-method-pos'); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/controller/UserController.kt'), + `package com.example +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping(path = "/api") +class UserController { + @GetMapping("/users") fun list() {} +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(providers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined(); + }); + + itKotlin('combines Kotlin class positional prefix with method named-arg path', async () => { + const dir = path.join(tmpDir, 'kotlin-spring-mixed-class-pos-method-named'); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/controller/UserController.kt'), + `package com.example +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api") +class UserController { + @GetMapping(value = "/users") fun list() {} +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(providers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined(); + }); + + itKotlin( + 'does NOT emit a Kotlin provider for @GetMapping(produces = ...) without path/value', + async () => { + // Anti-regression: without the `simple_identifier` key + // constraint, the named-arg query would capture + // `produces = "application/json"` and emit a bogus + // `http::GET::/application/json` contract. + const dir = path.join(tmpDir, 'kotlin-spring-produces-only'); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/controller/MisleadingController.kt'), + `package com.example +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +class MisleadingController { + @GetMapping(produces = "application/json") fun list() {} +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect( + providers.find((c) => c.contractId === 'http::GET::/application/json'), + ).toBeUndefined(); + const fromThisFile = providers.filter((c) => + c.symbolRef.filePath.endsWith('MisleadingController.kt'), + ); + expect(fromThisFile).toHaveLength(0); + }, + ); + + itKotlin( + 'emits exactly one Kotlin provider for @GetMapping(name = "...", value = "/users")', + async () => { + // Anti-regression: without the key constraint, both string + // literals would be captured as method paths, emitting two + // contracts (`/listUsers` + `/users`). + const dir = path.join(tmpDir, 'kotlin-spring-name-and-value'); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/controller/UserController.kt'), + `package com.example +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +class UserController { + @GetMapping(name = "listUsers", value = "/users") fun list() {} +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + const usersRoute = providers.find((c) => c.contractId === 'http::GET::/users'); + expect(usersRoute).toBeDefined(); + expect(usersRoute!.symbolName).toBe('list'); + + expect(providers.find((c) => c.contractId === 'http::GET::/listUsers')).toBeUndefined(); + + const fromThisFile = providers.filter((c) => + c.symbolRef.filePath.endsWith('UserController.kt'), + ); + expect(fromThisFile).toHaveLength(1); + }, + ); + + itKotlin( + 'uses Kotlin `path` (not non-route key) as class prefix when both appear', + async () => { + // Anti-regression: without the key constraint, the LAST captured + // value_argument would win in the prefix map. Here `name = "myApi"` + // appears after `path = "/api"` — the prefix must remain `/api`. + const dir = path.join(tmpDir, 'kotlin-spring-class-prefix-key-wins'); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/controller/UserController.kt'), + `package com.example +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping(path = "/api", name = "myApi") +class UserController { + @GetMapping("/users") fun list() {} +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(providers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined(); + expect(providers.find((c) => c.contractId === 'http::GET::/myApi/users')).toBeUndefined(); + }, + ); + it('extracts Express router.get patterns', async () => { const dir = path.join(tmpDir, 'express'); fs.mkdirSync(path.join(dir, 'src/routes'), { recursive: true }); @@ -782,6 +1397,259 @@ class ApiClient { ).toBeDefined(); }); + // ─── Kotlin consumers (RestTemplate / WebClient short / OkHttp) ── + // Same shape as the Java consumer test above, but parsed by the + // tree-sitter-kotlin grammar via `KOTLIN_HTTP_PLUGIN`. Three + // consumer flavors covered here (long-form WebClient + // `webClient.method(HttpMethod.X).uri(...)` is intentionally + // deferred to a follow-up — see kotlin.ts file header). + // + // tree-sitter-kotlin is an optionalDependency. If the binding is + // unavailable, `getPluginForFile` returns undefined for `.kt` and + // we skip the suite (matches the gating on the Provider tests). + const kotlinConsumerAvailable = getPluginForFile('Probe.kt') !== undefined; + const itKotlinConsumer = kotlinConsumerAvailable ? it : it.skip; + + itKotlinConsumer('extracts Kotlin RestTemplate verbs', async () => { + const dir = path.join(tmpDir, 'kotlin-rest-template'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'ApiClient.kt'), + `package com.example +import org.springframework.web.client.RestTemplate + +class ApiClient(private val restTemplate: RestTemplate) { + fun run() { + restTemplate.getForObject("/api/users/1", User::class.java) + restTemplate.getForEntity("/api/users/2", User::class.java) + restTemplate.postForObject("/api/users", body, User::class.java) + restTemplate.postForEntity("/api/users", body, User::class.java) + restTemplate.put("/api/users/3", body) + restTemplate.delete("/api/users/4") + restTemplate.patchForObject("/api/users/5", body, User::class.java) + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers.find((c) => c.contractId === 'http::GET::/api/users/{param}')).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::POST::/api/users')).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::PUT::/api/users/{param}')).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::DELETE::/api/users/{param}'), + ).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::PATCH::/api/users/{param}'), + ).toBeDefined(); + + // Framework label must be the same `spring-rest-template` used + // by the Java plugin so polyglot repos coalesce on a single key. + const restConsumers = consumers.filter((c) => c.meta.framework === 'spring-rest-template'); + expect(restConsumers.length).toBeGreaterThanOrEqual(5); + }); + + itKotlinConsumer('extracts Kotlin WebClient short-form verbs', async () => { + const dir = path.join(tmpDir, 'kotlin-web-client-short'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'OrderClient.kt'), + `package com.example +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.awaitBody +import org.springframework.web.reactive.function.client.awaitBodilessEntity + +class OrderClient(private val webClient: WebClient) { + suspend fun run() { + val r1 = webClient.get().uri("/api/orders/1").retrieve().awaitBody() + val r2 = webClient.post().uri("/api/orders").retrieve().awaitBody() + val r3 = webClient.put().uri("/api/orders/2").retrieve().awaitBody() + val r4 = webClient.delete().uri("/api/orders/3").retrieve().awaitBodilessEntity() + val r5 = webClient.patch().uri("/api/orders/4").retrieve().awaitBody() + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect( + consumers.find((c) => c.contractId === 'http::GET::/api/orders/{param}'), + ).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::POST::/api/orders')).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::PUT::/api/orders/{param}'), + ).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::DELETE::/api/orders/{param}'), + ).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::PATCH::/api/orders/{param}'), + ).toBeDefined(); + + const wcConsumers = consumers.filter((c) => c.meta.framework === 'spring-web-client'); + expect(wcConsumers.length).toBeGreaterThanOrEqual(5); + }); + + itKotlinConsumer('extracts Kotlin OkHttp Request.Builder().url(...)', async () => { + const dir = path.join(tmpDir, 'kotlin-okhttp'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'OkClient.kt'), + `package com.example +import okhttp3.OkHttpClient +import okhttp3.Request + +class OkClient(private val client: OkHttpClient) { + fun fetch() { + val req = Request.Builder().url("/api/items").build() + val resp = client.newCall(req).execute() + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + const okConsumer = consumers.find((c) => c.contractId === 'http::GET::/api/items'); + expect(okConsumer).toBeDefined(); + expect(okConsumer!.meta.framework).toBe('okhttp'); + }); + + itKotlinConsumer( + 'OkHttp Request.Builder().url("/x").post(body) — verb defaults to GET (Java parity)', + async () => { + // Anti-overreach / known-limitation pin: OkHttp encodes the + // HTTP verb on a sibling call (`.post(body)` / `.delete()` / + // ...), not on `.url(...)`. The query at `kotlin.ts:OK_HTTP_PATTERNS` + // intentionally does not walk the chain to recover the verb — + // it emits `method: 'GET'` for every match, mirroring the Java + // plugin's `OK_HTTP_PATTERNS` (java.ts). + // + // This test pins the accepted behavior so a future verb-walk + // implementation must update kotlin.ts's known-limitation + // comment in lockstep. Concretely: + // - `Request.Builder().url("/api/users").post(body).build()` + // → ONE consumer: `http::GET::/api/users` (heuristic-default) + // → NO `http::POST::/api/users` consumer + // + // Test signal: + // - if this becomes correct (POST detected) without updating + // the kotlin.ts comment + java.ts behavior together, this + // test goes red and the reviewer must reconcile both sides. + const dir = path.join(tmpDir, 'kotlin-okhttp-post-chain'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'OkPostClient.kt'), + `package com.example +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody + +class OkPostClient(private val client: OkHttpClient, private val body: RequestBody) { + fun create() { + val req = Request.Builder().url("/api/users").post(body).build() + client.newCall(req).execute() + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + const fromThisFile = consumers.filter((c) => + c.symbolRef.filePath.endsWith('OkPostClient.kt'), + ); + + // Heuristic-default GET: exactly one consumer is emitted for + // the .url("/x") capture, with method=GET regardless of the + // sibling .post(body) call. + expect(fromThisFile).toHaveLength(1); + expect(fromThisFile[0].contractId).toBe('http::GET::/api/users'); + expect(fromThisFile[0].meta.method).toBe('GET'); + + // Anti-overreach: no second contract with POST should appear. + // If a future verb-walk lands and this assertion needs to flip + // (i.e. POST is now detected), bump kotlin.ts's known-limitation + // comment and java.ts in the same PR. + expect(fromThisFile.find((c) => c.contractId === 'http::POST::/api/users')).toBeUndefined(); + }, + ); + + itKotlinConsumer( + 'does NOT match Kotlin WebClient long form (deferred to follow-up)', + async () => { + // Anti-overreach: confirm the short-form query does NOT + // accidentally fire on the long-form chain + // `webClient.method(HttpMethod.GET).uri(...)`. The long form + // is intentionally unsupported in this PR; if a future change + // to the short-form query starts capturing it we want a loud + // signal here. Long-form support will arrive in a follow-up + // with a dedicated query + verb walk-up helper. + const dir = path.join(tmpDir, 'kotlin-web-client-long'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'LegacyClient.kt'), + `package com.example +import org.springframework.http.HttpMethod +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.awaitBody + +class LegacyClient(private val webClient: WebClient) { + suspend fun run() { + val r = webClient.method(HttpMethod.GET).uri("/api/legacy").retrieve().awaitBody() + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + // No consumer should be emitted from this file by the + // current short-form query. Documented as a known limitation. + const fromLegacy = consumers.filter((c) => + c.symbolRef.filePath.endsWith('LegacyClient.kt'), + ); + expect(fromLegacy).toHaveLength(0); + }, + ); + + itKotlinConsumer( + 'does NOT pick up unrelated string-literal calls on a non-restTemplate receiver', + async () => { + // Anti-regression: the RestTemplate receiver constraint + // (#eq? @obj "restTemplate") must hold. A field with a + // different conventional name (e.g. `cacheClient`) calling + // `.getForObject("/x", ...)` should NOT produce a route. + const dir = path.join(tmpDir, 'kotlin-rest-template-other-receiver'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'CacheClient.kt'), + `package com.example + +class CacheClient(private val cacheClient: SomeCache) { + fun run() { + cacheClient.getForObject("/cache/key", String::class.java) + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers.find((c) => c.contractId === 'http::GET::/cache/key')).toBeUndefined(); + const fromCache = consumers.filter((c) => c.symbolRef.filePath.endsWith('CacheClient.kt')); + expect(fromCache).toHaveLength(0); + }, + ); + it('extracts Go stdlib and resty calls', async () => { const dir = path.join(tmpDir, 'go-consumer'); fs.mkdirSync(path.join(dir, 'cmd'), { recursive: true }); 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/ignore-service.test.ts b/gitnexus/test/unit/ignore-service.test.ts index cd5ebdb4e..9f989bbcc 100644 --- a/gitnexus/test/unit/ignore-service.test.ts +++ b/gitnexus/test/unit/ignore-service.test.ts @@ -175,6 +175,11 @@ describe('shouldIgnorePath', () => { expect(shouldIgnorePath('src/api.generated.ts')).toBe(true); }); + it('ignores generated Monaco editor worker bundles', () => { + expect(shouldIgnorePath('public/monaco-workers/json.worker.js')).toBe(true); + expect(shouldIgnorePath('keep-ui/public/monaco-workers/125.js')).toBe(true); + }); + it('ignores TypeScript declaration files', () => { expect(shouldIgnorePath('types/index.d.ts')).toBe(true); }); @@ -217,6 +222,7 @@ describe('isHardcodedIgnoredDirectory', () => { expect(isHardcodedIgnoredDirectory('node_modules')).toBe(true); expect(isHardcodedIgnoredDirectory('.git')).toBe(true); expect(isHardcodedIgnoredDirectory('dist')).toBe(true); + expect(isHardcodedIgnoredDirectory('monaco-workers')).toBe(true); expect(isHardcodedIgnoredDirectory('__pycache__')).toBe(true); }); @@ -318,6 +324,13 @@ describe('.gitnexusignore negation overrides hardcoded DEFAULT_IGNORE_LIST (#771 expect(filter.childrenIgnored(mkPath('.git'))).toBe(true); }); + it('explicit negation can still opt into generated Monaco worker bundles', async () => { + await fs.writeFile(path.join(tmpDir, '.gitnexusignore'), '!public/monaco-workers/\n'); + const filter = await createIgnoreFilter(tmpDir); + expect(filter.childrenIgnored(mkPath('public/monaco-workers'))).toBe(false); + expect(filter.ignored(mkPath('public/monaco-workers/json.worker.js'))).toBe(false); + }); + it('standard `.gitignore` rules (no negation) still layer on top of hardcoded', async () => { // Pre-#771 behaviour: if .gitnexusignore says `my-dir/`, that dir // is ignored in addition to the hardcoded list. Non-negation diff --git a/gitnexus/test/unit/impact-pagination.test.ts b/gitnexus/test/unit/impact-pagination.test.ts new file mode 100644 index 000000000..a9d604c26 --- /dev/null +++ b/gitnexus/test/unit/impact-pagination.test.ts @@ -0,0 +1,385 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const executeQueryMock = vi.fn(); +const executeParameterizedMock = vi.fn(); + +vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + initLbug: vi.fn(), + executeQuery: (...args: any[]) => executeQueryMock(...args), + executeParameterized: (...args: any[]) => executeParameterizedMock(...args), + closeLbug: vi.fn(), + isLbugReady: vi.fn().mockReturnValue(true), + }; +}); +vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + initLbug: vi.fn(), + executeQuery: (...args: any[]) => executeQueryMock(...args), + executeParameterized: (...args: any[]) => executeParameterizedMock(...args), + closeLbug: vi.fn(), + isLbugReady: vi.fn().mockReturnValue(true), + }; +}); + +import { LocalBackend } from '../../src/mcp/local/local-backend'; +import { collectImpactSymbolUids } from '../../src/core/group/cross-impact'; + +function makeBackend() { + const backend = new LocalBackend(); + const repoHandle = { + id: 'repo1', + name: 'repo1', + repoPath: '/tmp/repo', + storagePath: '/tmp/repo/.gitnexus', + lbugPath: '/tmp/repo/.gitnexus/lbug', + indexedAt: 'now', + lastCommit: 'c', + stats: {}, + } as any; + (backend as any).repos.set(repoHandle.id, repoHandle); + (backend as any).ensureInitialized = vi.fn().mockResolvedValue(undefined); + return { backend, repoHandle }; +} + +function setupMultiDepthHub(d1Count: number, d2Count: number) { + let depth = 0; + executeParameterizedMock.mockImplementation(async (...args: any[]) => { + const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('STEP_IN_PROCESS')) return []; + if (query.includes('MEMBER_OF')) return []; + return [{ id: 'hub1', name: 'HubSymbol', filePath: 'hub.ts' }]; + }); + + executeQueryMock.mockImplementation(async () => { + depth++; + const count = depth === 1 ? d1Count : depth === 2 ? d2Count : 0; + const res: any[] = []; + for (let i = 0; i < count; i++) { + res.push({ + id: `d${depth}-caller-${i}`, + name: `d${depth}caller${i}`, + filePath: `src/d${depth}-caller-${i}.ts`, + relType: 'CALLS', + confidence: null, + }); + } + return res; + }); +} + +function setupHubSymbol(count: number) { + executeParameterizedMock.mockImplementation(async (...args: any[]) => { + const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('STEP_IN_PROCESS')) return []; + if (query.includes('MEMBER_OF')) return []; + return [{ id: 'hub1', name: 'HubSymbol', filePath: 'hub.ts' }]; + }); + + executeQueryMock.mockImplementation(async (...args: any[]) => { + const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('r.type IN') && !query.includes('STEP_IN_PROCESS')) { + const res: any[] = []; + for (let i = 0; i < count; i++) { + res.push({ + id: `caller-${i}`, + name: `caller${i}`, + filePath: `src/caller-${i}.ts`, + relType: 'CALLS', + confidence: null, + }); + } + return res; + } + return []; + }); +} + +describe('impact: pagination and summaryOnly (#414)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns byDepthCounts in default response', async () => { + const { backend, repoHandle } = makeBackend(); + setupHubSymbol(50); + + const res = await (backend as any)._impactImpl(repoHandle, { + target: 'HubSymbol', + direction: 'upstream', + maxDepth: 1, + }); + + expect(res.byDepthCounts).toEqual({ 1: 50 }); + expect(res.impactedCount).toBe(50); + expect(res.byDepth).toBeDefined(); + expect(res.byDepth[1].length).toBe(50); + }); + + it('limit caps byDepth symbols per depth level', async () => { + const { backend, repoHandle } = makeBackend(); + setupHubSymbol(200); + + const res = await (backend as any)._impactImpl(repoHandle, { + target: 'HubSymbol', + direction: 'upstream', + maxDepth: 1, + limit: 20, + }); + + expect(res.impactedCount).toBe(200); + expect(res.byDepthCounts).toEqual({ 1: 200 }); + expect(res.byDepth[1].length).toBe(20); + expect(res.pagination).toEqual({ + limit: 20, + offset: 0, + truncated: true, + }); + }); + + it('offset skips symbols before applying limit', async () => { + const { backend, repoHandle } = makeBackend(); + setupHubSymbol(200); + + const res = await (backend as any)._impactImpl(repoHandle, { + target: 'HubSymbol', + direction: 'upstream', + maxDepth: 1, + limit: 20, + offset: 10, + }); + + expect(res.byDepth[1].length).toBe(20); + expect(res.byDepth[1][0].name).toBe('caller10'); + expect(res.pagination).toEqual({ + limit: 20, + offset: 10, + truncated: true, + }); + }); + + it('no pagination metadata when all results fit within limit', async () => { + const { backend, repoHandle } = makeBackend(); + setupHubSymbol(30); + + const res = await (backend as any)._impactImpl(repoHandle, { + target: 'HubSymbol', + direction: 'upstream', + maxDepth: 1, + limit: 50, + }); + + expect(res.byDepth[1].length).toBe(30); + expect(res.pagination).toBeUndefined(); + }); + + it('default limit of 100 caps large result sets', async () => { + const { backend, repoHandle } = makeBackend(); + setupHubSymbol(400); + + const res = await (backend as any)._impactImpl(repoHandle, { + target: 'HubSymbol', + direction: 'upstream', + maxDepth: 1, + }); + + expect(res.impactedCount).toBe(400); + expect(res.byDepthCounts).toEqual({ 1: 400 }); + expect(res.byDepth[1].length).toBe(100); + expect(res.pagination).toEqual({ + limit: 100, + offset: 0, + truncated: true, + }); + }); + + it('summaryOnly omits byDepth entirely', async () => { + const { backend, repoHandle } = makeBackend(); + setupHubSymbol(400); + + const res = await (backend as any)._impactImpl(repoHandle, { + target: 'HubSymbol', + direction: 'upstream', + maxDepth: 1, + summaryOnly: true, + }); + + expect(res.impactedCount).toBe(400); + expect(res.risk).toBe('CRITICAL'); + expect(res.byDepthCounts).toEqual({ 1: 400 }); + expect(res.summary.direct).toBe(400); + expect(res.affected_processes).toBeDefined(); + expect(res.affected_modules).toBeDefined(); + expect(res.byDepth).toBeUndefined(); + expect(res.pagination).toBeUndefined(); + }); + + it('summaryOnly response is small even for hub symbols', async () => { + const { backend, repoHandle } = makeBackend(); + setupHubSymbol(800); + + const res = await (backend as any)._impactImpl(repoHandle, { + target: 'HubSymbol', + direction: 'upstream', + maxDepth: 1, + summaryOnly: true, + }); + + expect(res.impactedCount).toBe(800); + expect(res.byDepthCounts).toEqual({ 1: 800 }); + expect(res.byDepth).toBeUndefined(); + expect(res.pagination).toBeUndefined(); + }); + + it('limit clamps to 1–10000 range', async () => { + const { backend, repoHandle } = makeBackend(); + setupHubSymbol(10); + + const resZero = await (backend as any)._impactImpl(repoHandle, { + target: 'HubSymbol', + direction: 'upstream', + maxDepth: 1, + limit: 0, + }); + expect(resZero.byDepth[1].length).toBe(1); + + const resNeg = await (backend as any)._impactImpl(repoHandle, { + target: 'HubSymbol', + direction: 'upstream', + maxDepth: 1, + limit: -5, + }); + expect(resNeg.byDepth[1].length).toBe(1); + }); + + it('multi-depth: each depth paginates independently', async () => { + const { backend, repoHandle } = makeBackend(); + setupMultiDepthHub(150, 50); + + const res = await (backend as any)._impactImpl(repoHandle, { + target: 'HubSymbol', + direction: 'upstream', + maxDepth: 2, + limit: 30, + }); + + expect(res.impactedCount).toBe(200); + expect(res.byDepthCounts).toEqual({ 1: 150, 2: 50 }); + expect(res.byDepth[1].length).toBe(30); + expect(res.byDepth[2].length).toBe(30); + expect(res.pagination.truncated).toBe(true); + }); + + it('offset-only truncation: pagination metadata present when offset > 0 even if tail fits', async () => { + const { backend, repoHandle } = makeBackend(); + setupHubSymbol(50); + + const res = await (backend as any)._impactImpl(repoHandle, { + target: 'HubSymbol', + direction: 'upstream', + maxDepth: 1, + limit: 100, + offset: 10, + }); + + expect(res.byDepth[1].length).toBe(40); + expect(res.pagination).toBeDefined(); + expect(res.pagination.truncated).toBe(true); + expect(res.pagination.offset).toBe(10); + }); + + it('offset past end: returns empty byDepth with pagination metadata', async () => { + const { backend, repoHandle } = makeBackend(); + setupHubSymbol(50); + + const res = await (backend as any)._impactImpl(repoHandle, { + target: 'HubSymbol', + direction: 'upstream', + maxDepth: 1, + limit: 20, + offset: 100, + }); + + expect(res.impactedCount).toBe(50); + expect(res.byDepthCounts).toEqual({ 1: 50 }); + expect(res.byDepth[1].length).toBe(0); + expect(res.pagination).toBeDefined(); + expect(res.pagination.truncated).toBe(true); + }); + + it('float limit/offset are truncated to integers', async () => { + const { backend, repoHandle } = makeBackend(); + setupHubSymbol(50); + + const res = await (backend as any)._impactImpl(repoHandle, { + target: 'HubSymbol', + direction: 'upstream', + maxDepth: 1, + limit: 20.7, + offset: 5.9, + }); + + expect(res.byDepth[1].length).toBe(20); + expect(res.byDepth[1][0].name).toBe('caller5'); + expect(res.pagination.limit).toBe(20); + expect(res.pagination.offset).toBe(5); + }); + + it('_runImpactBFS without limit returns all symbols (internal caller path)', async () => { + const { backend, repoHandle } = makeBackend(); + setupHubSymbol(400); + + const sym = { id: 'hub1', name: 'HubSymbol', filePath: 'hub.ts' }; + const res = await (backend as any)._runImpactBFS(repoHandle, sym, 'Function', 'upstream', { + maxDepth: 1, + relationTypes: ['CALLS'], + includeTests: false, + minConfidence: 0, + }); + + expect(res.impactedCount).toBe(400); + expect(res.byDepth[1].length).toBe(400); + expect(res.pagination).toBeUndefined(); + }); +}); + +describe('collectImpactSymbolUids with paginated results', () => { + it('collects all UIDs from complete byDepth', () => { + const impact = { + target: { id: 'target1', filePath: 'src/target.ts' }, + byDepth: { + 1: [ + { id: 'a', filePath: 'src/a.ts' }, + { id: 'b', filePath: 'src/b.ts' }, + { id: 'c', filePath: 'src/c.ts' }, + ], + }, + }; + const { uids } = collectImpactSymbolUids(impact, undefined); + expect(uids).toContain('target1'); + expect(uids).toContain('a'); + expect(uids).toContain('b'); + expect(uids).toContain('c'); + expect(uids.length).toBe(4); + }); + + it('only gets paginated subset when byDepth is capped', () => { + const impact = { + target: { id: 'target1', filePath: 'src/target.ts' }, + byDepthCounts: { 1: 300 }, + byDepth: { + 1: Array.from({ length: 100 }, (_, i) => ({ + id: `sym-${i}`, + filePath: `src/sym-${i}.ts`, + })), + }, + pagination: { limit: 100, offset: 0, truncated: true }, + }; + const { uids } = collectImpactSymbolUids(impact, undefined); + expect(uids.length).toBe(101); + }); +}); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index 17ec9c2e1..b67c27fb9 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -23,6 +23,7 @@ const minimalResult = (overrides: Partial = {}): ParseWorkerR heritage: [], routes: [], fetchCalls: [], + fetchWrapperDefs: [], decoratorRoutes: [], toolDefs: [], ormQueries: [], diff --git a/gitnexus/test/unit/lbug-native-check.test.ts b/gitnexus/test/unit/lbug-native-check.test.ts new file mode 100644 index 000000000..19bbab94a --- /dev/null +++ b/gitnexus/test/unit/lbug-native-check.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import os from 'os'; +import path from 'path'; +import fs from 'fs/promises'; +import { checkLbugNative } from '../../src/core/lbug/native-check.js'; + +describe('checkLbugNative', () => { + it('returns ok:true when the real @ladybugdb/core binary is present', () => { + const result = checkLbugNative(); + expect(result.ok).toBe(true); + expect(result.binaryPath).toBeDefined(); + expect(result.message).toBeUndefined(); + }); + + it('returns ok:false with repair instructions when lbugjs.node is missing', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'lbug-check-')); + try { + await fs.writeFile(path.join(tmpDir, 'install.js'), ''); + + const result = checkLbugNative(tmpDir); + + expect(result.ok).toBe(false); + expect(result.message).toContain('missing'); + expect(result.message).toContain('install.js'); + expect(result.message).toContain('trustedDependencies'); + expect(result.message).toContain('ignore-scripts'); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it('returns ok:false when lbugjs.node exists but is unloadable (zero-byte)', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'lbug-check-')); + try { + await fs.writeFile(path.join(tmpDir, 'lbugjs.node'), Buffer.alloc(0)); + + const result = checkLbugNative(tmpDir); + + expect(result.ok).toBe(false); + expect(result.message).toContain('failed to load'); + expect(result.message).toContain('install.js'); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/gitnexus/test/unit/lbug-native-safe-path.test.ts b/gitnexus/test/unit/lbug-native-safe-path.test.ts new file mode 100644 index 000000000..5d13b6a36 --- /dev/null +++ b/gitnexus/test/unit/lbug-native-safe-path.test.ts @@ -0,0 +1,63 @@ +/** + * Unit Tests: toNativeSafePath + * + * Verifies the Windows non-ASCII path workaround that converts paths to + * 8.3 short-name form before passing them to KuzuDB's native layer. + */ +import { describe, it, expect } from 'vitest'; +import { toNativeSafePath, cleanupNativePathJunctions } from '../../src/core/lbug/lbug-config.js'; + +describe('toNativeSafePath', () => { + it('returns ASCII paths unchanged on any platform', () => { + const p = 'C:\\Users\\test\\project\\.gitnexus\\lbug'; + expect(toNativeSafePath(p)).toBe(p); + }); + + it('returns forward-slash ASCII paths unchanged', () => { + const p = '/home/user/project/.gitnexus/lbug'; + expect(toNativeSafePath(p)).toBe(p); + }); + + it('returns empty string unchanged', () => { + expect(toNativeSafePath('')).toBe(''); + }); + + if (process.platform !== 'win32') { + it('returns non-ASCII paths unchanged on non-Windows', () => { + const p = '/home/用户/project/.gitnexus/lbug'; + expect(toNativeSafePath(p)).toBe(p); + }); + } + + if (process.platform === 'win32') { + it('converts a path with non-ASCII parent directory to an ASCII-safe form', () => { + // Real-world scenario: repo at C:\Project\中文\code, leaf is ASCII (.gitnexus/lbug). + // Create a CJK parent dir with an ASCII leaf to match. + const os = require('os'); + const fs = require('fs'); + const path = require('path'); + const cjkParent = path.join(os.tmpdir(), `gn-safepath-测试-${Date.now()}`); + const asciiLeaf = path.join(cjkParent, 'lbug'); + fs.mkdirSync(cjkParent, { recursive: true }); + try { + const result = toNativeSafePath(asciiLeaf); + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + // Either 8.3 short path or junction — both produce an all-ASCII result + // since the leaf ('lbug') is ASCII and the parent is resolved + if (result !== asciiLeaf) { + expect(/^[\x00-\x7F]+$/.test(result)).toBe(true); + } + } finally { + cleanupNativePathJunctions(); + fs.rmSync(cjkParent, { recursive: true, force: true }); + } + }); + + it('returns the original path when the target does not exist', () => { + const nonexistent = 'C:\\不存在的路径\\test'; + const result = toNativeSafePath(nonexistent); + expect(result).toBe(nonexistent); + }); + } +}); diff --git a/gitnexus/test/unit/lbug-pool-win-fts-probe.test.ts b/gitnexus/test/unit/lbug-pool-win-fts-probe.test.ts index fec1fe471..4a164307f 100644 --- a/gitnexus/test/unit/lbug-pool-win-fts-probe.test.ts +++ b/gitnexus/test/unit/lbug-pool-win-fts-probe.test.ts @@ -37,6 +37,7 @@ vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({ })); vi.mock('../../src/core/lbug/lbug-config.js', () => ({ createLbugDatabase: vi.fn(), + toNativeSafePath: vi.fn((p: string) => p), isWalCorruptionError: vi.fn(() => false), WAL_RECOVERY_SUGGESTION: '', })); diff --git a/gitnexus/test/unit/parse-impl-worker-lazy-cache.test.ts b/gitnexus/test/unit/parse-impl-worker-lazy-cache.test.ts index 44242b02d..789cc251d 100644 --- a/gitnexus/test/unit/parse-impl-worker-lazy-cache.test.ts +++ b/gitnexus/test/unit/parse-impl-worker-lazy-cache.test.ts @@ -39,6 +39,7 @@ const emptyWorkerResult = (filePath: string, name: string): ParseWorkerResult => heritage: [], routes: [], fetchCalls: [], + fetchWrapperDefs: [], decoratorRoutes: [], toolDefs: [], ormQueries: [], @@ -73,7 +74,7 @@ fs.writeFileSync(${JSON.stringify(markerPath)}, 'spawned'); parentPort.postMessage({ type: 'ready' }); const accumulated = { nodes: [], relationships: [], symbols: [], imports: [], calls: [], assignments: [], heritage: [], - routes: [], fetchCalls: [], decoratorRoutes: [], toolDefs: [], ormQueries: [], constructorBindings: [], + routes: [], fetchCalls: [], fetchWrapperDefs: [], decoratorRoutes: [], toolDefs: [], ormQueries: [], constructorBindings: [], fileScopeBindings: [], parsedFiles: [], skippedLanguages: {}, fileCount: 0, }; parentPort.on('message', (msg) => { diff --git a/gitnexus/test/unit/pool-wal-recovery.test.ts b/gitnexus/test/unit/pool-wal-recovery.test.ts index 647c80f1e..03f41232f 100644 --- a/gitnexus/test/unit/pool-wal-recovery.test.ts +++ b/gitnexus/test/unit/pool-wal-recovery.test.ts @@ -35,6 +35,7 @@ vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({ vi.mock('../../src/core/lbug/lbug-config.js', () => ({ createLbugDatabase: vi.fn(), + toNativeSafePath: vi.fn((p: string) => p), LBUG_MAX_DB_SIZE: 1024, WAL_RECOVERY_SUGGESTION: 'WAL corruption detected. Run `gitnexus analyze --force` to rebuild the index.', diff --git a/gitnexus/test/unit/registry-primary-flag.test.ts b/gitnexus/test/unit/registry-primary-flag.test.ts index 856dcef43..991bc5fe1 100644 --- a/gitnexus/test/unit/registry-primary-flag.test.ts +++ b/gitnexus/test/unit/registry-primary-flag.test.ts @@ -108,20 +108,20 @@ describe('isRegistryPrimary', () => { it('isolates flags per-language (one on does not affect others)', () => { process.env['REGISTRY_PRIMARY_PYTHON'] = 'true'; expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true); - // Ruby is not in MIGRATED_LANGUAGES — default false stays + // Swift is not in MIGRATED_LANGUAGES — default false stays // false regardless of Python's flag. - expect(isRegistryPrimary(SupportedLanguages.Ruby)).toBe(false); + expect(isRegistryPrimary(SupportedLanguages.Swift)).toBe(false); }); it('respects a mid-process env-var mutation (no stale cache)', () => { - // Use Ruby — not in MIGRATED_LANGUAGES — so the unset default is + // Use Swift — not in MIGRATED_LANGUAGES — so the unset default is // deterministically `false`, independent of which languages have // been flipped to registry-primary. - expect(isRegistryPrimary(SupportedLanguages.Ruby)).toBe(false); - process.env['REGISTRY_PRIMARY_RUBY'] = 'true'; - expect(isRegistryPrimary(SupportedLanguages.Ruby)).toBe(true); - delete process.env['REGISTRY_PRIMARY_RUBY']; - expect(isRegistryPrimary(SupportedLanguages.Ruby)).toBe(false); + expect(isRegistryPrimary(SupportedLanguages.Swift)).toBe(false); + process.env['REGISTRY_PRIMARY_SWIFT'] = 'true'; + expect(isRegistryPrimary(SupportedLanguages.Swift)).toBe(true); + delete process.env['REGISTRY_PRIMARY_SWIFT']; + expect(isRegistryPrimary(SupportedLanguages.Swift)).toBe(false); }); it('handles the CPlusPlus → REGISTRY_PRIMARY_CPP mapping correctly', () => { diff --git a/gitnexus/test/unit/scope-resolution/cpp/cpp-overload-ranking.test.ts b/gitnexus/test/unit/scope-resolution/cpp/cpp-overload-ranking.test.ts index 5cb679a37..93cf86dc2 100644 --- a/gitnexus/test/unit/scope-resolution/cpp/cpp-overload-ranking.test.ts +++ b/gitnexus/test/unit/scope-resolution/cpp/cpp-overload-ranking.test.ts @@ -1,6 +1,10 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import type { ParameterTypeClass, SymbolDefinition } from 'gitnexus-shared'; import { cppConversionRank } from '../../../../src/core/ingestion/languages/cpp/conversion-rank.js'; +import { + clearCppUserDefinedConversions, + registerCppUserDefinedConversion, +} from '../../../../src/core/ingestion/languages/cpp/user-defined-conversions.js'; import { narrowOverloadCandidates } from '../../../../src/core/ingestion/scope-resolution/passes/overload-narrowing.js'; const value = (base: string): ParameterTypeClass => ({ @@ -40,6 +44,10 @@ const mkDef = ( parameterTypeClasses: [...parameterTypeClasses], }); +afterEach(() => { + clearCppUserDefinedConversions(); +}); + describe('cppConversionRank pointer/nullptr/ellipsis ranks (#1637)', () => { it('ranks nullptr -> T* ahead of nullptr -> bool', () => { expect(cppConversionRank('null', 'int', value('null'), pointer('int'))).toBe(2); @@ -57,7 +65,33 @@ describe('cppConversionRank pointer/nullptr/ellipsis ranks (#1637)', () => { }); it('ranks ellipsis as the worst viable conversion', () => { - expect(cppConversionRank('int', '...', value('int'), ellipsis())).toBe(4); + expect(cppConversionRank('int', '...', value('int'), ellipsis())).toBe(5); + }); +}); + +describe('cppConversionRank user-defined conversion ranks (#1631)', () => { + it('ranks registered one-step user-defined conversions after standard conversions', () => { + clearCppUserDefinedConversions(); + registerCppUserDefinedConversion('int', 'Wrap'); + + expect(cppConversionRank('int', 'Wrap', value('int'), value('Wrap'))).toBe(4); + expect(cppConversionRank('int', 'double', value('int'), value('double'))).toBe(2); + }); + + it('keeps tied user-defined conversion candidates ambiguous', () => { + clearCppUserDefinedConversions(); + registerCppUserDefinedConversion('int', 'WrapA'); + registerCppUserDefinedConversion('int', 'WrapB'); + + const byWrapA = mkDef('h:WrapA', ['WrapA'], [value('WrapA')]); + const byWrapB = mkDef('h:WrapB', ['WrapB'], [value('WrapB')]); + + const result = narrowOverloadCandidates([byWrapA, byWrapB], 1, ['int'], { + argumentTypeClasses: [value('int')], + conversionRankFn: cppConversionRank, + }); + + expect(result.map((d) => d.nodeId)).toEqual(['h:WrapA', 'h:WrapB']); }); }); diff --git a/gitnexus/test/unit/scope-resolution/typescript/typescript-imports.test.ts b/gitnexus/test/unit/scope-resolution/typescript/typescript-imports.test.ts index 8d4252a16..b372991d6 100644 --- a/gitnexus/test/unit/scope-resolution/typescript/typescript-imports.test.ts +++ b/gitnexus/test/unit/scope-resolution/typescript/typescript-imports.test.ts @@ -6,7 +6,7 @@ * set of fake file paths (with and without tsconfig path aliases). */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import { emitTsScopeCaptures } from '../../../../src/core/ingestion/languages/typescript/captures.js'; import { splitImportStatement } from '../../../../src/core/ingestion/languages/typescript/import-decomposer.js'; import { interpretTsImport } from '../../../../src/core/ingestion/languages/typescript/interpret.js'; @@ -14,6 +14,9 @@ import { resolveTsImportTarget, type TsResolveContext, } from '../../../../src/core/ingestion/languages/typescript/import-target.js'; +import { buildSuffixIndex } from '../../../../src/core/ingestion/import-resolvers/utils.js'; +import * as importResolverUtils from '../../../../src/core/ingestion/import-resolvers/utils.js'; +import { typescriptScopeResolver } from '../../../../src/core/ingestion/languages/typescript/scope-resolver.js'; import type { SyntaxNode } from '../../../../src/core/ingestion/utils/ast-helpers.js'; import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; import { SupportedLanguages } from 'gitnexus-shared'; @@ -399,4 +402,68 @@ describe('resolveTsImportTarget — standard suffix + alias resolution', () => { ); expect(result).toBe('src/a.js'); }); + + it('uses a prebuilt suffix index for package-style imports', () => { + const parsed: ParsedImport = { + kind: 'named', + localName: 'Button', + importedName: 'Button', + targetRaw: 'components/Button', + }; + const files = ['src/main.ts', 'src/components/Button.ts']; + const index = buildSuffixIndex( + files.map((f) => f.toLowerCase()), + files, + ); + const result = resolveTsImportTarget( + parsed, + ctx('src/main.ts', files, { + allFileList: files, + normalizedFileList: files.map((f) => f.toLowerCase()), + index, + }), + ); + + expect(result).toBe('src/components/Button.ts'); + }); +}); + +describe('typescriptScopeResolver.resolveImportTarget — real wiring', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('builds suffix index and resolves package-style imports through the production path', () => { + const spy = vi.spyOn(importResolverUtils, 'buildSuffixIndex'); + const files = new Set(['src/main.ts', 'src/components/Button.ts']); + + const result = typescriptScopeResolver.resolveImportTarget!( + 'components/Button', + 'src/main.ts', + files, + ); + + expect(result).toBe('src/components/Button.ts'); + expect(spy).toHaveBeenCalled(); + }); + + it('returns null for unresolvable package-style imports', () => { + const files = new Set(['src/main.ts', 'src/utils/helper.ts']); + + const result = typescriptScopeResolver.resolveImportTarget!( + 'nonexistent/Module', + 'src/main.ts', + files, + ); + + expect(result).toBeNull(); + }); + + it('resolves relative imports through the scope-resolver entry point', () => { + const files = new Set(['src/main.ts', 'src/utils.ts']); + + const result = typescriptScopeResolver.resolveImportTarget!('./utils', 'src/main.ts', files); + + expect(result).toBe('src/utils.ts'); + }); }); 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-grouping-batch.test.ts b/gitnexus/test/unit/wiki-grouping-batch.test.ts new file mode 100644 index 000000000..9acc80a8f --- /dev/null +++ b/gitnexus/test/unit/wiki-grouping-batch.test.ts @@ -0,0 +1,583 @@ +/** + * Unit tests for wiki grouping batching — budget-aware splitting and merge logic. + * + * Covers: + * - batchFilesForGrouping: partitions FileWithExports[] into budget-bounded batches + * - mergeGroupings: deterministic merge of partial grouping results + * - buildModuleTree: full flow with mocked LLM verifying single vs batched calls + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import os from 'os'; +import path from 'path'; +import fs from 'fs/promises'; + +// ─── batchFilesForGrouping ────────────────────────────────────────── + +describe('batchFilesForGrouping', () => { + let tmpDir: string; + + beforeEach(async () => { + vi.resetModules(); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'wiki-batch-test-')); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + function makeFiles( + count: number, + dir: string, + symbolsPerFile = 1, + ): Array<{ filePath: string; symbols: Array<{ name: string; type: string }> }> { + return Array.from({ length: count }, (_, i) => ({ + filePath: `${dir}/file${i}.ts`, + symbols: Array.from({ length: symbolsPerFile }, (_, j) => ({ + name: `export${i}_${j}`, + type: 'function', + })), + })); + } + + it('returns a single batch when all files fit within budget', async () => { + const { WikiGenerator } = await import('../../src/core/wiki/generator.js'); + + const gen = new WikiGenerator('/repo', tmpDir, '/lbug', { + apiKey: '', + baseUrl: '', + model: 'test', + maxTokens: 1000, + temperature: 0, + provider: 'openai', + }); + + const files = makeFiles(5, 'src'); + const batches = (gen as any).batchFilesForGrouping(files); + + expect(batches).toHaveLength(1); + expect(batches[0]).toHaveLength(5); + }); + + it('returns empty array for empty file list', async () => { + const { WikiGenerator } = await import('../../src/core/wiki/generator.js'); + + const gen = new WikiGenerator('/repo', tmpDir, '/lbug', { + apiKey: '', + baseUrl: '', + model: 'test', + maxTokens: 1000, + temperature: 0, + provider: 'openai', + }); + + const batches = (gen as any).batchFilesForGrouping([]); + expect(batches).toHaveLength(0); + }); + + it('splits into multiple batches when files exceed budget', async () => { + const { WikiGenerator } = await import('../../src/core/wiki/generator.js'); + + const gen = new WikiGenerator('/repo', tmpDir, '/lbug', { + apiKey: '', + baseUrl: '', + model: 'test', + maxTokens: 1000, + temperature: 0, + provider: 'openai', + }); + + // Create many files across many directories with lots of symbols to blow past budget + const files = [ + ...makeFiles(200, 'alpha', 50), + ...makeFiles(200, 'beta', 50), + ...makeFiles(200, 'gamma', 50), + ...makeFiles(200, 'delta', 50), + ]; + + const batches = (gen as any).batchFilesForGrouping(files); + + // Each ~60k-token directory exceeds half the 100k budget, so each gets its own batch + expect(batches.length).toBe(4); + + // Every input file appears in exactly one batch + const allBatchedFiles = batches.flat().map((f: any) => f.filePath); + const uniqueFiles = new Set(allBatchedFiles); + expect(uniqueFiles.size).toBe(files.length); + expect(allBatchedFiles.length).toBe(files.length); + }); + + it('sub-batches a single oversized directory', async () => { + const { WikiGenerator } = await import('../../src/core/wiki/generator.js'); + + const gen = new WikiGenerator('/repo', tmpDir, '/lbug', { + apiKey: '', + baseUrl: '', + model: 'test', + maxTokens: 1000, + temperature: 0, + provider: 'openai', + }); + + // All files in one directory, with enough symbols to exceed budget + const files = makeFiles(500, 'monolith', 80); + + const batches = (gen as any).batchFilesForGrouping(files); + + // Sub-batching must produce multiple batches from this single oversized directory + const batchCount = batches.length; + expect(batchCount).toBe(batches.length); // deterministic — pin to actual + expect(batchCount >= 2).toBe(true); + + // Every batch must fit within budget (single-file batches are symbol-truncated) + for (const batch of batches) { + const tokens = (gen as any).estimateGroupingPromptTokens(batch); + expect(tokens <= 100_000).toBe(true); + } + + // Every file still present + const allBatchedFiles = batches.flat().map((f: any) => f.filePath); + expect(new Set(allBatchedFiles).size).toBe(files.length); + }); + + it('truncates symbols on a single-file batch that exceeds budget', async () => { + const { WikiGenerator } = await import('../../src/core/wiki/generator.js'); + + const gen = new WikiGenerator('/repo', tmpDir, '/lbug', { + apiKey: '', + baseUrl: '', + model: 'test', + maxTokens: 1000, + temperature: 0, + provider: 'openai', + }); + + // One file with 10,000 symbols — well over 100k token budget + const files = [ + { + filePath: 'giant/barrel.ts', + symbols: Array.from({ length: 10_000 }, (_, i) => ({ + name: `veryLongExportedSymbolName_${i}_padding`, + type: 'function', + })), + }, + ]; + + const batches = (gen as any).batchFilesForGrouping(files); + + expect(batches).toHaveLength(1); + expect(batches[0]).toHaveLength(1); + expect(batches[0][0].filePath).toBe('giant/barrel.ts'); + + // Symbols must have been truncated to fit within budget + expect(batches[0][0].symbols.length).toBeLessThan(10_000); + + // The batch must now be within budget + const tokens = (gen as any).estimateGroupingPromptTokens(batches[0]); + expect(tokens <= 100_000).toBe(true); + + // The last symbol should be the truncation marker + const lastSym = batches[0][0].symbols[batches[0][0].symbols.length - 1]; + expect(lastSym.type).toBe('truncated'); + expect(lastSym.name).toContain('... and'); + }); +}); + +// ─── mergeGroupings ──────────────────────────────────────────────── + +describe('mergeGroupings', () => { + let tmpDir: string; + + beforeEach(async () => { + vi.resetModules(); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'wiki-merge-test-')); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('merges disjoint groupings', async () => { + const { WikiGenerator } = await import('../../src/core/wiki/generator.js'); + + const gen = new WikiGenerator('/repo', tmpDir, '/lbug', { + apiKey: '', + baseUrl: '', + model: 'test', + maxTokens: 1000, + temperature: 0, + provider: 'openai', + }); + + const result = (gen as any).mergeGroupings([ + { Auth: ['src/auth.ts'], DB: ['src/db.ts'] }, + { API: ['src/api.ts'] }, + ]); + + expect(result).toEqual({ + Auth: ['src/auth.ts'], + DB: ['src/db.ts'], + API: ['src/api.ts'], + }); + }); + + it('concatenates files under same module name across batches', async () => { + const { WikiGenerator } = await import('../../src/core/wiki/generator.js'); + + const gen = new WikiGenerator('/repo', tmpDir, '/lbug', { + apiKey: '', + baseUrl: '', + model: 'test', + maxTokens: 1000, + temperature: 0, + provider: 'openai', + }); + + const result = (gen as any).mergeGroupings([ + { Auth: ['src/auth.ts'] }, + { Auth: ['src/session.ts'], DB: ['src/db.ts'] }, + ]); + + expect(result).toEqual({ + Auth: ['src/auth.ts', 'src/session.ts'], + DB: ['src/db.ts'], + }); + }); + + it('deduplicates files across batches (first-seen wins)', async () => { + const { WikiGenerator } = await import('../../src/core/wiki/generator.js'); + + const gen = new WikiGenerator('/repo', tmpDir, '/lbug', { + apiKey: '', + baseUrl: '', + model: 'test', + maxTokens: 1000, + temperature: 0, + provider: 'openai', + }); + + const result = (gen as any).mergeGroupings([ + { Auth: ['src/auth.ts', 'src/shared.ts'] }, + { Core: ['src/shared.ts', 'src/core.ts'] }, + ]); + + expect(result.Auth).toEqual(['src/auth.ts', 'src/shared.ts']); + expect(result.Core).toEqual(['src/core.ts']); + }); + + it('returns empty object for empty input', async () => { + const { WikiGenerator } = await import('../../src/core/wiki/generator.js'); + + const gen = new WikiGenerator('/repo', tmpDir, '/lbug', { + apiKey: '', + baseUrl: '', + model: 'test', + maxTokens: 1000, + temperature: 0, + provider: 'openai', + }); + + const result = (gen as any).mergeGroupings([]); + expect(result).toEqual({}); + }); + + it('merges case-variant module names by slug (first-seen wins)', async () => { + const { WikiGenerator } = await import('../../src/core/wiki/generator.js'); + + const gen = new WikiGenerator('/repo', tmpDir, '/lbug', { + apiKey: '', + baseUrl: '', + model: 'test', + maxTokens: 1000, + temperature: 0, + provider: 'openai', + }); + + const result = (gen as any).mergeGroupings([ + { 'API Routes': ['src/routes.ts'] }, + { 'API routes': ['src/middleware.ts'], DB: ['src/db.ts'] }, + ]); + + expect(Object.keys(result)).toEqual(['API Routes', 'DB']); + expect(result['API Routes']).toEqual(['src/routes.ts', 'src/middleware.ts']); + expect(result['API routes']).toBeUndefined(); + }); + + it('merges punctuation-variant module names by slug', async () => { + const { WikiGenerator } = await import('../../src/core/wiki/generator.js'); + + const gen = new WikiGenerator('/repo', tmpDir, '/lbug', { + apiKey: '', + baseUrl: '', + model: 'test', + maxTokens: 1000, + temperature: 0, + provider: 'openai', + }); + + const result = (gen as any).mergeGroupings([ + { 'Database Layer': ['src/db.ts'] }, + { 'database-layer': ['src/pool.ts'] }, + ]); + + expect(Object.keys(result)).toEqual(['Database Layer']); + expect(result['Database Layer']).toEqual(['src/db.ts', 'src/pool.ts']); + }); +}); + +// ─── buildModuleTree batched flow ────────────────────────────────── + +describe('buildModuleTree batched grouping', () => { + let tmpDir: string; + + beforeEach(async () => { + vi.resetModules(); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'wiki-buildtree-test-')); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('uses single LLM call for small file lists', async () => { + const fakeFiles = [ + { filePath: 'src/auth.ts', symbols: [{ name: 'login', type: 'function' }] }, + { filePath: 'src/db.ts', symbols: [{ name: 'connect', type: 'function' }] }, + ]; + + vi.doMock('../../src/core/wiki/graph-queries.js', () => ({ + initWikiDb: vi.fn().mockResolvedValue(undefined), + closeWikiDb: vi.fn().mockResolvedValue(undefined), + touchWikiDb: vi.fn(), + getFilesWithExports: vi.fn().mockResolvedValue(fakeFiles), + getAllFiles: vi.fn().mockResolvedValue(fakeFiles.map((f) => f.filePath)), + getIntraModuleCallEdges: vi.fn().mockResolvedValue([]), + getInterModuleCallEdges: vi.fn().mockResolvedValue({ incoming: [], outgoing: [] }), + getProcessesForFiles: vi.fn().mockResolvedValue([]), + getAllProcesses: vi.fn().mockResolvedValue([]), + getInterModuleEdgesForOverview: vi.fn().mockResolvedValue([]), + })); + + vi.doMock('child_process', () => ({ + execSync: vi.fn().mockImplementation(() => { + throw new Error('not a git repo'); + }), + execFileSync: vi.fn(), + })); + + const llmClient = await import('../../src/core/wiki/llm-client.js'); + const callLLMSpy = vi.spyOn(llmClient, 'callLLM').mockResolvedValue({ + content: JSON.stringify({ + Auth: ['src/auth.ts'], + Database: ['src/db.ts'], + }), + }); + + const { WikiGenerator } = await import('../../src/core/wiki/generator.js'); + + const storagePath = path.join(tmpDir, 'storage'); + const wikiDir = path.join(storagePath, 'wiki'); + const repoPath = path.join(tmpDir, 'repo'); + await fs.mkdir(wikiDir, { recursive: true }); + await fs.mkdir(repoPath, { recursive: true }); + + const gen = new WikiGenerator( + repoPath, + storagePath, + path.join(storagePath, 'lbug'), + { + apiKey: 'key', + baseUrl: 'http://localhost', + model: 'test', + maxTokens: 1000, + temperature: 0, + provider: 'openai', + }, + { reviewOnly: true }, + ); + + const result = await gen.run(); + + expect(callLLMSpy).toHaveBeenCalledTimes(1); + expect(result.moduleTree).toBeDefined(); + expect(result.moduleTree!.length).toBe(2); + }); + + it('uses multiple LLM calls for oversized file lists and merges results', async () => { + // Generate enough files to exceed the 100k token budget + const dirs = ['alpha', 'beta', 'gamma', 'delta']; + const fakeFiles: Array<{ filePath: string; symbols: Array<{ name: string; type: string }> }> = + []; + for (const dir of dirs) { + for (let i = 0; i < 150; i++) { + fakeFiles.push({ + filePath: `${dir}/file${i}.ts`, + symbols: Array.from({ length: 60 }, (_, j) => ({ + name: `${dir}Export${i}_${j}`, + type: 'function', + })), + }); + } + } + + vi.doMock('../../src/core/wiki/graph-queries.js', () => ({ + initWikiDb: vi.fn().mockResolvedValue(undefined), + closeWikiDb: vi.fn().mockResolvedValue(undefined), + touchWikiDb: vi.fn(), + getFilesWithExports: vi.fn().mockResolvedValue(fakeFiles), + getAllFiles: vi.fn().mockResolvedValue(fakeFiles.map((f) => f.filePath)), + getIntraModuleCallEdges: vi.fn().mockResolvedValue([]), + getInterModuleCallEdges: vi.fn().mockResolvedValue({ incoming: [], outgoing: [] }), + getProcessesForFiles: vi.fn().mockResolvedValue([]), + getAllProcesses: vi.fn().mockResolvedValue([]), + getInterModuleEdgesForOverview: vi.fn().mockResolvedValue([]), + })); + + vi.doMock('child_process', () => ({ + execSync: vi.fn().mockImplementation(() => { + throw new Error('not a git repo'); + }), + execFileSync: vi.fn(), + })); + + const llmClient = await import('../../src/core/wiki/llm-client.js'); + let callCount = 0; + vi.spyOn(llmClient, 'callLLM').mockImplementation(async (prompt: string) => { + callCount++; + // Parse the file paths from the prompt to return them grouped + const fileRegex = /- ([^\s:]+):/g; + const files: string[] = []; + let match; + while ((match = fileRegex.exec(prompt)) !== null) { + files.push(match[1]); + } + const groupName = `Module${callCount}`; + return { content: JSON.stringify({ [groupName]: files }) }; + }); + + const { WikiGenerator } = await import('../../src/core/wiki/generator.js'); + + const storagePath = path.join(tmpDir, 'storage'); + const wikiDir = path.join(storagePath, 'wiki'); + const repoPath = path.join(tmpDir, 'repo'); + await fs.mkdir(wikiDir, { recursive: true }); + await fs.mkdir(repoPath, { recursive: true }); + + const gen = new WikiGenerator( + repoPath, + storagePath, + path.join(storagePath, 'lbug'), + { + apiKey: 'key', + baseUrl: 'http://localhost', + model: 'test', + maxTokens: 1000, + temperature: 0, + provider: 'openai', + }, + { reviewOnly: true }, + ); + + const result = await gen.run(); + + // Each ~60k-token directory exceeds half the 100k budget, so each gets its own batch + expect(callCount).toBe(4); + expect(result.moduleTree).toBeDefined(); + + // All 600 files should be accounted for + const allFiles = result.moduleTree!.flatMap((n: any) => + n.children ? n.children.flatMap((c: any) => c.files) : n.files, + ); + expect(allFiles.length).toBe(fakeFiles.length); + }); + + it('falls back to directory grouping when a batch LLM call fails', async () => { + const dirs = ['alpha', 'beta', 'gamma']; + const fakeFiles: Array<{ filePath: string; symbols: Array<{ name: string; type: string }> }> = + []; + for (const dir of dirs) { + for (let i = 0; i < 150; i++) { + fakeFiles.push({ + filePath: `${dir}/file${i}.ts`, + symbols: Array.from({ length: 60 }, (_, j) => ({ + name: `${dir}Export${i}_${j}`, + type: 'function', + })), + }); + } + } + + vi.doMock('../../src/core/wiki/graph-queries.js', () => ({ + initWikiDb: vi.fn().mockResolvedValue(undefined), + closeWikiDb: vi.fn().mockResolvedValue(undefined), + touchWikiDb: vi.fn(), + getFilesWithExports: vi.fn().mockResolvedValue(fakeFiles), + getAllFiles: vi.fn().mockResolvedValue(fakeFiles.map((f) => f.filePath)), + getIntraModuleCallEdges: vi.fn().mockResolvedValue([]), + getInterModuleCallEdges: vi.fn().mockResolvedValue({ incoming: [], outgoing: [] }), + getProcessesForFiles: vi.fn().mockResolvedValue([]), + getAllProcesses: vi.fn().mockResolvedValue([]), + getInterModuleEdgesForOverview: vi.fn().mockResolvedValue([]), + })); + + vi.doMock('child_process', () => ({ + execSync: vi.fn().mockImplementation(() => { + throw new Error('not a git repo'); + }), + execFileSync: vi.fn(), + })); + + const llmClient = await import('../../src/core/wiki/llm-client.js'); + let callCount = 0; + vi.spyOn(llmClient, 'callLLM').mockImplementation(async () => { + callCount++; + if (callCount === 2) throw new Error('LLM API error'); + return { content: JSON.stringify({ SomeModule: ['alpha/file0.ts'] }) }; + }); + + const { WikiGenerator } = await import('../../src/core/wiki/generator.js'); + + const storagePath = path.join(tmpDir, 'storage'); + const wikiDir = path.join(storagePath, 'wiki'); + const repoPath = path.join(tmpDir, 'repo'); + await fs.mkdir(wikiDir, { recursive: true }); + await fs.mkdir(repoPath, { recursive: true }); + + const gen = new WikiGenerator( + repoPath, + storagePath, + path.join(storagePath, 'lbug'), + { + apiKey: 'key', + baseUrl: 'http://localhost', + model: 'test', + maxTokens: 1000, + temperature: 0, + provider: 'openai', + }, + { reviewOnly: true }, + ); + + const result = await gen.run(); + + // Should have fallen back to directory-based grouping + expect(result.moduleTree).toBeDefined(); + const moduleNames = result.moduleTree!.map((n: any) => n.name); + // fallbackGrouping groups by top-level directory + expect(moduleNames).toContain('alpha'); + expect(moduleNames).toContain('beta'); + expect(moduleNames).toContain('gamma'); + + // First batch's LLM result ('SomeModule') must NOT leak through — nuclear fallback + // discards all partial results + expect(moduleNames).not.toContain('SomeModule'); + + // All files still accounted for + const allFiles = result.moduleTree!.flatMap((n: any) => + n.children ? n.children.flatMap((c: any) => c.files) : n.files, + ); + expect(allFiles.length).toBe(fakeFiles.length); + }); +}); diff --git a/gitnexus/test/unit/worker-pool-timeout-retire.test.ts b/gitnexus/test/unit/worker-pool-timeout-retire.test.ts new file mode 100644 index 000000000..0579ae6d6 --- /dev/null +++ b/gitnexus/test/unit/worker-pool-timeout-retire.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { EventEmitter } from 'node:events'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { createWorkerPool } from '../../src/core/ingestion/workers/worker-pool.js'; + +type FirstWorkerBehavior = 'stall' | 'delayed-safe-return'; + +class TimeoutThenHealthyWorker extends EventEmitter { + static instances: TimeoutThenHealthyWorker[] = []; + static firstWorkerBehavior: FirstWorkerBehavior = 'stall'; + static safeReturnDelayMs = 40; + + readonly id: number; + terminateCalls = 0; + unrefCalls = 0; + private currentPaths: string[] = []; + + constructor() { + super(); + this.id = TimeoutThenHealthyWorker.instances.length; + TimeoutThenHealthyWorker.instances.push(this); + queueMicrotask(() => this.emit('message', { type: 'ready' })); + } + + postMessage(msg: unknown): void { + if (msg === null || typeof msg !== 'object') return; + const type = (msg as { type?: unknown }).type; + if (type === 'sub-batch') { + const files = (msg as { files?: Array<{ path: string }> }).files ?? []; + this.currentPaths = files.map((file) => file.path); + if (this.id === 0) { + if (TimeoutThenHealthyWorker.firstWorkerBehavior === 'delayed-safe-return') { + setTimeout(() => { + this.emit('message', { type: 'sub-batch-done' }); + }, TimeoutThenHealthyWorker.safeReturnDelayMs); + } + return; + } + queueMicrotask(() => { + this.emit('message', { type: 'progress', filesProcessed: this.currentPaths.length }); + this.emit('message', { type: 'sub-batch-done' }); + }); + return; + } + if (type === 'flush') { + const paths = this.currentPaths.slice(); + queueMicrotask(() => this.emit('message', { type: 'result', data: { paths } })); + } + } + + async terminate(): Promise { + this.terminateCalls++; + this.emit('exit', 0); + return 0; + } + + unref(): void { + this.unrefCalls++; + } +} + +const waitFor = async ( + predicate: () => boolean, + message: string, + timeoutMs = 250, +): Promise => { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error(message); +}; + +let tempDir: string; +let workerUrl: URL; + +beforeEach(() => { + TimeoutThenHealthyWorker.instances = []; + TimeoutThenHealthyWorker.firstWorkerBehavior = 'stall'; + TimeoutThenHealthyWorker.safeReturnDelayMs = 40; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-worker-timeout-retire-')); + const workerPath = path.join(tempDir, 'fake-worker.js'); + fs.writeFileSync(workerPath, '// fake worker path for createWorkerPool'); + workerUrl = pathToFileURL(workerPath) as URL; +}); + +afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe('worker pool timeout retirement', () => { + it('does not immediately terminate a worker that timed out inside native parsing', async () => { + const pool = createWorkerPool(workerUrl, 1, { + subBatchIdleTimeoutMs: 20, + maxTimeoutRetries: 1, + timeoutBackoffFactor: 2, + workerFactory: () => + new TimeoutThenHealthyWorker() as unknown as import('node:worker_threads').Worker, + }); + + try { + const results = await pool.dispatch<{ path: string; content: string }, { paths: string[] }>([ + { path: 'src/native-stall.ts', content: 'const x = 1;' }, + ]); + + expect(results).toEqual([{ paths: ['src/native-stall.ts'] }]); + expect(TimeoutThenHealthyWorker.instances.length).toBeGreaterThanOrEqual(2); + expect(TimeoutThenHealthyWorker.instances[0].unrefCalls).toBe(1); + expect(TimeoutThenHealthyWorker.instances[0].terminateCalls).toBe(0); + + await pool.terminate(); + + expect(TimeoutThenHealthyWorker.instances[0].terminateCalls).toBe(1); + } finally { + await pool.terminate(); + } + }); + + it('terminates a retired worker once it returns to a JS-visible safe point', async () => { + TimeoutThenHealthyWorker.firstWorkerBehavior = 'delayed-safe-return'; + TimeoutThenHealthyWorker.safeReturnDelayMs = 35; + const pool = createWorkerPool(workerUrl, 1, { + subBatchIdleTimeoutMs: 10, + maxTimeoutRetries: 1, + timeoutBackoffFactor: 2, + workerFactory: () => + new TimeoutThenHealthyWorker() as unknown as import('node:worker_threads').Worker, + }); + + try { + const results = await pool.dispatch<{ path: string; content: string }, { paths: string[] }>([ + { path: 'src/native-stall.ts', content: 'const x = 1;' }, + ]); + + expect(results).toEqual([{ paths: ['src/native-stall.ts'] }]); + expect(TimeoutThenHealthyWorker.instances[0].unrefCalls).toBe(1); + await waitFor( + () => TimeoutThenHealthyWorker.instances[0]?.terminateCalls === 1, + 'Timed out waiting for retired worker to terminate after safe signal', + ); + + await pool.terminate(); + + expect(TimeoutThenHealthyWorker.instances[0].terminateCalls).toBe(1); + } finally { + await pool.terminate(); + } + }); + + it('terminates retired workers when the circuit breaker shuts the pool down', async () => { + const pool = createWorkerPool(workerUrl, 1, { + subBatchIdleTimeoutMs: 10, + maxTimeoutRetries: 1, + timeoutBackoffFactor: 2, + consecutiveFailureThreshold: 1, + workerFactory: () => + new TimeoutThenHealthyWorker() as unknown as import('node:worker_threads').Worker, + }); + + try { + await expect( + pool.dispatch<{ path: string; content: string }, { paths: string[] }>([ + { path: 'src/native-stall.ts', content: 'const x = 1;' }, + ]), + ).rejects.toThrow(/circuit breaker/i); + + await waitFor( + () => TimeoutThenHealthyWorker.instances[0]?.terminateCalls === 1, + 'Timed out waiting for circuit breaker cleanup to terminate retired worker', + ); + } finally { + await pool.terminate(); + } + }); +}); diff --git a/gitnexus/vitest.config.ts b/gitnexus/vitest.config.ts index 34ef25467..d8dc68835 100644 --- a/gitnexus/vitest.config.ts +++ b/gitnexus/vitest.config.ts @@ -69,6 +69,7 @@ export default defineConfig({ 'test/integration/lbug-orphan-sidecar-recovery.test.ts', 'test/integration/lbug-readonly-init.test.ts', 'test/integration/analyze-wal-checkpoint-failure.test.ts', + 'test/integration/lbug-non-ascii-path.test.ts', ], fileParallelism: false, sequence: { groupOrder: 1 }, @@ -101,6 +102,7 @@ export default defineConfig({ 'test/integration/lbug-orphan-sidecar-recovery.test.ts', 'test/integration/lbug-readonly-init.test.ts', 'test/integration/analyze-wal-checkpoint-failure.test.ts', + 'test/integration/lbug-non-ascii-path.test.ts', 'test/integration/skills-e2e.test.ts', ], },