From af421cc9a3c8618a41207faeb2eee10abd8a20e7 Mon Sep 17 00:00:00 2001 From: Abhigyan Patwari <126312502+abhigyanpatwari@users.noreply.github.com> Date: Wed, 1 Apr 2026 10:34:10 +0530 Subject: [PATCH 01/12] feat(web): repo landing screen with selectable repo cards (#607) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): add repo landing screen with selectable repo cards Instead of auto-loading the first indexed repo when the backend is detected, show a landing screen that lets users choose which repo to explore or analyze a new one. This addresses the UX gap where users with multiple indexed repos had no way to pick—they were always sent to the first one found. - New RepoLanding component with clickable repo cards (name, stats, indexed date) and an embedded RepoAnalyzer for new repos - DropZone gains a 'landing' phase between server detection and graph loading - Shared connectToRepo handler replaces the old handleAnalyzeComplete for both repo selection and post-analysis connection Made-with: Cursor * fix(web): update e2e flow for repo landing screen The new landing screen intentionally stops auto-loading the first indexed repo, so the existing Playwright tests were still waiting for the explorer to appear automatically. Update the specs to select a repo from the landing screen before asserting on the graph, and add a stable test id for repo cards. Also format DropZone to satisfy the Prettier CI check. Made-with: Cursor * fix(e2e): use waitFor instead of instant isVisible for landing card locator.isVisible() is a non-retrying instant check — the landing card hadn't rendered yet when it was called, causing the click to be silently skipped. Switch to waitFor which properly polls until the element appears. Made-with: Cursor --------- Co-authored-by: Abhigyan Patwari --- gitnexus-web/e2e/onboarding.spec.ts | 22 ++- gitnexus-web/e2e/server-connect.spec.ts | 22 ++- gitnexus-web/src/components/DropZone.tsx | 77 +++++----- gitnexus-web/src/components/RepoLanding.tsx | 148 ++++++++++++++++++++ 4 files changed, 215 insertions(+), 54 deletions(-) create mode 100644 gitnexus-web/src/components/RepoLanding.tsx diff --git a/gitnexus-web/e2e/onboarding.spec.ts b/gitnexus-web/e2e/onboarding.spec.ts index 309dc7ef9..da92ffb70 100644 --- a/gitnexus-web/e2e/onboarding.spec.ts +++ b/gitnexus-web/e2e/onboarding.spec.ts @@ -15,6 +15,20 @@ import { test, expect } from '@playwright/test'; const BACKEND_URL = 'http://localhost:4747'; +async function enterExploringView(page: import('@playwright/test').Page) { + await page.goto('/'); + + const landingCard = page.locator('[data-testid="landing-repo-card"]').first(); + try { + await landingCard.waitFor({ state: 'visible', timeout: 15_000 }); + await landingCard.click(); + } catch { + // Landing screen may not appear (e.g. ?server auto-connect) + } + + await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); +} + // ── Flow 1: Onboarding (no server running) ───────────────────────────────── test.describe('Flow 1: Onboarding — no server', () => { @@ -249,10 +263,7 @@ test.describe('Flow 4: Repo dropdown in exploring view', () => { }); test('project badge opens repo dropdown', async ({ page }, testInfo) => { - await page.goto('/'); - - // Wait for auto-connect to finish and exploring view to load - await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); + await enterExploringView(page); await page.screenshot({ path: testInfo.outputPath('exploring-loaded.png') }); // Click the project badge (has a chevron) @@ -269,8 +280,7 @@ test.describe('Flow 4: Repo dropdown in exploring view', () => { }); test('analyze option opens inline form', async ({ page }, testInfo) => { - await page.goto('/'); - await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); + await enterExploringView(page); // Open repo dropdown const badge = page diff --git a/gitnexus-web/e2e/server-connect.spec.ts b/gitnexus-web/e2e/server-connect.spec.ts index 5d1c307e3..eb241da10 100644 --- a/gitnexus-web/e2e/server-connect.spec.ts +++ b/gitnexus-web/e2e/server-connect.spec.ts @@ -49,23 +49,33 @@ test.beforeAll(async () => { }); /** - * Wait for the new auto-connect flow to complete. + * Wait for the server-detection flow to complete. * - * The app now auto-detects the server via polling and connects without - * any user interaction. We just need to wait for the exploring view. + * The app auto-detects the server, then either: + * - shows the landing screen when indexed repos exist, or + * - goes straight into analyze onboarding when there are zero repos. + * + * For these tests we require at least one indexed repo, so pick the first + * landing card when present and then wait for the exploring view. */ async function waitForGraphLoaded(page: import('@playwright/test').Page, testInfo: TestInfo) { await page.goto('/'); - // The app auto-connects: onboarding → success → loading → exploring. - // Wait for the status bar "Ready" indicator which confirms the graph is loaded. + const landingCard = page.locator('[data-testid="landing-repo-card"]').first(); + try { + await landingCard.waitFor({ state: 'visible', timeout: 15_000 }); + await landingCard.click(); + } catch { + // Landing screen may not appear (e.g. ?server auto-connect) + } + await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); await expect(page.getByText(/\d+ nodes/).first()).toBeVisible(); await page.screenshot({ path: testInfo.outputPath('graph-loaded.png') }); } test.describe('Server Connection & Graph Loading', () => { - test('auto-connects and loads graph', async ({ page }, testInfo) => { + test('selects a repo from landing and loads graph', async ({ page }, testInfo) => { await waitForGraphLoaded(page, testInfo); await page.screenshot({ path: testInfo.outputPath('graph-loaded-full.png'), fullPage: true }); }); diff --git a/gitnexus-web/src/components/DropZone.tsx b/gitnexus-web/src/components/DropZone.tsx index a0cb0cd37..f268eb656 100644 --- a/gitnexus-web/src/components/DropZone.tsx +++ b/gitnexus-web/src/components/DropZone.tsx @@ -1,9 +1,15 @@ import { useState, useRef, useEffect } from 'react'; import { Loader2, Check, Sparkles } from '@/lib/lucide-icons'; -import { connectToServer, fetchRepos, type ConnectResult } from '../services/backend-client'; +import { + connectToServer, + fetchRepos, + type ConnectResult, + type BackendRepo, +} from '../services/backend-client'; import { useBackend } from '../hooks/useBackend'; import { OnboardingGuide } from './OnboardingGuide'; import { AnalyzeOnboarding } from './AnalyzeOnboarding'; +import { RepoLanding } from './RepoLanding'; interface DropZoneProps { onServerConnect?: (result: ConnectResult, serverUrl?: string) => void | Promise; @@ -144,75 +150,52 @@ export const DropZone = ({ onServerConnect }: DropZoneProps) => { const autoConnectTimerRef = useRef | null>(null); // Connection state - // 'analyze' = server up but zero repos indexed — show URL input - const [phase, setPhase] = useState<'onboarding' | 'analyze' | 'success' | 'loading'>( + // 'analyze' = server up but zero repos indexed — show URL input + // 'landing' = server up with indexed repos — show repo picker + analyze + const [phase, setPhase] = useState<'onboarding' | 'analyze' | 'landing' | 'success' | 'loading'>( 'onboarding', ); const [loadingMessage, setLoadingMessage] = useState(''); const abortControllerRef = useRef(null); + const [detectedRepos, setDetectedRepos] = useState([]); - // Auto-connect to the detected server + // Auto-connect to the detected server — fetch repo list and show the + // appropriate screen (landing with repo cards, or analyze for zero repos). const handleAutoConnect = async () => { setPhase('loading'); setLoadingMessage('Connecting...'); setError(null); - const abortController = new AbortController(); - abortControllerRef.current = abortController; - try { - // Check if the server has any indexed repos first const repos = await fetchRepos(); if (repos.length === 0) { - // Server is up but has no repos — transition to the analyze UI - // instead of showing a generic error string. setPhase('analyze'); autoConnectRan.current = false; return; } - const result = await connectToServer( - detectedBackendUrl, - (p, downloaded, total) => { - if (p === 'validating') { - setLoadingMessage('Validating server...'); - } else if (p === 'downloading') { - const mb = (downloaded / (1024 * 1024)).toFixed(1); - const pct = total ? Math.round((downloaded / total) * 100) : null; - setLoadingMessage(pct ? `Downloading graph... ${pct}%` : `Downloading... ${mb} MB`); - } else if (p === 'extracting') { - setLoadingMessage('Processing graph...'); - } - }, - abortController.signal, - ); - - if (onServerConnect) { - await onServerConnect(result, detectedBackendUrl); - } + // Show landing screen so the user can choose which repo to explore + setDetectedRepos(repos); + setPhase('landing'); } catch (err) { if ((err as Error).name === 'AbortError') return; const message = err instanceof Error ? err.message : 'Failed to connect'; setError(message); - // Show error on the loading card — do NOT reset autoConnectRan while - // isConnected is still true, or the auto-connect effect will loop. - // The "server went away" branch handles the reset when isConnected drops. setPhase('onboarding'); - } finally { - abortControllerRef.current = null; } }; const handleAutoConnectRef = useRef(handleAutoConnect); handleAutoConnectRef.current = handleAutoConnect; - // Called by AnalyzeOnboarding when a new repo finishes indexing. - // Connects directly to the newly-analyzed repo by name. - const handleAnalyzeComplete = (repoName: string) => { + // Shared handler: connect to a specific repo by name (used by both repo + // card selection on the landing screen and post-analysis completion). + const connectToRepo = (repoName: string) => { autoConnectRan.current = true; setPhase('loading'); setLoadingMessage('Loading graph...'); - // Connect to the specific repo that was just analyzed + setError(null); + (async () => { const abortController = new AbortController(); abortControllerRef.current = abortController; @@ -220,9 +203,12 @@ export const DropZone = ({ onServerConnect }: DropZoneProps) => { const result = await connectToServer( detectedBackendUrl, (p, downloaded, total) => { - if (p === 'downloading') { + if (p === 'validating') { + setLoadingMessage('Validating server...'); + } else if (p === 'downloading') { const pct = total ? Math.round((downloaded / total) * 100) : null; - setLoadingMessage(pct ? `Downloading graph... ${pct}%` : 'Downloading graph...'); + const mb = (downloaded / (1024 * 1024)).toFixed(1); + setLoadingMessage(pct ? `Downloading graph... ${pct}%` : `Downloading... ${mb} MB`); } else if (p === 'extracting') { setLoadingMessage('Processing graph...'); } @@ -236,7 +222,7 @@ export const DropZone = ({ onServerConnect }: DropZoneProps) => { } catch (err) { if ((err as Error).name === 'AbortError') return; setError(err instanceof Error ? err.message : 'Failed to load graph'); - setPhase('onboarding'); + setPhase(detectedRepos.length > 0 ? 'landing' : 'analyze'); } finally { abortControllerRef.current = null; } @@ -314,7 +300,14 @@ export const DropZone = ({ onServerConnect }: DropZoneProps) => { {displayPhase && ( {displayPhase === 'onboarding' && } - {displayPhase === 'analyze' && } + {displayPhase === 'analyze' && } + {displayPhase === 'landing' && ( + + )} {displayPhase === 'success' && } {displayPhase === 'loading' && } diff --git a/gitnexus-web/src/components/RepoLanding.tsx b/gitnexus-web/src/components/RepoLanding.tsx new file mode 100644 index 000000000..e6a44a78e --- /dev/null +++ b/gitnexus-web/src/components/RepoLanding.tsx @@ -0,0 +1,148 @@ +/** + * RepoLanding + * + * Unified landing screen shown when the backend is connected and at least one + * repository is indexed. Displays pre-indexed repos as selectable cards, plus + * an "Analyze a New Repository" section powered by RepoAnalyzer. + * + * Rendering context: + * DropZone (Crossfade, phase="landing") + * └─ RepoLanding + * ├─ RepoCard (× N) + * └─ RepoAnalyzer (variant="onboarding") + */ + +import { Sparkles, ArrowRight, GitBranch, FileCode, Layers } from '@/lib/lucide-icons'; +import { RepoAnalyzer } from './RepoAnalyzer'; +import type { BackendRepo } from '../services/backend-client'; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function formatRelativeTime(dateStr: string): string { + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60_000); + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours}h ago`; + const diffDays = Math.floor(diffHours / 24); + if (diffDays < 30) return `${diffDays}d ago`; + return date.toLocaleDateString(); +} + +// ── Repo card ──────────────────────────────────────────────────────────────── + +function RepoCard({ repo, onClick }: { repo: BackendRepo; onClick: () => void }) { + const stats = repo.stats; + + return ( + + ); +} + +// ── RepoLanding ────────────────────────────────────────────────────────────── + +interface RepoLandingProps { + repos: BackendRepo[]; + onSelectRepo: (repoName: string) => void; + onAnalyzeComplete: (repoName: string) => void; +} + +export const RepoLanding = ({ repos, onSelectRepo, onAnalyzeComplete }: RepoLandingProps) => { + return ( +
+ {/* Ambient glows — mirrors OnboardingGuide aesthetic */} +
+
+ + {/* Header */} +
+
+
+ + + GitNexus + +
+ +

+ Choose a repository +

+

+ Select an indexed repository to explore, or analyze a new one. +

+
+
+ + {/* Repo list */} +
+ {repos.map((repo) => ( + onSelectRepo(repo.name)} /> + ))} +
+ + {/* Divider */} +
+
+ + or analyze new + +
+
+ + {/* Analyzer form */} +
+ +
+ + {/* Footer hint */} +

+ Public & private repos · Cloned locally by the server · No data leaves + your machine +

+
+ ); +}; From 113dadf945e2e3c55c46605704a0abfe4b369fca Mon Sep 17 00:00:00 2001 From: Abhigyan Patwari Date: Wed, 1 Apr 2026 10:34:46 +0530 Subject: [PATCH 02/12] chore: bump version to 1.5.0 Made-with: Cursor --- gitnexus/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gitnexus/package.json b/gitnexus/package.json index c77865026..252bf0375 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -1,6 +1,6 @@ { "name": "gitnexus", - "version": "1.4.10", + "version": "1.5.0", "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.", "author": "Abhigyan Patwari", "license": "PolyForm-Noncommercial-1.0.0", From 0e6f8f1720a38c7853b850480e9f9fd4704ec752 Mon Sep 17 00:00:00 2001 From: Abhigyan Patwari <126312502+abhigyanpatwari@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:24:38 +0530 Subject: [PATCH 03/12] =?UTF-8?q?chore:=20release=20v1.5.0=20=E2=80=94=20u?= =?UTF-8?q?pdate=20CHANGELOG=20and=20package-lock=20(#611)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor Co-authored-by: Abhigyan Patwari --- gitnexus/CHANGELOG.md | 28 ++++++++++++++++++++++++++++ gitnexus/package-lock.json | 4 ++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/gitnexus/CHANGELOG.md b/gitnexus/CHANGELOG.md index e649fc209..fc7056141 100644 --- a/gitnexus/CHANGELOG.md +++ b/gitnexus/CHANGELOG.md @@ -2,6 +2,34 @@ All notable changes to GitNexus will be documented in this file. +## [1.5.0] - 2026-04-01 + +### Added +- **Repo landing screen** — when the backend detects indexed repositories, the web UI now shows a landing page with selectable repo cards (name, stats, indexed date) instead of auto-loading the first repo; users can also analyze new repos directly from the landing screen (#607) +- **Unified web & CLI ingestion pipeline** — complete architectural migration of the web app from a self-contained WASM browser app to a thin client backed by the CLI server; new `gitnexus-shared` package for cross-package type unification (#536) + - New server endpoints: `/api/heartbeat` (SSE liveness), `/api/info`, `/api/repos`, `/api/file`, `/api/grep`, `/api/analyze` (SSE progress), `/api/embed`, `/api/mcp` (MCP-over-StreamableHTTP) + - Onboarding flow: auto-detect server → connect → repo landing or analyze + - Header repo dropdown: switch, re-analyze, or delete repos +- **Azure OpenAI support for wiki command** — fixed broken Azure auth (`api-key` header), `api-version` URL parameter, reasoning model handling (`max_completion_tokens`, no `temperature`), content filter error messages; added interactive setup wizard, `--api-version` and `--reasoning-model` CLI flags (#562) +- **Java method references & interface dispatch** — `obj::method` treated as call sites, overload selection via typed variable args (not just literals), interface dispatch emits additional CALLS edges to implementing classes (#540) +- **MethodExtractor abstraction** — structured method metadata extraction (isAbstract, isFinal, annotations, visibility, parameter types) with config-driven factory pattern (#576) + - Java and Kotlin configs with overload-safe `methodInfoCache` keyed by `name:line` + - C# config with `sealed`, `params`/`out`/`ref`/optional parameters, `[Attribute]` syntax, `internal` visibility (#582) +- **`--skip-agents-md` CLI flag** — opt out of overwriting GitNexus-managed sections in AGENTS.md and CLAUDE.md during `gitnexus analyze` (#517) +- **Prettier** — monorepo-wide code formatter with lint-staged + Husky pre-commit hook, `.prettierrc` config, Tailwind CSS v4 plugin, `endOfLine: "lf"` + `.gitattributes` for Windows consistency (#563) +- **ESLint v9** — flat config with `unused-imports` auto-removal, `@typescript-eslint` rules, React hooks rules, CI `lint` job (#564) + +### Fixed +- **OpenCode MCP configuration** — corrected README MCP setup for OpenCode which requires `command` as an array containing both executable and arguments (#363) +- **litellm security** — excluded vulnerable versions 1.82.7 and 1.82.8 in eval harness `pyproject.toml` (#580) + +### Changed +- **Reduced explicit `any` types** — 128 `no-explicit-any` warnings eliminated (689 → 561, 19% reduction) across `NodeProperties` index signature, ~80 `SyntaxNode` substitutions, typed worker protocol, and graphology community detection (#566) + +### Docs +- Added `gitnexus-shared` build step to web UI quick start instructions (#585) +- Added enterprise offering section to README (#579) + ## [1.4.10] - 2026-03-27 ### Fixed diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 9994f13c1..6d6a958fd 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitnexus", - "version": "1.4.10", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitnexus", - "version": "1.4.10", + "version": "1.5.0", "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { "@huggingface/transformers": "^3.0.0", From 39322e37df2127dfb96e8ac35f1cf02cb1e06834 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Wed, 1 Apr 2026 07:35:46 +0100 Subject: [PATCH 04/12] fix(ci): build gitnexus-shared before npm ci in publish workflow The publish job failed because npm ci triggers the prepare script (tsc) before gitnexus-shared types are available. Add the same build step that the setup-gitnexus composite action uses in CI. --- .github/workflows/publish.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3da468208..b8ef3f9fc 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -30,6 +30,10 @@ jobs: registry-url: https://registry.npmjs.org cache: npm cache-dependency-path: gitnexus/package-lock.json + - name: Build gitnexus-shared + run: npm install && npm run build + working-directory: gitnexus-shared + - run: npm ci working-directory: gitnexus From 71255a096cb8f9af000c65bbfc8b1836350bce07 Mon Sep 17 00:00:00 2001 From: Abhigyan Patwari <126312502+abhigyanpatwari@users.noreply.github.com> Date: Wed, 1 Apr 2026 17:04:39 +0530 Subject: [PATCH 05/12] fix: bundle gitnexus-shared into CLI dist (#613) * fix(ci): build gitnexus-shared before publish, use CHANGELOG for release notes The publish workflow was missing the gitnexus-shared build step that the setup-gitnexus composite action provides. Since PR #536 unified the ingestion pipeline, gitnexus imports types from gitnexus-shared, so it must be built first. Also replaces generate_release_notes with CHANGELOG.md extraction so GitHub Releases use the reviewed changelog entry instead of a flat PR title list. Made-with: Cursor * fix: bundle gitnexus-shared into CLI dist to fix module resolution gitnexus-shared was declared as a file: dependency but never published to npm, causing ERR_MODULE_NOT_FOUND for users installing gitnexus globally. The build script now copies gitnexus-shared/dist into dist/_shared/ and rewrites bare specifiers to relative paths. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: move gitnexus-shared to devDependencies, use tsc for prepare gitnexus-shared must remain available for tsc to resolve imports during development/CI, but is not needed at runtime since it's bundled into dist/_shared/. Moving it to devDependencies keeps it out of production installs while allowing compilation. The prepare script now runs plain tsc (no shared bundling needed for local dev). Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Abhigyan Patwari Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/publish.yml | 17 ++++++++- gitnexus/package-lock.json | 3 +- gitnexus/package.json | 6 +-- gitnexus/scripts/build.js | 69 +++++++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 gitnexus/scripts/build.js diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b8ef3f9fc..8a0ee6ebc 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -67,7 +67,22 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Extract release notes from CHANGELOG + id: changelog + shell: bash + run: | + VERSION="${GITHUB_REF#refs/tags/v}" + NOTES=$(awk "/^## \\[$VERSION\\]/{found=1; next} /^## \\[/{if(found) exit} found" gitnexus/CHANGELOG.md) + if [ -z "$NOTES" ]; then + echo "::warning::No CHANGELOG entry found for v$VERSION, falling back to auto-generated notes" + echo "fallback=true" >> "$GITHUB_OUTPUT" + else + echo "$NOTES" > /tmp/release-notes.md + echo "fallback=false" >> "$GITHUB_OUTPUT" + fi + - name: Create GitHub Release uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 with: - generate_release_notes: true + body_path: ${{ steps.changelog.outputs.fallback == 'false' && '/tmp/release-notes.md' || '' }} + generate_release_notes: ${{ steps.changelog.outputs.fallback == 'true' }} diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 6d6a958fd..b5a1a139e 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -16,7 +16,6 @@ "commander": "^12.0.0", "cors": "^2.8.5", "express": "^4.19.2", - "gitnexus-shared": "file:../gitnexus-shared", "glob": "^11.0.0", "graphology": "^0.25.4", "graphology-indices": "^0.17.0", @@ -50,6 +49,7 @@ "@types/node": "^20.0.0", "@types/uuid": "^10.0.0", "@vitest/coverage-v8": "^4.0.18", + "gitnexus-shared": "file:../gitnexus-shared", "tsx": "^4.0.0", "typescript": "^5.4.5", "vitest": "^4.0.18" @@ -65,6 +65,7 @@ }, "../gitnexus-shared": { "version": "1.0.0", + "dev": true, "devDependencies": { "typescript": "^6.0.2" } diff --git a/gitnexus/package.json b/gitnexus/package.json index 252bf0375..48e388b11 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -38,7 +38,7 @@ "vendor" ], "scripts": { - "build": "tsc", + "build": "node scripts/build.js", "serve": "tsx src/cli/index.ts serve", "dev": "tsx watch src/cli/index.ts", "test": "vitest run", @@ -46,11 +46,10 @@ "test:integration": "vitest run test/integration", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "prepare": "npm run build", + "prepare": "tsc", "prepack": "npm run build && chmod +x dist/cli/index.js" }, "dependencies": { - "gitnexus-shared": "file:../gitnexus-shared", "@huggingface/transformers": "^3.0.0", "@ladybugdb/core": "^0.15.2", "@modelcontextprotocol/sdk": "^1.0.0", @@ -87,6 +86,7 @@ "tree-sitter-swift": "^0.6.0" }, "devDependencies": { + "gitnexus-shared": "file:../gitnexus-shared", "@types/cli-progress": "^3.11.6", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", diff --git a/gitnexus/scripts/build.js b/gitnexus/scripts/build.js new file mode 100644 index 000000000..d3e33bfb3 --- /dev/null +++ b/gitnexus/scripts/build.js @@ -0,0 +1,69 @@ +#!/usr/bin/env node +/** + * Build script that compiles gitnexus and inlines gitnexus-shared into the dist. + * + * Steps: + * 1. Build gitnexus-shared (tsc) + * 2. Build gitnexus (tsc) + * 3. Copy gitnexus-shared/dist → dist/_shared + * 4. Rewrite bare 'gitnexus-shared' specifiers → relative paths + */ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..'); +const SHARED_ROOT = path.resolve(ROOT, '..', 'gitnexus-shared'); +const DIST = path.join(ROOT, 'dist'); +const SHARED_DEST = path.join(DIST, '_shared'); + +// ── 1. Build gitnexus-shared ─────────────────────────────────────── +console.log('[build] compiling gitnexus-shared…'); +execSync('npx tsc', { cwd: SHARED_ROOT, stdio: 'inherit' }); + +// ── 2. Build gitnexus ────────────────────────────────────────────── +console.log('[build] compiling gitnexus…'); +execSync('npx tsc', { cwd: ROOT, stdio: 'inherit' }); + +// ── 3. Copy shared dist ──────────────────────────────────────────── +console.log('[build] copying shared module into dist/_shared…'); +fs.cpSync(path.join(SHARED_ROOT, 'dist'), SHARED_DEST, { recursive: true }); + +// ── 4. Rewrite imports ───────────────────────────────────────────── +console.log('[build] rewriting gitnexus-shared imports…'); +let rewritten = 0; + +function rewriteFile(filePath) { + const content = fs.readFileSync(filePath, 'utf-8'); + if (!content.includes('gitnexus-shared')) return; + + const relDir = path.relative(path.dirname(filePath), SHARED_DEST); + // Always use posix separators and point to the package index + const relImport = relDir.split(path.sep).join('/') + '/index.js'; + + const updated = content + .replace(/from\s+['"]gitnexus-shared['"]/g, `from '${relImport}'`) + .replace(/import\(\s*['"]gitnexus-shared['"]\s*\)/g, `import('${relImport}')`); + + if (updated !== content) { + fs.writeFileSync(filePath, updated); + rewritten++; + } +} + +function walk(dir, extensions, cb) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full, extensions, cb); + } else if (extensions.some((ext) => entry.name.endsWith(ext))) { + cb(full); + } + } +} + +walk(DIST, ['.js', '.d.ts'], rewriteFile); + +console.log(`[build] done — rewrote ${rewritten} files.`); From c617350c58f38ce3623b203545083afa1b6e3082 Mon Sep 17 00:00:00 2001 From: Abhigyan Patwari Date: Wed, 1 Apr 2026 17:05:08 +0530 Subject: [PATCH 06/12] =?UTF-8?q?chore:=20release=20v1.5.1=20=E2=80=94=20u?= =?UTF-8?q?pdate=20CHANGELOG=20and=20package-lock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- gitnexus/CHANGELOG.md | 5 +++++ gitnexus/package-lock.json | 4 ++-- gitnexus/package.json | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/gitnexus/CHANGELOG.md b/gitnexus/CHANGELOG.md index fc7056141..273f97657 100644 --- a/gitnexus/CHANGELOG.md +++ b/gitnexus/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to GitNexus will be documented in this file. +## [1.5.1] - 2026-04-01 + +### Fixed +- **`gitnexus-shared` module not found** — `gitnexus-shared` was a `file:` workspace dependency never published to npm, causing `ERR_MODULE_NOT_FOUND` when installing `gitnexus` globally. The build now bundles shared code into `dist/_shared/` and rewrites imports to relative paths (#613) + ## [1.5.0] - 2026-04-01 ### Added diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index b5a1a139e..2feb1266a 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitnexus", - "version": "1.5.0", + "version": "1.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitnexus", - "version": "1.5.0", + "version": "1.5.1", "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { "@huggingface/transformers": "^3.0.0", diff --git a/gitnexus/package.json b/gitnexus/package.json index 48e388b11..adb5eb544 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -1,6 +1,6 @@ { "name": "gitnexus", - "version": "1.5.0", + "version": "1.5.1", "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.", "author": "Abhigyan Patwari", "license": "PolyForm-Noncommercial-1.0.0", From dcf40da3a8fd8ee2f9002b16818e67c535fdaed7 Mon Sep 17 00:00:00 2001 From: Abhigyan Patwari Date: Wed, 1 Apr 2026 17:28:14 +0530 Subject: [PATCH 07/12] fix: ensure import rewrites survive npm publish lifecycle npm runs `prepare` after `prepack` during publish, so the previous `prepare: tsc` overwrote the rewritten imports before packing. Both `prepare` and `prepack` now run the full build script so the tarball always contains rewritten relative imports. Co-Authored-By: Claude Opus 4.6 (1M context) --- gitnexus/CHANGELOG.md | 8 +++++++- gitnexus/package-lock.json | 4 ++-- gitnexus/package.json | 6 +++--- gitnexus/scripts/build.js | 4 ++++ 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/gitnexus/CHANGELOG.md b/gitnexus/CHANGELOG.md index 273f97657..081eb9b26 100644 --- a/gitnexus/CHANGELOG.md +++ b/gitnexus/CHANGELOG.md @@ -2,10 +2,16 @@ All notable changes to GitNexus will be documented in this file. -## [1.5.1] - 2026-04-01 +## [1.5.2] - 2026-04-01 ### Fixed - **`gitnexus-shared` module not found** — `gitnexus-shared` was a `file:` workspace dependency never published to npm, causing `ERR_MODULE_NOT_FOUND` when installing `gitnexus` globally. The build now bundles shared code into `dist/_shared/` and rewrites imports to relative paths (#613) +- **v1.5.1 publish regression** — npm's `prepare` lifecycle ran `tsc` after `prepack`, overwriting the rewritten imports before packing; both scripts now run the full build so the final tarball is always correct + +## [1.5.1] - 2026-04-01 [YANKED] + +### Fixed +- Incomplete fix for `gitnexus-shared` bundling — `prepare` script overwrote rewritten imports during publish ## [1.5.0] - 2026-04-01 diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 2feb1266a..a533998ef 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitnexus", - "version": "1.5.1", + "version": "1.5.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitnexus", - "version": "1.5.1", + "version": "1.5.2", "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { "@huggingface/transformers": "^3.0.0", diff --git a/gitnexus/package.json b/gitnexus/package.json index adb5eb544..d7361e725 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -1,6 +1,6 @@ { "name": "gitnexus", - "version": "1.5.1", + "version": "1.5.2", "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.", "author": "Abhigyan Patwari", "license": "PolyForm-Noncommercial-1.0.0", @@ -46,8 +46,8 @@ "test:integration": "vitest run test/integration", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "prepare": "tsc", - "prepack": "npm run build && chmod +x dist/cli/index.js" + "prepare": "node scripts/build.js", + "prepack": "node scripts/build.js" }, "dependencies": { "@huggingface/transformers": "^3.0.0", diff --git a/gitnexus/scripts/build.js b/gitnexus/scripts/build.js index d3e33bfb3..68378c61d 100644 --- a/gitnexus/scripts/build.js +++ b/gitnexus/scripts/build.js @@ -66,4 +66,8 @@ function walk(dir, extensions, cb) { walk(DIST, ['.js', '.d.ts'], rewriteFile); +// ── 5. Make CLI entry executable ──────────────────────────────────── +const cliEntry = path.join(DIST, 'cli', 'index.js'); +if (fs.existsSync(cliEntry)) fs.chmodSync(cliEntry, 0o755); + console.log(`[build] done — rewrote ${rewritten} files.`); From 12be2025f1cd21bfe1714c6a387e3506a34acb02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Wed, 1 Apr 2026 14:09:59 +0100 Subject: [PATCH 08/12] feat(ts,js): TypeScript/JavaScript MethodExtractor config (#588) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ts,js): MethodExtractor config for TypeScript and JavaScript (#570) Add per-language method extraction config following the established JVM and C# patterns. Shared config base mirrors the field extractor's typescript-javascript.ts pattern — TS-only node types are harmless no-ops for JS. Key features: - isAbstract for abstract class methods and interface methods - Parameter extraction with isOptional (?:, defaults) and isVariadic (...) - Decorator extraction from preceding body-level siblings - isAsync and isOverride detection - Visibility via accessibility_modifier two-pass pattern - Return type extraction unwrapping type_annotation * test(ts,js): add override, getter/setter, destructured param tests Address code review findings: - Add override method detection test - Add getter/setter extraction test - Add destructured parameter with type annotation test - Tighten constructor and private method assertions * refactor(ts,js): address code review findings - Replace O(M*N) decorator index scan with previousNamedSibling walk - Remove dead findVisibility 'modifiers' fallback (TS uses accessibility_modifier, not a modifiers wrapper) - Document call_signature/construct_signature as known gaps - Document that TS constructors are method_definition nodes - Remove unused findVisibility import * fix(ts,js): type guard before cast, add generator/computed/overload tests - Use type guard pattern (Set.has check before as-cast) in visibility extraction to ensure string is validated before narrowing - Add generator method test (*items()) — confirms extraction works - Add computed property name test ([Symbol.iterator]) — documents bracket-in-name behavior as intentional - Add class-level method overload test — verifies overload signatures + implementation are all extracted * fix(ts,js): detect #private methods as visibility 'private' ES2022 private class methods (#name) use private_property_identifier as their name node type. Detect this and return 'private' visibility instead of the default 'public'. * fix(ts,js): address review findings + close ingestion gaps - hasKeyword/findVisibility: skip name field child to prevent false positives on soft-keyword method names (e.g. `abstract()`, `static()`) - extractTsJsParameters: filter TS `this` parameter (compile-time only) - extractMethodSignature: mirror `this`-param skip in fallback path - tree-sitter queries: capture abstract_method_signature, method_signature, and private_property_identifier for TS; add private_property_identifier for JS - Remove dead childForFieldName('name') fallbacks and typeFromAnnotation fallback - Add 10+ unit tests, 4 integration tests through query pipeline * test(ts): update HAS_METHOD count for interface method_signature capture The new method_signature query now captures ILogger.log() as a Method node with a HAS_METHOD edge, increasing the expected count from 4 to 5. * fix(ts,js): address second review — async generator test, declare module gap - Add async generator method test (async *values() → isAsync: true) - Document declare module/global augmentation as known gap --- .../field-extractors/configs/helpers.ts | 14 +- .../core/ingestion/languages/typescript.ts | 7 + .../configs/typescript-javascript.ts | 278 ++++++++ .../src/core/ingestion/tree-sitter-queries.ts | 16 + .../src/core/ingestion/utils/ast-helpers.ts | 6 + gitnexus/test/integration/has-method.test.ts | 90 +++ .../integration/resolvers/typescript.test.ts | 3 +- gitnexus/test/unit/field-extraction.test.ts | 19 + gitnexus/test/unit/method-extraction.test.ts | 613 +++++++++++++++++- gitnexus/test/unit/method-signature.test.ts | 25 + 10 files changed, 1066 insertions(+), 5 deletions(-) create mode 100644 gitnexus/src/core/ingestion/method-extractors/configs/typescript-javascript.ts diff --git a/gitnexus/src/core/ingestion/field-extractors/configs/helpers.ts b/gitnexus/src/core/ingestion/field-extractors/configs/helpers.ts index 893731eb8..39d7996f0 100644 --- a/gitnexus/src/core/ingestion/field-extractors/configs/helpers.ts +++ b/gitnexus/src/core/ingestion/field-extractors/configs/helpers.ts @@ -15,12 +15,17 @@ import type { FieldVisibility } from '../../field-types.js'; /** * Check whether any child of `node` (named or unnamed) has .text matching - * one of the given `keywords`. + * the given `keyword`. + * + * Skips the `name` field child to avoid false positives when a method is + * named after a contextual keyword (e.g. `abstract()` in TypeScript). */ export function hasKeyword(node: SyntaxNode, keyword: string): boolean { + const nameNode = node.childForFieldName('name'); for (let i = 0; i < node.childCount; i++) { const child = node.child(i); - if (child && child.text.trim() === keyword) return true; + if (!child || child === nameNode) continue; + if (child.text.trim() === keyword) return true; } return false; } @@ -46,6 +51,7 @@ export function hasModifier(node: SyntaxNode, modifierType: string, keyword: str /** * Return the first matching visibility keyword found either as a direct keyword * child or inside a modifier wrapper node. + * Skips the `name` field child (same rationale as hasKeyword). */ export function findVisibility( node: SyntaxNode, @@ -53,10 +59,12 @@ export function findVisibility( defaultVis: FieldVisibility, modifierNodeType?: string, ): FieldVisibility { + const nameNode = node.childForFieldName('name'); // Direct keyword children for (let i = 0; i < node.childCount; i++) { const child = node.child(i); - const text = child?.text.trim() as FieldVisibility | undefined; + if (!child || child === nameNode) continue; + const text = child.text.trim() as FieldVisibility | undefined; if (text && (keywords as ReadonlySet).has(text)) return text; } // Modifier wrapper diff --git a/gitnexus/src/core/ingestion/languages/typescript.ts b/gitnexus/src/core/ingestion/languages/typescript.ts index 838804bd0..5def76f1e 100644 --- a/gitnexus/src/core/ingestion/languages/typescript.ts +++ b/gitnexus/src/core/ingestion/languages/typescript.ts @@ -17,6 +17,11 @@ import { TYPESCRIPT_QUERIES, JAVASCRIPT_QUERIES } from '../tree-sitter-queries.j import { typescriptFieldExtractor } from '../field-extractors/typescript.js'; import { createFieldExtractor } from '../field-extractors/generic.js'; import { javascriptConfig } from '../field-extractors/configs/typescript-javascript.js'; +import { createMethodExtractor } from '../method-extractors/generic.js'; +import { + typescriptMethodConfig, + javascriptMethodConfig, +} from '../method-extractors/configs/typescript-javascript.js'; const BUILT_INS: ReadonlySet = new Set([ 'console', @@ -124,6 +129,7 @@ export const typescriptProvider = defineLanguage({ importResolver: resolveTypescriptImport, namedBindingExtractor: extractTsNamedBindings, fieldExtractor: typescriptFieldExtractor, + methodExtractor: createMethodExtractor(typescriptMethodConfig), builtInNames: BUILT_INS, }); @@ -136,5 +142,6 @@ export const javascriptProvider = defineLanguage({ importResolver: resolveJavascriptImport, namedBindingExtractor: extractTsNamedBindings, fieldExtractor: createFieldExtractor(javascriptConfig), + methodExtractor: createMethodExtractor(javascriptMethodConfig), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/method-extractors/configs/typescript-javascript.ts b/gitnexus/src/core/ingestion/method-extractors/configs/typescript-javascript.ts new file mode 100644 index 000000000..a4a200a83 --- /dev/null +++ b/gitnexus/src/core/ingestion/method-extractors/configs/typescript-javascript.ts @@ -0,0 +1,278 @@ +// gitnexus/src/core/ingestion/method-extractors/configs/typescript-javascript.ts +// Verified against tree-sitter-typescript ^0.23.2, tree-sitter-javascript ^0.23.0 + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { + MethodExtractionConfig, + ParameterInfo, + MethodVisibility, +} from '../../method-types.js'; +import { hasKeyword } from '../../field-extractors/configs/helpers.js'; +import { extractSimpleTypeName } from '../../type-extractors/shared.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +// --------------------------------------------------------------------------- +// TS/JS helpers +// --------------------------------------------------------------------------- + +const VISIBILITY_KEYWORDS = new Set(['public', 'private', 'protected']); + +/** + * Extract parameters from formal_parameters. + * + * Handles both TS node types (required_parameter, optional_parameter, rest_parameter) + * and JS node types (identifier, assignment_pattern, rest_pattern), plus destructured + * parameters (object_pattern, array_pattern) in both grammars. + */ +function extractTsJsParameters(node: SyntaxNode): ParameterInfo[] { + const paramList = node.childForFieldName('parameters'); + if (!paramList) return []; + const params: ParameterInfo[] = []; + + for (let i = 0; i < paramList.namedChildCount; i++) { + const param = paramList.namedChild(i); + if (!param) continue; + + switch (param.type) { + case 'required_parameter': { + const patternNode = param.childForFieldName('pattern'); + if (!patternNode) break; + + // Skip TS `this` parameter — it's a compile-time type constraint, not a real param + if (patternNode.type === 'this') break; + + // Rest parameter: pattern is a rest_pattern (...args) — extract inner identifier + const isRest = patternNode.type === 'rest_pattern'; + const nameNode = isRest ? patternNode.firstNamedChild : patternNode; + if (!nameNode) break; + + // type field is a type_annotation — unwrap to get the inner type node + const typeAnnotation = param.childForFieldName('type'); + const typeNode = typeAnnotation?.firstNamedChild; + + // Default value: presence of a 'value' field means isOptional + const hasDefault = !!param.childForFieldName('value'); + + params.push({ + name: nameNode.text, + type: typeNode + ? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null) + : null, + isOptional: hasDefault, + isVariadic: isRest, + }); + break; + } + case 'optional_parameter': { + const nameNode = param.childForFieldName('pattern'); + if (!nameNode) break; + const typeAnnotation = param.childForFieldName('type'); + const typeNode = typeAnnotation?.firstNamedChild; + params.push({ + name: nameNode.text, + type: typeNode + ? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null) + : null, + isOptional: true, + isVariadic: false, + }); + break; + } + case 'rest_parameter': { + const nameNode = param.childForFieldName('pattern'); + if (!nameNode) break; + const typeAnnotation = param.childForFieldName('type'); + const typeNode = typeAnnotation?.firstNamedChild; + params.push({ + name: nameNode.text, + type: typeNode + ? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null) + : null, + isOptional: false, + isVariadic: true, + }); + break; + } + case 'identifier': { + // JS: bare parameter name, no type info + params.push({ name: param.text, type: null, isOptional: false, isVariadic: false }); + break; + } + case 'assignment_pattern': { + // JS: param = defaultValue — the left side is the name, isOptional = true + const left = param.childForFieldName('left'); + if (left) { + params.push({ name: left.text, type: null, isOptional: true, isVariadic: false }); + } + break; + } + case 'rest_pattern': { + // JS: ...args + const inner = param.firstNamedChild; + if (inner) { + params.push({ name: inner.text, type: null, isOptional: false, isVariadic: true }); + } + break; + } + case 'object_pattern': + case 'array_pattern': { + // Destructured parameter — use full text as name + params.push({ name: param.text, type: null, isOptional: false, isVariadic: false }); + break; + } + } + } + return params; +} + +/** + * Extract return type from return_type field, unwrapping type_annotation. + * + * tree-sitter-typescript uses `return_type` as the field name (not `type` like JVM). + * The return_type field points to a type_annotation node that must be unwrapped. + */ +function extractTsJsReturnType(node: SyntaxNode): string | undefined { + const returnType = node.childForFieldName('return_type'); + if (returnType) { + if (returnType.type === 'type_annotation') { + const inner = returnType.firstNamedChild; + if (inner) return extractSimpleTypeName(inner) ?? inner.text?.trim(); + } + return extractSimpleTypeName(returnType) ?? returnType.text?.trim(); + } + return undefined; +} + +/** + * Extract visibility from accessibility_modifier or #private name. + * + * tree-sitter-typescript emits accessibility_modifier as a named child of method nodes + * (not as a modifiers wrapper like JVM). Pass 1 scans for that child; pass 2 checks for + * ES2022 private_property_identifier (#name). Default: public. + */ +function extractTsJsVisibility(node: SyntaxNode): MethodVisibility { + // Pass 1: check for accessibility_modifier named child (TS-specific) + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child && child.type === 'accessibility_modifier') { + const t = child.text.trim(); + if (VISIBILITY_KEYWORDS.has(t as MethodVisibility)) return t as MethodVisibility; + } + } + // Pass 2: ES2022 private methods (#name) are inherently private + const nameNode = node.childForFieldName('name'); + if (nameNode && nameNode.type === 'private_property_identifier') return 'private'; + // No accessibility_modifier found — default to public. + // Note: tree-sitter-typescript does not wrap modifiers in a 'modifiers' node + // (unlike JVM), so there is no wrapper to scan. + return 'public'; +} + +/** + * Extract decorator names, prefixed with '@'. + * + * In tree-sitter-typescript, decorators are **siblings** of the method_definition in the + * class_body — they are NOT children of the method node. We find them by walking backwards + * from the method node through its preceding siblings in the parent body. + */ +function extractTsJsDecorators(node: SyntaxNode): string[] { + const decorators: string[] = []; + // Walk backwards via previousNamedSibling to collect consecutive decorator siblings. + // This avoids the O(N) index-finding scan through the parent's children. + let sibling = node.previousNamedSibling; + while (sibling && sibling.type === 'decorator') { + const name = extractDecoratorName(sibling); + if (name) decorators.unshift(name); + sibling = sibling.previousNamedSibling; + } + return decorators; +} + +function extractDecoratorName(decorator: SyntaxNode): string | undefined { + const expr = decorator.firstNamedChild; + if (!expr) return undefined; + if (expr.type === 'call_expression') { + const fn = expr.childForFieldName('function'); + return fn ? '@' + fn.text : undefined; + } + if (expr.type === 'identifier') return '@' + expr.text; + if (expr.type === 'member_expression') return '@' + expr.text; + return undefined; +} + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +// TS and JS share the same config base. TS-only node types (abstract_class_declaration, +// interface_declaration, abstract_method_signature, method_signature, interface_body) are +// included because the JS grammar never produces these nodes — they are harmless no-ops. +// This mirrors the field extractor's typescript-javascript.ts shared pattern. +// +// Note: TS and JS share a method config but NOT a field extractor because the TS field +// extractor needs a hand-written class for type_alias_declaration object literals and +// nested type discovery. Methods have no such requirement. +const shared: Omit = { + typeDeclarationNodes: [ + 'class_declaration', + 'abstract_class_declaration', + 'interface_declaration', + ], + // Note: TS constructors are method_definition nodes (name = 'constructor'), so no + // explicit constructor_declaration entry is needed (unlike JVM/C# configs). + // Known gaps: + // - call_signature and construct_signature (e.g., interface Fn { (x: string): void; }) + // are not extracted — they have no name field and are uncommon in practice. + // - class_expression (const Foo = class { ... }) — methods inside class expressions + // are not discovered because class_expression is not in typeDeclarationNodes. + // - declare module / declare global augmentations — methods inside ambient_module_declaration + // wrappers are not surfaced because the top-level walker doesn't descend into them. + methodNodeTypes: ['method_definition', 'method_signature', 'abstract_method_signature'], + bodyNodeTypes: ['class_body', 'interface_body'], + + extractName(node) { + const nameNode = node.childForFieldName('name'); + return nameNode?.text; + }, + + extractReturnType: extractTsJsReturnType, + extractParameters: extractTsJsParameters, + extractVisibility: extractTsJsVisibility, + + isStatic(node) { + return hasKeyword(node, 'static'); + }, + + isAbstract(node, ownerNode) { + // Explicit abstract keyword on the method itself + if (hasKeyword(node, 'abstract')) return true; + // Interface methods are implicitly abstract — TS interfaces never have method bodies + // (unlike Java default methods), so no !body check needed + if (ownerNode.type === 'interface_declaration') return true; + return false; + }, + + isFinal(_node) { + return false; // TS/JS has no final/sealed methods + }, + + extractAnnotations: extractTsJsDecorators, + + isAsync(node) { + return hasKeyword(node, 'async'); + }, + + isOverride(node) { + return hasKeyword(node, 'override'); + }, +}; + +export const typescriptMethodConfig: MethodExtractionConfig = { + ...shared, + language: SupportedLanguages.TypeScript, +}; + +export const javascriptMethodConfig: MethodExtractionConfig = { + ...shared, + language: SupportedLanguages.JavaScript, +}; diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index d9f51918f..cbb648f0d 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -24,6 +24,18 @@ export const TYPESCRIPT_QUERIES = ` (method_definition name: (property_identifier) @name) @definition.method +; ES2022 #private methods (private_property_identifier not matched by property_identifier) +(method_definition + name: (private_property_identifier) @name) @definition.method + +; Abstract method signatures in abstract classes +(abstract_method_signature + name: (property_identifier) @name) @definition.method + +; Interface method signatures +(method_signature + name: (property_identifier) @name) @definition.method + (lexical_declaration (variable_declarator name: (identifier) @name @@ -145,6 +157,10 @@ export const JAVASCRIPT_QUERIES = ` (method_definition name: (property_identifier) @name) @definition.method +; ES2022 #private methods +(method_definition + name: (private_property_identifier) @name) @definition.method + (lexical_declaration (variable_declarator name: (identifier) @name diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index 654526ce8..833d5db16 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -664,6 +664,12 @@ export const extractMethodSignature = (node: SyntaxNode | null | undefined): Met ) { continue; } + // TypeScript: `this` parameter is a compile-time type constraint, not a real param + // e.g., handle(this: void, event: Event) — only count 'event' + if (param.type === 'required_parameter') { + const patternNode = param.childForFieldName('pattern'); + if (patternNode?.type === 'this') continue; + } // Kotlin: default values are siblings of the parameter node inside // function_value_parameters, so they appear as named children (e.g. // string_literal, integer_literal, boolean_literal, call_expression). diff --git a/gitnexus/test/integration/has-method.test.ts b/gitnexus/test/integration/has-method.test.ts index 81c78cb3e..bf9ecce20 100644 --- a/gitnexus/test/integration/has-method.test.ts +++ b/gitnexus/test/integration/has-method.test.ts @@ -535,6 +535,96 @@ public: }); }); +describe('HAS_METHOD integration — TypeScript: abstract, interface, and #private methods', () => { + beforeAll(async () => { + await loadLanguage(SupportedLanguages.TypeScript, 'methods.ts'); + }); + + it('abstract method signatures are captured and link to abstract class', () => { + const code = ` +abstract class Shape { + abstract area(): number; + describe(): string { return "shape"; } +} +`; + const results = parseAndExtractMethods(code, SupportedLanguages.TypeScript, 'src/shapes.ts'); + + const area = results.find((r) => r.name === 'area' && r.defType === 'definition.method'); + expect(area).toBeDefined(); + expect(area!.enclosingClassId).toBe('Class:src/shapes.ts:Shape'); + + const describe = results.find( + (r) => r.name === 'describe' && r.defType === 'definition.method', + ); + expect(describe).toBeDefined(); + expect(describe!.enclosingClassId).toBe('Class:src/shapes.ts:Shape'); + }); + + it('interface method signatures are captured and link to interface', () => { + const code = ` +interface Printable { + print(format: string): void; + getLabel(): string; +} +`; + const results = parseAndExtractMethods(code, SupportedLanguages.TypeScript, 'src/printable.ts'); + + const print = results.find((r) => r.name === 'print' && r.defType === 'definition.method'); + expect(print).toBeDefined(); + expect(print!.enclosingClassId).toBe('Interface:src/printable.ts:Printable'); + + const getLabel = results.find( + (r) => r.name === 'getLabel' && r.defType === 'definition.method', + ); + expect(getLabel).toBeDefined(); + expect(getLabel!.enclosingClassId).toBe('Interface:src/printable.ts:Printable'); + }); + + it('ES2022 #private methods are captured and link to class', () => { + const code = ` +class Vault { + #decrypt(data: string): string { return data; } + read(): string { return this.#decrypt("x"); } +} +`; + const results = parseAndExtractMethods(code, SupportedLanguages.TypeScript, 'src/vault.ts'); + + const decrypt = results.find((r) => r.name === '#decrypt'); + expect(decrypt).toBeDefined(); + expect(decrypt!.defType).toBe('definition.method'); + expect(decrypt!.enclosingClassId).toBe('Class:src/vault.ts:Vault'); + + const read = results.find((r) => r.name === 'read'); + expect(read).toBeDefined(); + expect(read!.enclosingClassId).toBe('Class:src/vault.ts:Vault'); + }); +}); + +describe('HAS_METHOD integration — JavaScript: ES2022 #private methods', () => { + beforeAll(async () => { + await loadLanguage(SupportedLanguages.JavaScript); + }); + + it('#private methods are captured and link to class', () => { + const code = ` +class Encapsulated { + #internal() { return 42; } + expose() { return this.#internal(); } +} +`; + const results = parseAndExtractMethods( + code, + SupportedLanguages.JavaScript, + 'src/encapsulated.js', + ); + + const internal = results.find((r) => r.name === '#internal'); + expect(internal).toBeDefined(); + expect(internal!.defType).toBe('definition.method'); + expect(internal!.enclosingClassId).toBe('Class:src/encapsulated.js:Encapsulated'); + }); +}); + describe('HAS_METHOD integration — C# struct and record', () => { beforeAll(async () => { await loadLanguage(SupportedLanguages.CSharp); diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index 7765f0768..577c85478 100644 --- a/gitnexus/test/integration/resolvers/typescript.test.ts +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -58,10 +58,11 @@ describe('TypeScript heritage resolution', () => { it('emits HAS_METHOD edges linking methods to classes', () => { const hasMethod = getRelationships(result, 'HAS_METHOD'); - expect(hasMethod.length).toBe(4); + expect(hasMethod.length).toBe(5); expect(edgeSet(hasMethod)).toEqual([ 'BaseService → getName', 'ConsoleLogger → log', + 'ILogger → log', 'UserService → getUsers', 'UserService → log', ]); diff --git a/gitnexus/test/unit/field-extraction.test.ts b/gitnexus/test/unit/field-extraction.test.ts index a12e1882f..751618146 100644 --- a/gitnexus/test/unit/field-extraction.test.ts +++ b/gitnexus/test/unit/field-extraction.test.ts @@ -511,6 +511,25 @@ describe('GenericFieldExtractor — TypeScript config', () => { expect(countField!.isStatic).toBe(false); expect(countField!.isReadonly).toBe(false); }); + + it('does not false-positive visibility on fields named after visibility keywords', () => { + parser.setLanguage(TypeScript.typescript); + // A class field literally named 'private' with no accessibility modifier — + // findVisibility must not confuse the name with a keyword + const tree = parser.parse(` + class Flags { + private: boolean; + } + `); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.fields).toHaveLength(1); + expect(result!.fields[0].name).toBe('private'); + // Default visibility is public — the field NAME 'private' must not be treated as a keyword + expect(result!.fields[0].visibility).toBe('public'); + }); }); // --------------------------------------------------------------------------- diff --git a/gitnexus/test/unit/method-extraction.test.ts b/gitnexus/test/unit/method-extraction.test.ts index 09f206f92..eae62ce68 100644 --- a/gitnexus/test/unit/method-extraction.test.ts +++ b/gitnexus/test/unit/method-extraction.test.ts @@ -5,10 +5,16 @@ import { kotlinMethodConfig, } from '../../src/core/ingestion/method-extractors/configs/jvm.js'; import { csharpMethodConfig } from '../../src/core/ingestion/method-extractors/configs/csharp.js'; +import { + typescriptMethodConfig, + javascriptMethodConfig, +} from '../../src/core/ingestion/method-extractors/configs/typescript-javascript.js'; import type { MethodExtractorContext } from '../../src/core/ingestion/method-types.js'; import Parser from 'tree-sitter'; import Java from 'tree-sitter-java'; import CSharp from 'tree-sitter-c-sharp'; +import TypeScript from 'tree-sitter-typescript'; +import JavaScript from 'tree-sitter-javascript'; import { SupportedLanguages } from '../../src/config/supported-languages.js'; let Kotlin: unknown; @@ -295,7 +301,7 @@ describe('Java MethodExtractor', () => { const classNode = tree.rootNode.child(0)!; const result = extractor.extract(classNode, javaCtx); - expect(result!.methods.length).toBeGreaterThanOrEqual(1); + expect(result!.methods).toHaveLength(1); const sg = result!.methods.find((m) => m.name === 'surfaceGravity'); expect(sg).toBeDefined(); expect(sg!.returnType).toBe('double'); @@ -1297,3 +1303,608 @@ describe('C# MethodExtractor', () => { }); }); }); + +// --------------------------------------------------------------------------- +// TypeScript +// --------------------------------------------------------------------------- + +const parseTypeScript = (code: string) => { + parser.setLanguage(TypeScript.typescript); + return parser.parse(code); +}; + +const tsCtx: MethodExtractorContext = { + filePath: 'Test.ts', + language: SupportedLanguages.TypeScript, +}; + +describe('TypeScript MethodExtractor', () => { + const extractor = createMethodExtractor(typescriptMethodConfig); + + describe('isTypeDeclaration', () => { + it('recognizes class_declaration', () => { + const tree = parseTypeScript('class Foo { }'); + expect(extractor.isTypeDeclaration(tree.rootNode.child(0)!)).toBe(true); + }); + + it('recognizes abstract_class_declaration', () => { + const tree = parseTypeScript('abstract class Foo { }'); + expect(extractor.isTypeDeclaration(tree.rootNode.child(0)!)).toBe(true); + }); + + it('recognizes interface_declaration', () => { + const tree = parseTypeScript('interface Bar { }'); + expect(extractor.isTypeDeclaration(tree.rootNode.child(0)!)).toBe(true); + }); + + it('rejects function_declaration', () => { + const tree = parseTypeScript('function hello() {}'); + expect(extractor.isTypeDeclaration(tree.rootNode.child(0)!)).toBe(false); + }); + }); + + describe('extract', () => { + it('extracts typed method with return type and parameters', () => { + const tree = parseTypeScript(` + class UserService { + greet(name: string, age: number): string { + return name; + } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + expect(result).not.toBeNull(); + expect(result!.ownerName).toBe('UserService'); + expect(result!.methods).toHaveLength(1); + + const m = result!.methods[0]; + expect(m.name).toBe('greet'); + expect(m.returnType).toBe('string'); + expect(m.visibility).toBe('public'); + expect(m.isStatic).toBe(false); + expect(m.isAbstract).toBe(false); + expect(m.parameters).toHaveLength(2); + expect(m.parameters[0]).toEqual({ + name: 'name', + type: 'string', + isOptional: false, + isVariadic: false, + }); + expect(m.parameters[1]).toEqual({ + name: 'age', + type: 'number', + isOptional: false, + isVariadic: false, + }); + }); + + it('extracts static method', () => { + const tree = parseTypeScript(` + class MathUtils { + static add(a: number, b: number): number { return a + b; } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + expect(result!.methods[0].isStatic).toBe(true); + expect(result!.methods[0].name).toBe('add'); + }); + + it('extracts abstract class with abstract and concrete methods', () => { + const tree = parseTypeScript(` + abstract class Shape { + abstract area(): number; + describe(): string { return "shape"; } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + expect(result).not.toBeNull(); + expect(result!.methods).toHaveLength(2); + + const abstractMethod = result!.methods.find((m) => m.name === 'area'); + const concreteMethod = result!.methods.find((m) => m.name === 'describe'); + expect(abstractMethod!.isAbstract).toBe(true); + expect(abstractMethod!.returnType).toBe('number'); + expect(concreteMethod!.isAbstract).toBe(false); + expect(concreteMethod!.returnType).toBe('string'); + }); + + it('extracts interface methods as abstract', () => { + const tree = parseTypeScript(` + interface Printable { + print(format: string): void; + getLabel(): string; + } + `); + const interfaceNode = tree.rootNode.child(0)!; + const result = extractor.extract(interfaceNode, tsCtx); + + expect(result).not.toBeNull(); + expect(result!.ownerName).toBe('Printable'); + expect(result!.methods).toHaveLength(2); + expect(result!.methods.every((m) => m.isAbstract)).toBe(true); + + const printMethod = result!.methods.find((m) => m.name === 'print'); + expect(printMethod!.parameters[0]).toEqual({ + name: 'format', + type: 'string', + isOptional: false, + isVariadic: false, + }); + expect(printMethod!.returnType).toBe('void'); + }); + + it('extracts private and protected visibility', () => { + const tree = parseTypeScript(` + class Account { + private secret(): void {} + protected validate(): boolean { return true; } + public display(): void {} + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + expect(result!.methods).toHaveLength(3); + const secret = result!.methods.find((m) => m.name === 'secret'); + const validate = result!.methods.find((m) => m.name === 'validate'); + const display = result!.methods.find((m) => m.name === 'display'); + expect(secret!.visibility).toBe('private'); + expect(validate!.visibility).toBe('protected'); + expect(display!.visibility).toBe('public'); + }); + + it('extracts optional and rest parameters', () => { + const tree = parseTypeScript(` + class Logger { + log(message: string, level?: string, ...tags: string[]): void {} + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + const params = result!.methods[0].parameters; + expect(params).toHaveLength(3); + expect(params[0]).toEqual({ + name: 'message', + type: 'string', + isOptional: false, + isVariadic: false, + }); + expect(params[1].name).toBe('level'); + expect(params[1].isOptional).toBe(true); + expect(params[1].isVariadic).toBe(false); + expect(params[2].name).toBe('tags'); + expect(params[2].isOptional).toBe(false); + expect(params[2].isVariadic).toBe(true); + }); + + it('extracts default parameter as optional', () => { + const tree = parseTypeScript(` + class Formatter { + format(value: string, prefix: string = ">>") { return prefix + value; } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + const params = result!.methods[0].parameters; + expect(params).toHaveLength(2); + expect(params[1].name).toBe('prefix'); + expect(params[1].isOptional).toBe(true); + }); + + it('extracts decorators as annotations', () => { + const tree = parseTypeScript(` + class Controller { + @Log + @deprecated("use newMethod") + handle(req: Request): void {} + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + const annotations = result!.methods[0].annotations; + expect(annotations).toContain('@Log'); + expect(annotations).toContain('@deprecated'); + }); + + it('extracts async method', () => { + const tree = parseTypeScript(` + class ApiClient { + async fetch(url: string): Promise { return new Response(); } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + expect(result!.methods[0].isAsync).toBe(true); + expect(result!.methods[0].name).toBe('fetch'); + expect(result!.methods[0].returnType).toBe('Promise'); + }); + + it('extracts constructor', () => { + const tree = parseTypeScript(` + class Person { + constructor(public name: string, private age: number) {} + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + expect(result!.methods).toHaveLength(1); + const ctor = result!.methods[0]; + expect(ctor.name).toBe('constructor'); + expect(ctor.parameters).toHaveLength(2); + expect(ctor.parameters[0].name).toBe('name'); + expect(ctor.parameters[0].type).toBe('string'); + expect(ctor.parameters[1].name).toBe('age'); + expect(ctor.parameters[1].type).toBe('number'); + }); + + it('extracts override method', () => { + const tree = parseTypeScript(` + class Child extends Parent { + override toString(): string { return "child"; } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + expect(result!.methods[0].name).toBe('toString'); + expect(result!.methods[0].isOverride).toBe(true); + }); + + it('extracts getter and setter as methods', () => { + const tree = parseTypeScript(` + class Config { + get value(): number { return 1; } + set value(v: number) {} + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + // Getter and setter both have name 'value' (no get/set prefix from extractName) + expect(result!.methods).toHaveLength(2); + const getter = result!.methods[0]; + const setter = result!.methods[1]; + expect(getter.name).toBe('value'); + expect(getter.parameters).toHaveLength(0); + expect(getter.returnType).toBe('number'); + expect(setter.name).toBe('value'); + expect(setter.parameters).toHaveLength(1); + expect(setter.parameters[0].name).toBe('v'); + }); + + it('extracts destructured parameter', () => { + const tree = parseTypeScript(` + class Handler { + handle({ method, path }: Request): void {} + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + const params = result!.methods[0].parameters; + expect(params).toHaveLength(1); + // Destructured params extract the pattern text and type from annotation + expect(params[0].name).toBe('{ method, path }'); + expect(params[0].type).toBe('Request'); + }); + + it('extracts generator method as method_definition', () => { + const tree = parseTypeScript(` + class Stream { + *items(): Generator { yield 1; } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + expect(result!.methods).toHaveLength(1); + expect(result!.methods[0].name).toBe('items'); + expect(result!.methods[0].returnType).toBe('Generator'); + }); + + it('extracts async generator method with isAsync true', () => { + const tree = parseTypeScript(` + class Stream { + async *values(): AsyncGenerator { yield 1; } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + expect(result!.methods).toHaveLength(1); + expect(result!.methods[0].name).toBe('values'); + expect(result!.methods[0].isAsync).toBe(true); + expect(result!.methods[0].returnType).toBe('AsyncGenerator'); + }); + + it('extracts computed property name with brackets', () => { + const tree = parseTypeScript(` + class Iterable { + [Symbol.iterator](): Iterator { return this; } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + expect(result!.methods).toHaveLength(1); + // Computed names include brackets — this is intentional for static analysis disambiguation + expect(result!.methods[0].name).toBe('[Symbol.iterator]'); + }); + + it('extracts class-level method overloads', () => { + const tree = parseTypeScript(` + class Parser { + parse(input: string): string; + parse(input: number): number; + parse(input: string | number): string | number { return input; } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + // Two overload signatures (method_signature) + one implementation (method_definition) = 3 + const parseMethods = result!.methods.filter((m) => m.name === 'parse'); + expect(parseMethods).toHaveLength(3); + // Overload signatures inside a class body are not abstract + for (const m of parseMethods) { + expect(m.isAbstract).toBe(false); + } + }); + + it('filters out this-parameter (compile-time constraint)', () => { + const tree = parseTypeScript(` + class Handler { + handle(this: void, event: Event): void {} + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + const params = result!.methods[0].parameters; + // 'this' is not a real parameter — only 'event' should appear + expect(params).toHaveLength(1); + expect(params[0].name).toBe('event'); + expect(params[0].type).toBe('Event'); + }); + + it('does not false-positive on methods named after soft keywords', () => { + const tree = parseTypeScript(` + class Foo { + static abstract() {} + static() {} + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + const abstractMethod = result!.methods.find((m) => m.name === 'abstract'); + expect(abstractMethod).toBeDefined(); + expect(abstractMethod!.isStatic).toBe(true); + expect(abstractMethod!.isAbstract).toBe(false); // name, not keyword + + const staticMethod = result!.methods.find((m) => m.name === 'static'); + expect(staticMethod).toBeDefined(); + expect(staticMethod!.isStatic).toBe(false); // name, not keyword + }); + + it('extracts destructured rest parameter via required_parameter + rest_pattern', () => { + const tree = parseTypeScript(` + class Router { + route(base: string, ...{ method, path }: RouteConfig): void {} + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + const params = result!.methods[0].parameters; + expect(params).toHaveLength(2); + expect(params[0].name).toBe('base'); + expect(params[1].name).toBe('{ method, path }'); + expect(params[1].isVariadic).toBe(true); + expect(params[1].type).toBe('RouteConfig'); + }); + + it('extracts ES2022 #private method as visibility private', () => { + const tree = parseTypeScript(` + class Vault { + #decrypt(data: string): string { return data; } + public read(): string { return this.#decrypt("x"); } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + const decrypt = result!.methods.find((m) => m.name === '#decrypt'); + expect(decrypt).toBeDefined(); + expect(decrypt!.visibility).toBe('private'); + expect(decrypt!.parameters[0].type).toBe('string'); + + const read = result!.methods.find((m) => m.name === 'read'); + expect(read!.visibility).toBe('public'); + }); + + it('extracts generic method without type params in name', () => { + const tree = parseTypeScript(` + class Mapper { + transform(input: T, fn: (x: T) => U): U { return fn(input); } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + const m = result!.methods[0]; + expect(m.name).toBe('transform'); + expect(m.parameters).toHaveLength(2); + expect(m.parameters[0].name).toBe('input'); + expect(m.parameters[0].type).toBe('T'); + }); + + it('returns empty methods for class with no methods', () => { + const tree = parseTypeScript(` + class Empty {} + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + expect(result).not.toBeNull(); + expect(result!.ownerName).toBe('Empty'); + expect(result!.methods).toHaveLength(0); + }); + }); +}); + +// --------------------------------------------------------------------------- +// JavaScript +// --------------------------------------------------------------------------- + +const parseJavaScript = (code: string) => { + parser.setLanguage(JavaScript); + return parser.parse(code); +}; + +const jsCtx: MethodExtractorContext = { + filePath: 'Test.js', + language: SupportedLanguages.JavaScript, +}; + +describe('JavaScript MethodExtractor', () => { + const extractor = createMethodExtractor(javascriptMethodConfig); + + describe('extract', () => { + it('extracts class method with default public visibility and null types', () => { + const tree = parseJavaScript(` + class Greeter { + greet(name) { return "Hello " + name; } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, jsCtx); + + expect(result).not.toBeNull(); + expect(result!.ownerName).toBe('Greeter'); + expect(result!.methods).toHaveLength(1); + + const m = result!.methods[0]; + expect(m.name).toBe('greet'); + expect(m.returnType).toBeNull(); + expect(m.visibility).toBe('public'); + expect(m.isAbstract).toBe(false); + expect(m.parameters).toHaveLength(1); + expect(m.parameters[0]).toEqual({ + name: 'name', + type: null, + isOptional: false, + isVariadic: false, + }); + }); + + it('extracts static method and constructor', () => { + const tree = parseJavaScript(` + class Factory { + constructor(type) { this.type = type; } + static create(type) { return new Factory(type); } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, jsCtx); + + expect(result!.methods).toHaveLength(2); + const ctor = result!.methods.find((m) => m.name === 'constructor'); + const create = result!.methods.find((m) => m.name === 'create'); + expect(ctor).toBeDefined(); + expect(create!.isStatic).toBe(true); + }); + + it('extracts default parameter as optional and rest as variadic', () => { + const tree = parseJavaScript(` + class EventEmitter { + emit(event, data = null, ...listeners) {} + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, jsCtx); + + const params = result!.methods[0].parameters; + expect(params).toHaveLength(3); + expect(params[0]).toEqual({ + name: 'event', + type: null, + isOptional: false, + isVariadic: false, + }); + expect(params[1].name).toBe('data'); + expect(params[1].isOptional).toBe(true); + expect(params[2].name).toBe('listeners'); + expect(params[2].isVariadic).toBe(true); + }); + + it('does not detect abstract or interface types (JS has neither)', () => { + const tree = parseJavaScript(` + class Shape { + area() { return 0; } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, jsCtx); + + expect(result!.methods[0].isAbstract).toBe(false); + expect(result!.methods[0].isFinal).toBe(false); + }); + + it('extracts private field method with # prefix', () => { + const tree = parseJavaScript(` + class Encapsulated { + #internal() { return 42; } + expose() { return this.#internal(); } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, jsCtx); + + const internal = result!.methods.find((m) => m.name === '#internal'); + expect(internal).toBeDefined(); + expect(internal!.name).toBe('#internal'); + // ES2022 private methods (#name) are inherently private + expect(internal!.visibility).toBe('private'); + }); + + it('extracts destructured object parameter', () => { + const tree = parseJavaScript(` + class Handler { + handle({ method, path }) {} + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, jsCtx); + + const params = result!.methods[0].parameters; + expect(params).toHaveLength(1); + expect(params[0].name).toBe('{ method, path }'); + expect(params[0].type).toBeNull(); + }); + + it('extracts async method', () => { + const tree = parseJavaScript(` + class Client { + async fetch(url) { return null; } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, jsCtx); + + expect(result!.methods[0].isAsync).toBe(true); + expect(result!.methods[0].name).toBe('fetch'); + }); + }); +}); diff --git a/gitnexus/test/unit/method-signature.test.ts b/gitnexus/test/unit/method-signature.test.ts index 350457331..af9e79e55 100644 --- a/gitnexus/test/unit/method-signature.test.ts +++ b/gitnexus/test/unit/method-signature.test.ts @@ -64,6 +64,31 @@ describe('extractMethodSignature', () => { expect(sig.parameterCount).toBe(1); expect(sig.returnType).toBeUndefined(); }); + + it('skips TypeScript this-parameter (compile-time constraint)', () => { + parser.setLanguage(TypeScript.typescript); + const code = `class Handler { + handle(this: void, event: Event): void {} +}`; + const tree = parser.parse(code); + const classNode = tree.rootNode.child(0)!; + const classBody = classNode.childForFieldName('body')!; + const methodNode = classBody.namedChild(0)!; + + const sig = extractMethodSignature(methodNode); + // 'this' is not a real parameter — only 'event' should be counted + expect(sig.parameterCount).toBe(1); + }); + + it('skips this-parameter in top-level function', () => { + parser.setLanguage(TypeScript.typescript); + const code = `function onClick(this: HTMLElement, ev: MouseEvent): void {}`; + const tree = parser.parse(code); + const funcNode = tree.rootNode.child(0)!; + + const sig = extractMethodSignature(funcNode); + expect(sig.parameterCount).toBe(1); + }); }); describe('Python', () => { From 80d363f1451a4a5bbbdd9fefd496e1710f1b8c51 Mon Sep 17 00:00:00 2001 From: Abhigyan Patwari <126312502+abhigyanpatwari@users.noreply.github.com> Date: Wed, 1 Apr 2026 21:30:43 +0530 Subject: [PATCH 09/12] fix(wiki): Azure OpenAI compat and HTML viewer script injection (#618) * fix(wiki): Azure OpenAI compat and HTML viewer script injection - Use max_completion_tokens instead of deprecated max_tokens for all models - Skip sending temperature for Azure provider (some models reject non-default values) - Simplify Azure interactive setup: endpoint + deployment + key (3 prompts instead of 7) - Escape in embedded JSON to prevent premature script tag closure Co-Authored-By: Claude Opus 4.6 (1M context) * fix(test): align wiki-llm-client test with max_completion_tokens change The test expected max_tokens for non-reasoning models, but the source now uses max_completion_tokens for all models since max_tokens is deprecated by newer OpenAI models. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Abhigyan Patwari Co-authored-by: Claude Opus 4.6 (1M context) --- gitnexus/src/cli/wiki.ts | 76 +++++----------------- gitnexus/src/core/wiki/html-viewer.ts | 10 +-- gitnexus/src/core/wiki/llm-client.ts | 10 +-- gitnexus/test/unit/wiki-llm-client.test.ts | 6 +- 4 files changed, 32 insertions(+), 70 deletions(-) diff --git a/gitnexus/src/cli/wiki.ts b/gitnexus/src/cli/wiki.ts index 8d4446818..ccd1cae4e 100644 --- a/gitnexus/src/cli/wiki.ts +++ b/gitnexus/src/cli/wiki.ts @@ -232,65 +232,28 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio llmConfig = { ...llmConfig, provider: 'cursor', model, apiKey: '', baseUrl: '' }; } else if (choice === '3') { - // Azure OpenAI guided setup - console.log('\n Azure OpenAI setup.'); - console.log( - ' You need: your resource name, deployment name, and API key from the Azure portal.\n', - ); + // Azure OpenAI guided setup — minimal prompts + console.log('\n Azure OpenAI setup.\n'); - const resourceName = ( - await prompt(' Azure resource name (e.g. my-openai-resource): ') - ).trim(); - if (!resourceName) { - console.log('\n No resource name provided. Aborting.\n'); + const endpoint = ( + await prompt(' Endpoint URL (e.g. https://my-resource.openai.azure.com): ') + ) + .trim() + .replace(/\/+$/, ''); + if (!endpoint) { + console.log('\n No endpoint provided. Aborting.\n'); process.exitCode = 1; return; } - const deploymentName = ( - await prompt(' Deployment name (the name you gave your model deployment): ') - ).trim(); + const deploymentName = (await prompt(' Deployment name: ')).trim(); if (!deploymentName) { console.log('\n No deployment name provided. Aborting.\n'); process.exitCode = 1; return; } - // Offer v1 or legacy URL - console.log('\n API format:'); - console.log(' [1] v1 API — recommended (no api-version needed)'); - console.log(' [2] Legacy — uses api-version query param\n'); - const apiFormat = await prompt(' Select format (1/2, default: 1): '); - - let azureApiVersion: string | undefined; - let azureBaseUrl: string; - if (apiFormat === '2') { - const versionInput = await prompt(' api-version (default: 2024-10-21): '); - azureApiVersion = versionInput || '2024-10-21'; - azureBaseUrl = `https://${resourceName}.openai.azure.com/openai/deployments/${deploymentName}`; - } else { - azureBaseUrl = `https://${resourceName}.openai.azure.com/openai/v1`; - azureApiVersion = undefined; - } - - defaultModel = deploymentName; - - // Ask if this is a reasoning model deployment - const reasoningAnswer = await prompt( - ' Is this a reasoning model (o1, o3, o4-mini)? (y/N): ', - ); - const isReasoningModelDeployment = ['y', 'yes'].includes(reasoningAnswer.toLowerCase()); - - if (isReasoningModelDeployment) { - console.log( - ' Note: temperature and max_tokens will be omitted for this deployment (Azure reasoning model requirement).\n', - ); - } - - const modelInput = await prompt(` Model / deployment name (default: ${defaultModel}): `); - const model = modelInput || defaultModel; - - // API key + // API key — use env var if available const envKey = process.env.GITNEXUS_API_KEY || process.env.OPENAI_API_KEY || ''; let azureKey: string; if (envKey) { @@ -311,26 +274,23 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio return; } - // Save Azure config including optional apiVersion and isReasoningModel - const azureConfig: Parameters[0] = { + // Always use v1 API format — no need for api-version + const azureBaseUrl = `${endpoint}/openai/v1`; + + await saveCLIConfig({ apiKey: azureKey, baseUrl: azureBaseUrl, - model, + model: deploymentName, provider: 'azure', - isReasoningModel: isReasoningModelDeployment, - }; - if (azureApiVersion) azureConfig.apiVersion = azureApiVersion; - await saveCLIConfig(azureConfig); + }); console.log(' Config saved to ~/.gitnexus/config.json\n'); llmConfig = { ...llmConfig, apiKey: azureKey, baseUrl: azureBaseUrl, - model, + model: deploymentName, provider: 'azure', - apiVersion: azureApiVersion, - isReasoningModel: isReasoningModelDeployment, }; } else { // OpenAI-compatible provider (OpenAI, OpenRouter, Custom) diff --git a/gitnexus/src/core/wiki/html-viewer.ts b/gitnexus/src/core/wiki/html-viewer.ts index 1e4ecabc9..f961d36ae 100644 --- a/gitnexus/src/core/wiki/html-viewer.ts +++ b/gitnexus/src/core/wiki/html-viewer.ts @@ -67,10 +67,12 @@ function buildHTML( pages: Record, meta: Record | null, ): string { - // Embed data as JSON inside the HTML - const pagesJSON = JSON.stringify(pages); - const treeJSON = JSON.stringify(moduleTree); - const metaJSON = JSON.stringify(meta); + // Embed data as JSON inside the HTML. + // Escape sequences so they don't prematurely close the ` in embedded JSON so LLM-generated markdown no longer breaks the viewer (#618) +- Ensure import rewrites survive npm publish lifecycle + ## [1.4.0] - 2026-03-13 ### Added diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index a533998ef..c46519e41 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitnexus", - "version": "1.5.2", + "version": "1.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitnexus", - "version": "1.5.2", + "version": "1.6.0", "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { "@huggingface/transformers": "^3.0.0", diff --git a/gitnexus/package.json b/gitnexus/package.json index d7361e725..7a95ddb98 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -1,6 +1,6 @@ { "name": "gitnexus", - "version": "1.5.2", + "version": "1.6.0", "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.", "author": "Abhigyan Patwari", "license": "PolyForm-Noncommercial-1.0.0", From dc86ea96dc8d1097c8b82ed731a765c9fcbc9f95 Mon Sep 17 00:00:00 2001 From: Abhigyan Patwari Date: Wed, 1 Apr 2026 21:35:24 +0530 Subject: [PATCH 11/12] =?UTF-8?q?chore:=20release=20v1.5.3=20=E2=80=94=20u?= =?UTF-8?q?pdate=20CHANGELOG=20and=20package-lock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 2 +- gitnexus/package-lock.json | 4 ++-- gitnexus/package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b7128581..1b6298d78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ All notable changes to GitNexus will be documented in this file. - Added automatic cleanup of stale KuzuDB index files - LadybugDB v0.15 requires explicit VECTOR extension loading for semantic search -## [1.6.0] - 2026-04-01 +## [1.5.3] - 2026-04-01 ### Added diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index c46519e41..58fd23885 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitnexus", - "version": "1.6.0", + "version": "1.5.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitnexus", - "version": "1.6.0", + "version": "1.5.3", "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { "@huggingface/transformers": "^3.0.0", diff --git a/gitnexus/package.json b/gitnexus/package.json index 7a95ddb98..59bc71e50 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -1,6 +1,6 @@ { "name": "gitnexus", - "version": "1.6.0", + "version": "1.5.3", "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.", "author": "Abhigyan Patwari", "license": "PolyForm-Noncommercial-1.0.0", From ba5de0bde4069e36460b17b74b54315290bd081e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Wed, 1 Apr 2026 18:07:11 +0100 Subject: [PATCH 12/12] feat(cpp): C/C++ MethodExtractor config with pure virtual detection (#617) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cpp): C/C++ MethodExtractor config with pure virtual detection (#572) - Pure virtual (= 0) detected as isAbstract via token scanning - virtual/final/override via hasKeyword and virtual_specifier children - Access specifier visibility via backward sibling walk (public:/private:/protected:) - Pointer/reference parameter types extracted correctly - Constructor and destructor support via declaration node type - Static detection via storage_class_specifier - 16 new tests covering all acceptance criteria * fix(cpp): isVirtual infers from override/final + out-of-class resolution - isVirtual returns true for override/final methods (C++ mandates these are virtual) - Add findClassNodeByQualifiedName to parse-worker: resolves Foo::bar() back to the Foo class declaration for method extractor enrichment - Handles pointer/ref return types, constructors, destructors - Integration test for virtual/static/constructor inline methods - 233 unit+integration tests pass, 97 C++ resolver tests pass * fix(cpp): address review — deep pointers, templates, unions, trailing returns - Fix extractParamName: recursive unwrap for int** ptr → "ptr" (not "**ptr") - Fix findFunctionDeclarator: recursive unwrap for multi-level pointer chains - Template methods: generic extractor unwraps template_declaration to inner node - union_specifier: added to typeDeclarationNodes, visibility defaults to public - Trailing return type: auto foo() -> T now extracts T instead of "auto" - Fix version comment: ^0.22.4 → ^0.23.4 to match package.json - 4 new tests: double pointer params, template methods, union methods, trailing returns * fix(cpp): template method visibility + union isTypeDeclaration test extractCppVisibility now walks from the template_declaration parent when the node is wrapped by a template, restoring correct access- specifier resolution for templated class methods. Also adds missing isTypeDeclaration assertion for union_specifier and expands the template method test with explicit visibility checks. * fix(cpp): address deep gap analysis review findings - findClassNodeByQualifiedName: recursive pointer/reference declarator unwrap, fixing out-of-class linking for deep pointer return types (e.g. int** Foo::bar()) - findClassNodeByQualifiedName: recurse into namespace_definition blocks so namespace-wrapped classes resolve correctly - Suppress = delete / = default special members from extraction via delete_method_clause / default_method_clause node detection - Update known-gaps: namespace-wrapped classes, const-overload collapse - Add tree-sitter-c version comment for consistency - toBeFalsy() → toBe(undefined) for precise isVirtual assertion - Tests: = delete, = default, = 0 non-regression, operator overloads, deep pointer return types, default visibility (class vs struct), multiple access specifier sections --- .../src/core/ingestion/languages/c-cpp.ts | 4 + .../method-extractors/configs/c-cpp.ts | 372 +++++++++++++ .../ingestion/method-extractors/generic.ts | 8 +- .../core/ingestion/workers/parse-worker.ts | 72 ++- gitnexus/test/integration/has-method.test.ts | 57 ++ gitnexus/test/unit/method-extraction.test.ts | 488 ++++++++++++++++++ 6 files changed, 999 insertions(+), 2 deletions(-) create mode 100644 gitnexus/src/core/ingestion/method-extractors/configs/c-cpp.ts diff --git a/gitnexus/src/core/ingestion/languages/c-cpp.ts b/gitnexus/src/core/ingestion/languages/c-cpp.ts index a316a61d4..87bd3a7bf 100644 --- a/gitnexus/src/core/ingestion/languages/c-cpp.ts +++ b/gitnexus/src/core/ingestion/languages/c-cpp.ts @@ -22,6 +22,8 @@ import { cConfig as cFieldConfig, cppConfig as cppFieldConfig, } from '../field-extractors/configs/c-cpp.js'; +import { createMethodExtractor } from '../method-extractors/generic.js'; +import { cMethodConfig, cppMethodConfig } from '../method-extractors/configs/c-cpp.js'; const C_BUILT_INS: ReadonlySet = new Set([ 'printf', @@ -149,6 +151,7 @@ export const cProvider = defineLanguage({ importResolver: resolveCImport, importSemantics: 'wildcard', fieldExtractor: createFieldExtractor(cFieldConfig), + methodExtractor: createMethodExtractor(cMethodConfig), labelOverride: cppLabelOverride, builtInNames: C_BUILT_INS, }); @@ -163,6 +166,7 @@ export const cppProvider = defineLanguage({ importSemantics: 'wildcard', mroStrategy: 'leftmost-base', fieldExtractor: createFieldExtractor(cppFieldConfig), + methodExtractor: createMethodExtractor(cppMethodConfig), labelOverride: cppLabelOverride, builtInNames: C_BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/method-extractors/configs/c-cpp.ts b/gitnexus/src/core/ingestion/method-extractors/configs/c-cpp.ts new file mode 100644 index 000000000..615b279a8 --- /dev/null +++ b/gitnexus/src/core/ingestion/method-extractors/configs/c-cpp.ts @@ -0,0 +1,372 @@ +// gitnexus/src/core/ingestion/method-extractors/configs/c-cpp.ts +// Verified against tree-sitter-cpp ^0.23.4 + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { + MethodExtractionConfig, + ParameterInfo, + MethodVisibility, +} from '../../method-types.js'; +import { hasKeyword } from '../../field-extractors/configs/helpers.js'; +import { extractSimpleTypeName } from '../../type-extractors/shared.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +// --------------------------------------------------------------------------- +// C/C++ helpers +// --------------------------------------------------------------------------- + +/** + * Find the function_declarator inside a method node, handling pointer/reference + * return types where the function_declarator is nested inside a pointer_declarator + * or reference_declarator. + */ +function findFunctionDeclarator(node: SyntaxNode): SyntaxNode | null { + const declarator = node.childForFieldName('declarator'); + if (!declarator) return null; + if (declarator.type === 'function_declarator') return declarator; + // Recursively unwrap pointer_declarator / reference_declarator chains + // (e.g. int** (*pfn)() has pointer_declarator → pointer_declarator → function_declarator) + let current: SyntaxNode | null = declarator; + while (current) { + for (let i = 0; i < current.namedChildCount; i++) { + const child = current.namedChild(i); + if (child?.type === 'function_declarator') return child; + } + // Go deeper into nested pointer/reference declarators + const next = current.namedChildren.find( + (c) => c.type === 'pointer_declarator' || c.type === 'reference_declarator', + ); + current = next ?? null; + } + return null; +} + +/** + * Detect `= delete` and `= default` special member function declarations. + * These are not callable methods and should be suppressed from extraction. + * tree-sitter-cpp ^0.23.4 emits `delete_method_clause` / `default_method_clause` + * as named children of the function_definition node. + */ +function isDeletedOrDefaulted(node: SyntaxNode): boolean { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'delete_method_clause' || child?.type === 'default_method_clause') { + return true; + } + } + return false; +} + +/** + * Extract method name from a function_declarator. + * The name is the `declarator` field of the function_declarator — typically a + * field_identifier, but can be a destructor_name (~ClassName) or operator name. + */ +function extractCppMethodName(node: SyntaxNode): string | undefined { + const funcDecl = findFunctionDeclarator(node); + if (!funcDecl) return undefined; + + // Suppress `= delete` and `= default` special members — these are not callable + // methods and should not appear in HAS_METHOD edges. + if (isDeletedOrDefaulted(node)) return undefined; + + const nameNode = funcDecl.childForFieldName('declarator'); + if (!nameNode) return undefined; + // destructor_name: ~ClassName + if (nameNode.type === 'destructor_name') return nameNode.text; + // operator_name: operator==, operator+, etc. + if (nameNode.type === 'operator_name') return nameNode.text; + return nameNode.text; +} + +/** + * Extract return type from the `type` field of the method node. + * tree-sitter-cpp puts the return type as the `type` field on field_declaration + * and function_definition nodes. + */ +function extractCppReturnType(node: SyntaxNode): string | undefined { + const typeNode = node.childForFieldName('type'); + if (typeNode) { + const typeText = extractSimpleTypeName(typeNode) ?? typeNode.text?.trim(); + // C++11 trailing return type: `auto foo() -> ReturnType` + // When the declared type is `auto`, check for a trailing_return_type on the + // function_declarator which holds the actual return type. + if (typeText === 'auto') { + const funcDecl = findFunctionDeclarator(node); + if (funcDecl) { + for (let i = 0; i < funcDecl.namedChildCount; i++) { + const child = funcDecl.namedChild(i); + if (child?.type === 'trailing_return_type') { + // trailing_return_type contains a type_descriptor with the real type + const typeDesc = child.firstNamedChild; + if (typeDesc) return extractSimpleTypeName(typeDesc) ?? typeDesc.text?.trim(); + } + } + } + } + return typeText; + } + // Fallback: first type-like named child (for declarations without type field) + const first = node.firstNamedChild; + if ( + first && + (first.type === 'primitive_type' || + first.type === 'type_identifier' || + first.type === 'sized_type_specifier' || + first.type === 'template_type') + ) { + return extractSimpleTypeName(first) ?? first.text?.trim(); + } + return undefined; +} + +/** + * Extract parameters from the parameter_list inside the function_declarator. + * + * C/C++ uses parameter_declaration (required) and optional_parameter_declaration + * (with default value). Variadic `...` appears as a variadic_parameter_declaration. + */ +function extractCppParameters(node: SyntaxNode): ParameterInfo[] { + const funcDecl = findFunctionDeclarator(node); + if (!funcDecl) return []; + const paramList = funcDecl.childForFieldName('parameters'); + if (!paramList) return []; + const params: ParameterInfo[] = []; + + for (let i = 0; i < paramList.namedChildCount; i++) { + const param = paramList.namedChild(i); + if (!param) continue; + + switch (param.type) { + case 'parameter_declaration': { + const typeNode = param.childForFieldName('type'); + const declNode = param.childForFieldName('declarator'); + // Extract name — may be wrapped in pointer_declarator or reference_declarator + const name = extractParamName(declNode); + params.push({ + name: name ?? typeNode?.text?.trim() ?? '?', + type: typeNode + ? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null) + : null, + isOptional: false, + isVariadic: false, + }); + break; + } + case 'optional_parameter_declaration': { + const typeNode = param.childForFieldName('type'); + const declNode = param.childForFieldName('declarator'); + const name = extractParamName(declNode); + params.push({ + name: name ?? typeNode?.text?.trim() ?? '?', + type: typeNode + ? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null) + : null, + isOptional: true, + isVariadic: false, + }); + break; + } + case 'variadic_parameter_declaration': { + // C-style `...` or typed variadic `T... args` + const typeNode = param.childForFieldName('type'); + const declNode = param.childForFieldName('declarator'); + const name = extractParamName(declNode); + params.push({ + name: name ?? '...', + type: typeNode + ? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null) + : null, + isOptional: false, + isVariadic: true, + }); + break; + } + case 'variadic_parameter': { + // Bare `...` (C-style) + params.push({ + name: '...', + type: null, + isOptional: false, + isVariadic: true, + }); + break; + } + } + } + return params; +} + +/** Extract parameter name, recursively unwrapping pointer/reference declarators. */ +function extractParamName(declNode: SyntaxNode | null): string | undefined { + if (!declNode) return undefined; + if (declNode.type === 'identifier') return declNode.text; + // Recursively unwrap pointer_declarator / reference_declarator chains (e.g. int** ptr) + for (let i = 0; i < declNode.namedChildCount; i++) { + const child = declNode.namedChild(i); + if (!child) continue; + if (child.type === 'identifier') return child.text; + if (child.type === 'pointer_declarator' || child.type === 'reference_declarator') { + return extractParamName(child); + } + } + return undefined; +} + +/** + * Detect C++ access specifier by walking backwards through siblings. + * Mirrors the field extractor pattern in c-cpp.ts. + */ +function extractCppVisibility(node: SyntaxNode): MethodVisibility { + // If this node was unwrapped from a template_declaration, the access_specifier + // is a sibling of the template_declaration in field_declaration_list, not of + // this node — climb up one level before walking backward. + const startNode = node.parent?.type === 'template_declaration' ? node.parent : node; + + let sibling = startNode.previousNamedSibling; + while (sibling) { + if (sibling.type === 'access_specifier') { + const text = sibling.text.replace(':', '').trim(); + if (text === 'public' || text === 'private' || text === 'protected') return text; + } + sibling = sibling.previousNamedSibling; + } + // Default: struct/union = public, class = private + const parent = startNode.parent?.parent; + return parent?.type === 'struct_specifier' || parent?.type === 'union_specifier' + ? 'public' + : 'private'; +} + +/** + * Detect pure virtual methods (`= 0`). + * tree-sitter-cpp emits `=` (unnamed) followed by `number_literal` with text `0`. + */ +function isPureVirtual(node: SyntaxNode): boolean { + let foundEquals = false; + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (!child) continue; + if (child.text === '=') { + foundEquals = true; + } else if (foundEquals && child.type === 'number_literal' && child.text === '0') { + return true; + } else if (foundEquals) { + foundEquals = false; // Reset if something else follows `=` + } + } + return false; +} + +/** + * Check for a virtual_specifier ('final' or 'override') inside the function_declarator. + * In tree-sitter-cpp, these are named children of the function_declarator, not the + * method node itself. + */ +function hasVirtualSpecifier(node: SyntaxNode, keyword: string): boolean { + const funcDecl = findFunctionDeclarator(node); + if (!funcDecl) return false; + for (let i = 0; i < funcDecl.namedChildCount; i++) { + const child = funcDecl.namedChild(i); + if (child?.type === 'virtual_specifier' && child.text === keyword) return true; + } + return false; +} + +// --------------------------------------------------------------------------- +// C++ config +// --------------------------------------------------------------------------- + +// C++ methods appear as field_declaration (declarations) or function_definition +// (inline definitions) inside field_declaration_list. The generic extractor +// iterates bodyNodeTypes children and matches against methodNodeTypes. +// +// Key difference from TS/JVM/C#: C++ has no dedicated method_declaration node. +// A field_declaration is a method if it contains a function_declarator. +// The generic extractor calls extractName() on every methodNodeType node — if +// extractName returns undefined (no function_declarator), the method is skipped. +// +// Known gaps: +// - Out-of-class method definitions (void Foo::bar() {}) are not linked as +// HAS_METHOD — they appear as top-level function_definition nodes. +// This includes namespace-wrapped and nested classes. +// - Friend declarations are not extracted. +// - Template method declarations with explicit specialization. +// - const-qualified method overloads (e.g. begin() vs begin() const) collapse +// to the same name — the schema has no isConst field to distinguish them. +export const cppMethodConfig: MethodExtractionConfig = { + language: SupportedLanguages.CPlusPlus, + typeDeclarationNodes: ['class_specifier', 'struct_specifier', 'union_specifier'], + // declaration covers constructors/destructors; field_declaration covers method + // declarations; function_definition covers inline method definitions. + // Non-method declarations (variables, typedefs) are filtered by extractName + // returning undefined when no function_declarator is found. + methodNodeTypes: ['field_declaration', 'function_definition', 'declaration'], + bodyNodeTypes: ['field_declaration_list'], + + extractName: extractCppMethodName, + extractReturnType: extractCppReturnType, + extractParameters: extractCppParameters, + extractVisibility: extractCppVisibility, + + isStatic(node) { + return hasKeyword(node, 'static'); + }, + + isAbstract(node) { + return isPureVirtual(node); + }, + + isFinal(node) { + return hasVirtualSpecifier(node, 'final'); + }, + + isVirtual(node) { + // In C++, override and method-level final are only legal on virtual functions, + // so they imply virtual even without the explicit keyword. + return ( + hasKeyword(node, 'virtual') || + hasVirtualSpecifier(node, 'override') || + hasVirtualSpecifier(node, 'final') + ); + }, + + isOverride(node) { + return hasVirtualSpecifier(node, 'override'); + }, +}; + +// --------------------------------------------------------------------------- +// C config (minimal — C has no classes/methods, only struct function pointers) +// Verified against tree-sitter-c 0.23.2 +// --------------------------------------------------------------------------- + +// C does not have methods in the OOP sense. Structs with function pointer fields +// are handled by the field extractor. This config exists for completeness but +// will rarely match since C structs don't contain function_definition nodes. +export const cMethodConfig: MethodExtractionConfig = { + language: SupportedLanguages.C, + typeDeclarationNodes: ['struct_specifier'], + methodNodeTypes: ['function_definition'], + bodyNodeTypes: ['field_declaration_list'], + + extractName: extractCppMethodName, + extractReturnType: extractCppReturnType, + extractParameters: extractCppParameters, + + extractVisibility() { + return 'public'; // C has no access control + }, + + isStatic(node) { + return hasKeyword(node, 'static'); + }, + + isAbstract() { + return false; // C has no virtual/abstract + }, + + isFinal() { + return false; + }, +}; diff --git a/gitnexus/src/core/ingestion/method-extractors/generic.ts b/gitnexus/src/core/ingestion/method-extractors/generic.ts index ebc7163eb..0adecb518 100644 --- a/gitnexus/src/core/ingestion/method-extractors/generic.ts +++ b/gitnexus/src/core/ingestion/method-extractors/generic.ts @@ -120,9 +120,15 @@ function extractMethodsFromBody( out: MethodInfo[], ): void { for (let i = 0; i < body.namedChildCount; i++) { - const child = body.namedChild(i); + let child = body.namedChild(i); if (!child) continue; + // C++ template methods are wrapped in template_declaration — unwrap to the inner node + if (child.type === 'template_declaration') { + const inner = child.namedChildren.find((c) => methodNodeSet.has(c.type)); + if (inner) child = inner; + } + if (methodNodeSet.has(child.type)) { const method = buildMethod(child, ownerNode, context, config); if (method) out.push(method); diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 20909db11..7f2253189 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -352,6 +352,75 @@ function findEnclosingClassNode(node: SyntaxNode): SyntaxNode | null { return null; } +/** + * For C++ out-of-class method definitions (e.g. `void Foo::bar() {}`), extract the + * class name from the qualified_identifier scope and find the class declaration in the + * file's AST. Returns the class SyntaxNode or null if not found. + * + * Handles pointer/reference return types where function_declarator is nested inside + * pointer_declarator or reference_declarator. + */ +function findClassNodeByQualifiedName(node: SyntaxNode): SyntaxNode | null { + const declarator = node.childForFieldName('declarator'); + if (!declarator) return null; + + // Find the function_declarator, recursively unwrapping pointer_declarator / + // reference_declarator chains (e.g. int** Foo::bar() has + // pointer_declarator → pointer_declarator → function_declarator). + let funcDecl: SyntaxNode | null = null; + if (declarator.type === 'function_declarator') { + funcDecl = declarator; + } else { + let current: SyntaxNode | null = declarator; + while (current && !funcDecl) { + for (let i = 0; i < current.namedChildCount; i++) { + const child = current.namedChild(i); + if (child?.type === 'function_declarator') { + funcDecl = child; + break; + } + } + if (!funcDecl) { + const next = current.namedChildren.find( + (c) => c.type === 'pointer_declarator' || c.type === 'reference_declarator', + ); + current = next ?? null; + } + } + } + if (!funcDecl) return null; + + // Check if the inner declarator is a qualified_identifier (Foo::bar) + const innerDecl = funcDecl.childForFieldName('declarator'); + if (!innerDecl || innerDecl.type !== 'qualified_identifier') return null; + + const scope = innerDecl.childForFieldName('scope'); + if (!scope) return null; + const className = scope.text; + + // Search the file for a matching class/struct specifier, including inside + // namespace_definition blocks (the majority of production C++ uses namespaces). + const root = node.tree.rootNode; + const classTypes = new Set(['class_specifier', 'struct_specifier']); + const searchIn = (parent: SyntaxNode): SyntaxNode | null => { + for (let i = 0; i < parent.namedChildCount; i++) { + const child = parent.namedChild(i); + if (!child) continue; + if (classTypes.has(child.type)) { + const nameNode = child.childForFieldName('name'); + if (nameNode?.text === className) return child; + } + // Recurse into namespace blocks + if (child.type === 'namespace_definition') { + const found = searchIn(child); + if (found) return found; + } + } + return null; + }; + return searchIn(root); +} + /** * Minimal no-op SymbolTable stub for FieldExtractorContext in the worker. * Field extraction only uses symbolTable.lookupExactAll for optional type resolution — @@ -1713,7 +1782,8 @@ const processFileGroup = ( // MethodExtractor is available or the method isn't inside a class body. let enrichedByMethodExtractor = false; if (provider.methodExtractor && definitionNode) { - const classNode = findEnclosingClassNode(definitionNode); + const classNode = + findEnclosingClassNode(definitionNode) ?? findClassNodeByQualifiedName(definitionNode); if (classNode) { const methodMap = getMethodInfo(classNode, provider, { filePath: file.path, diff --git a/gitnexus/test/integration/has-method.test.ts b/gitnexus/test/integration/has-method.test.ts index bf9ecce20..4ad257ab6 100644 --- a/gitnexus/test/integration/has-method.test.ts +++ b/gitnexus/test/integration/has-method.test.ts @@ -535,6 +535,63 @@ public: }); }); +describe('HAS_METHOD integration — C++ virtual/static/constructor inline methods', () => { + beforeAll(async () => { + await loadLanguage(SupportedLanguages.CPlusPlus); + }); + + it('virtual, override, static, and constructor methods are captured from inline class body', () => { + const code = ` +class Shape { +public: + Shape() {} + virtual ~Shape() {} + virtual double area() = 0; + static Shape* create(); +}; + +class Circle : public Shape { +public: + Circle(double r) : radius(r) {} + double area() override { return 3.14 * radius * radius; } +private: + double radius; +}; +`; + const results = parseAndExtractMethods(code, SupportedLanguages.CPlusPlus, 'src/shapes.h'); + + // Shape methods + const shapeCtor = results.find( + (r) => r.name === 'Shape' && r.enclosingClassId === 'Class:src/shapes.h:Shape', + ); + expect(shapeCtor).toBeDefined(); + + const shapeDtor = results.find((r) => r.name === '~Shape'); + expect(shapeDtor).toBeDefined(); + expect(shapeDtor!.enclosingClassId).toBe('Class:src/shapes.h:Shape'); + + const area = results.find( + (r) => r.name === 'area' && r.enclosingClassId === 'Class:src/shapes.h:Shape', + ); + expect(area).toBeDefined(); + + const create = results.find((r) => r.name === 'create'); + expect(create).toBeDefined(); + expect(create!.enclosingClassId).toBe('Class:src/shapes.h:Shape'); + + // Circle methods + const circleCtor = results.find( + (r) => r.name === 'Circle' && r.enclosingClassId === 'Class:src/shapes.h:Circle', + ); + expect(circleCtor).toBeDefined(); + + const circleArea = results.find( + (r) => r.name === 'area' && r.enclosingClassId === 'Class:src/shapes.h:Circle', + ); + expect(circleArea).toBeDefined(); + }); +}); + describe('HAS_METHOD integration — TypeScript: abstract, interface, and #private methods', () => { beforeAll(async () => { await loadLanguage(SupportedLanguages.TypeScript, 'methods.ts'); diff --git a/gitnexus/test/unit/method-extraction.test.ts b/gitnexus/test/unit/method-extraction.test.ts index eae62ce68..7ee8a68cd 100644 --- a/gitnexus/test/unit/method-extraction.test.ts +++ b/gitnexus/test/unit/method-extraction.test.ts @@ -9,10 +9,12 @@ import { typescriptMethodConfig, javascriptMethodConfig, } from '../../src/core/ingestion/method-extractors/configs/typescript-javascript.js'; +import { cppMethodConfig } from '../../src/core/ingestion/method-extractors/configs/c-cpp.js'; import type { MethodExtractorContext } from '../../src/core/ingestion/method-types.js'; import Parser from 'tree-sitter'; import Java from 'tree-sitter-java'; import CSharp from 'tree-sitter-c-sharp'; +import CPP from 'tree-sitter-cpp'; import TypeScript from 'tree-sitter-typescript'; import JavaScript from 'tree-sitter-javascript'; import { SupportedLanguages } from '../../src/config/supported-languages.js'; @@ -1908,3 +1910,489 @@ describe('JavaScript MethodExtractor', () => { }); }); }); + +// --------------------------------------------------------------------------- +// C++ +// --------------------------------------------------------------------------- + +const parseCPP = (code: string) => { + parser.setLanguage(CPP); + return parser.parse(code); +}; + +const cppCtx: MethodExtractorContext = { + filePath: 'Test.cpp', + language: SupportedLanguages.CPlusPlus, +}; + +describe('C++ MethodExtractor', () => { + const extractor = createMethodExtractor(cppMethodConfig); + + describe('isTypeDeclaration', () => { + it('recognizes class_specifier', () => { + const tree = parseCPP('class Foo {};'); + expect(extractor.isTypeDeclaration(tree.rootNode.child(0)!)).toBe(true); + }); + + it('recognizes struct_specifier', () => { + const tree = parseCPP('struct Bar {};'); + expect(extractor.isTypeDeclaration(tree.rootNode.child(0)!)).toBe(true); + }); + + it('recognizes union_specifier', () => { + const tree = parseCPP('union Variant {};'); + expect(extractor.isTypeDeclaration(tree.rootNode.child(0)!)).toBe(true); + }); + + it('rejects function_definition', () => { + const tree = parseCPP('void foo() {}'); + expect(extractor.isTypeDeclaration(tree.rootNode.child(0)!)).toBe(false); + }); + }); + + describe('extract', () => { + it('extracts pure virtual method as isAbstract and isVirtual', () => { + const tree = parseCPP(` + class Shape { + public: + virtual double area() const = 0; + }; + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, cppCtx); + + expect(result).not.toBeNull(); + expect(result!.ownerName).toBe('Shape'); + expect(result!.methods).toHaveLength(1); + + const m = result!.methods[0]; + expect(m.name).toBe('area'); + expect(m.returnType).toBe('double'); + expect(m.isAbstract).toBe(true); + expect(m.isVirtual).toBe(true); + expect(m.visibility).toBe('public'); + }); + + it('extracts virtual non-pure method as isAbstract false', () => { + const tree = parseCPP(` + class Base { + public: + virtual void draw() {} + }; + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, cppCtx); + + const m = result!.methods[0]; + expect(m.name).toBe('draw'); + expect(m.isAbstract).toBe(false); + expect(m.isVirtual).toBe(true); + }); + + it('extracts final method', () => { + const tree = parseCPP(` + class Derived { + public: + void process() final; + }; + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, cppCtx); + + expect(result!.methods[0].name).toBe('process'); + expect(result!.methods[0].isFinal).toBe(true); + // final is only legal on virtual functions — isVirtual must be true + expect(result!.methods[0].isVirtual).toBe(true); + }); + + it('extracts override method', () => { + const tree = parseCPP(` + class Child { + public: + void draw() override {} + }; + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, cppCtx); + + expect(result!.methods[0].name).toBe('draw'); + expect(result!.methods[0].isOverride).toBe(true); + // override is only legal on virtual functions — isVirtual must be true + expect(result!.methods[0].isVirtual).toBe(true); + }); + + it('non-virtual method has isVirtual false', () => { + const tree = parseCPP(` + class Plain { + public: + void bar(); + }; + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, cppCtx); + + expect(result!.methods[0].isVirtual).toBe(undefined); + }); + + it('extracts static method', () => { + const tree = parseCPP(` + class Factory { + public: + static Factory* create(); + }; + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, cppCtx); + + expect(result!.methods[0].name).toBe('create'); + expect(result!.methods[0].isStatic).toBe(true); + expect(result!.methods[0].returnType).toBe('Factory'); + }); + + it('extracts parameters with types including pointer and reference', () => { + const tree = parseCPP(` + class Handler { + public: + void process(int x, const char* name, double& ref); + }; + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, cppCtx); + + const params = result!.methods[0].parameters; + expect(params).toHaveLength(3); + expect(params[0].name).toBe('x'); + expect(params[0].type).toBe('int'); + expect(params[1].name).toBe('name'); + expect(params[1].type).toBe('char'); + expect(params[2].name).toBe('ref'); + expect(params[2].type).toBe('double'); + }); + + it('extracts optional parameter with default value', () => { + const tree = parseCPP(` + class Config { + public: + void set(int value, int priority = 0); + }; + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, cppCtx); + + const params = result!.methods[0].parameters; + expect(params).toHaveLength(2); + expect(params[0].isOptional).toBe(false); + expect(params[1].name).toBe('priority'); + expect(params[1].isOptional).toBe(true); + }); + + it('extracts access specifier visibility correctly', () => { + const tree = parseCPP(` + class Account { + public: + void deposit(int amount); + private: + void validate(); + protected: + void notify(); + }; + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, cppCtx); + + expect(result!.methods).toHaveLength(3); + const deposit = result!.methods.find((m) => m.name === 'deposit'); + const validate = result!.methods.find((m) => m.name === 'validate'); + const notify = result!.methods.find((m) => m.name === 'notify'); + expect(deposit!.visibility).toBe('public'); + expect(validate!.visibility).toBe('private'); + expect(notify!.visibility).toBe('protected'); + }); + + it('defaults to private for class without access specifier', () => { + const tree = parseCPP(` + class Foo { + void bar(); + }; + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, cppCtx); + + expect(result!.methods[0].visibility).toBe('private'); + }); + + it('defaults to public for struct without access specifier', () => { + const tree = parseCPP(` + struct Foo { + void bar(); + }; + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, cppCtx); + + expect(result!.methods[0].visibility).toBe('public'); + }); + + it('extracts destructor', () => { + const tree = parseCPP(` + class Resource { + public: + ~Resource(); + }; + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, cppCtx); + + expect(result!.methods[0].name).toBe('~Resource'); + }); + + it('extracts constructor', () => { + const tree = parseCPP(` + class Point { + public: + Point(int x, int y); + }; + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, cppCtx); + + expect(result!.methods).toHaveLength(1); + expect(result!.methods[0].name).toBe('Point'); + expect(result!.methods[0].parameters).toHaveLength(2); + }); + + it('returns empty methods for class with only data members', () => { + const tree = parseCPP(` + class Data { + int x; + int y; + }; + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, cppCtx); + + // field_declaration without function_declarator → extractName returns undefined → skipped + expect(result).not.toBeNull(); + expect(result!.methods).toHaveLength(0); + }); + + it('extracts double-pointer parameter name correctly', () => { + const tree = parseCPP(` + class Allocator { + public: + void alloc(int** ptr, char** argv); + }; + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, cppCtx); + + const params = result!.methods[0].parameters; + expect(params).toHaveLength(2); + expect(params[0].name).toBe('ptr'); + expect(params[0].type).toBe('int'); + expect(params[1].name).toBe('argv'); + }); + + it('extracts template methods from class body with correct visibility', () => { + const tree = parseCPP(` + class Buffer { + public: + template + void push(T value); + template + T get(int index) { return T(); } + private: + template + void internal(T x); + }; + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, cppCtx); + + expect(result!.methods).toHaveLength(3); + const push = result!.methods.find((m) => m.name === 'push'); + const get = result!.methods.find((m) => m.name === 'get'); + const internal = result!.methods.find((m) => m.name === 'internal'); + expect(push).toBeDefined(); + expect(push!.parameters).toHaveLength(1); + expect(push!.parameters[0].name).toBe('value'); + expect(push!.visibility).toBe('public'); + expect(get).toBeDefined(); + expect(get!.parameters).toHaveLength(1); + expect(get!.parameters[0].name).toBe('index'); + expect(get!.visibility).toBe('public'); + expect(internal).toBeDefined(); + expect(internal!.visibility).toBe('private'); + }); + + it('extracts methods from union_specifier', () => { + const tree = parseCPP(` + union Variant { + void clear(); + int asInt() const; + }; + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, cppCtx); + + expect(result).not.toBeNull(); + expect(result!.ownerName).toBe('Variant'); + expect(result!.methods).toHaveLength(2); + // Union default visibility is public (like struct) + expect(result!.methods[0].visibility).toBe('public'); + expect(result!.methods[1].visibility).toBe('public'); + }); + + it('suppresses = delete special members from extraction', () => { + const tree = parseCPP(` + class NonCopyable { + public: + void doWork(); + NonCopyable(const NonCopyable&) = delete; + NonCopyable& operator=(const NonCopyable&) = delete; + }; + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, cppCtx); + + expect(result!.methods).toHaveLength(1); + expect(result!.methods[0].name).toBe('doWork'); + }); + + it('suppresses = default special members from extraction', () => { + const tree = parseCPP(` + class Widget { + public: + Widget() = default; + ~Widget() = default; + void paint(); + }; + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, cppCtx); + + expect(result!.methods).toHaveLength(1); + expect(result!.methods[0].name).toBe('paint'); + }); + + it('does not suppress = 0 (pure virtual) as deleted/defaulted', () => { + const tree = parseCPP(` + class Shape { + public: + virtual double area() = 0; + }; + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, cppCtx); + + expect(result!.methods).toHaveLength(1); + expect(result!.methods[0].name).toBe('area'); + expect(result!.methods[0].isAbstract).toBe(true); + }); + + it('extracts operator overloads', () => { + const tree = parseCPP(` + class Vec { + public: + Vec operator+(const Vec& rhs) const; + bool operator==(const Vec& rhs) const; + Vec& operator<<(int val); + }; + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, cppCtx); + + expect(result!.methods).toHaveLength(3); + const names = result!.methods.map((m) => m.name); + expect(names).toContain('operator+'); + expect(names).toContain('operator=='); + expect(names).toContain('operator<<'); + + const plus = result!.methods.find((m) => m.name === 'operator+')!; + expect(plus.returnType).toBe('Vec'); + expect(plus.parameters).toHaveLength(1); + expect(plus.parameters[0].name).toBe('rhs'); + }); + + it('extracts method with deep pointer return type', () => { + const tree = parseCPP(` + class Matrix { + public: + int** getBuffer(); + const char* getName(); + }; + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, cppCtx); + + expect(result!.methods).toHaveLength(2); + expect(result!.methods[0].name).toBe('getBuffer'); + expect(result!.methods[0].returnType).toBe('int'); + expect(result!.methods[1].name).toBe('getName'); + }); + + it('defaults to private visibility for class, public for struct', () => { + const classTree = parseCPP(` + class Foo { + void secret(); + }; + `); + const classResult = extractor.extract(classTree.rootNode.child(0)!, cppCtx); + expect(classResult!.methods[0].name).toBe('secret'); + expect(classResult!.methods[0].visibility).toBe('private'); + + const structTree = parseCPP(` + struct Bar { + void open(); + }; + `); + const structResult = extractor.extract(structTree.rootNode.child(0)!, cppCtx); + expect(structResult!.methods[0].name).toBe('open'); + expect(structResult!.methods[0].visibility).toBe('public'); + }); + + it('tracks visibility across multiple access specifier sections', () => { + const tree = parseCPP(` + class Mixed { + public: + void pub1(); + private: + void priv1(); + void priv2(); + protected: + void prot1(); + public: + void pub2(); + }; + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, cppCtx); + + expect(result!.methods).toHaveLength(5); + const byName = Object.fromEntries(result!.methods.map((m) => [m.name, m.visibility])); + expect(byName['pub1']).toBe('public'); + expect(byName['priv1']).toBe('private'); + expect(byName['priv2']).toBe('private'); + expect(byName['prot1']).toBe('protected'); + expect(byName['pub2']).toBe('public'); + }); + + it('extracts trailing return type instead of auto', () => { + const tree = parseCPP(` + class Container { + public: + auto begin() -> iterator; + auto size() -> size_t; + }; + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, cppCtx); + + expect(result!.methods).toHaveLength(2); + expect(result!.methods[0].name).toBe('begin'); + expect(result!.methods[0].returnType).toBe('iterator'); + expect(result!.methods[1].name).toBe('size'); + expect(result!.methods[1].returnType).toBe('size_t'); + }); + }); +});