Merge branch 'main' into fix/wiki-script-escape-and-llm-token-param

This commit is contained in:
Jobin Kurian 2026-04-02 10:56:55 +05:30 committed by GitHub
commit 8355ceb8e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 2460 additions and 136 deletions

View file

@ -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
@ -63,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' }}

View file

@ -10,6 +10,19 @@ 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.5.3] - 2026-04-01
### Added
- **TypeScript/JavaScript MethodExtractor config** — shared extraction config covering abstract methods, visibility modifiers, async/override keywords, decorators, rest/optional/destructured parameters, and return types (#588) — @compound-ai
### Fixed
- **Azure OpenAI compatibility** — use `max_completion_tokens` instead of deprecated `max_tokens` (newer models reject `max_tokens`); skip `temperature` for Azure provider (some models reject non-default values) (#618)
- **Simplified Azure interactive setup** — 3 prompts (endpoint, deployment, key) instead of 7 (#618)
- **Wiki HTML viewer script injection** — escape `</script>` 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

View file

@ -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

View file

@ -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 });
});

View file

@ -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<void>;
@ -144,75 +150,52 @@ export const DropZone = ({ onServerConnect }: DropZoneProps) => {
const autoConnectTimerRef = useRef<ReturnType<typeof setTimeout> | 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<AbortController | null>(null);
const [detectedRepos, setDetectedRepos] = useState<BackendRepo[]>([]);
// 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 && (
<Crossfade activeKey={displayPhase}>
{displayPhase === 'onboarding' && <OnboardingGuide isPolling={isPolling} />}
{displayPhase === 'analyze' && <AnalyzeOnboarding onComplete={handleAnalyzeComplete} />}
{displayPhase === 'analyze' && <AnalyzeOnboarding onComplete={connectToRepo} />}
{displayPhase === 'landing' && (
<RepoLanding
repos={detectedRepos}
onSelectRepo={connectToRepo}
onAnalyzeComplete={connectToRepo}
/>
)}
{displayPhase === 'success' && <SuccessCard />}
{displayPhase === 'loading' && <LoadingCard message={loadingMessage} />}
</Crossfade>

View file

@ -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 (
<button
onClick={onClick}
data-testid="landing-repo-card"
className="group w-full cursor-pointer rounded-xl border border-border-default bg-elevated p-4 text-left transition-all duration-200 hover:border-accent/40 hover:bg-hover hover:shadow-glow-soft"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4 shrink-0 text-accent" />
<h3 className="truncate text-sm font-semibold text-text-primary transition-colors group-hover:text-accent">
{repo.name}
</h3>
</div>
{repo.indexedAt && (
<p className="mt-1 pl-6 text-xs text-text-muted">
Indexed {formatRelativeTime(repo.indexedAt)}
</p>
)}
</div>
<ArrowRight className="h-4 w-4 shrink-0 text-text-muted opacity-0 transition-all duration-200 group-hover:translate-x-0.5 group-hover:text-accent group-hover:opacity-100" />
</div>
{stats && (stats.files || stats.nodes) && (
<div className="mt-3 flex flex-wrap gap-2 pl-6">
{stats.files != null && (
<span className="inline-flex items-center gap-1 rounded-md bg-void px-2 py-0.5 text-[11px] text-text-muted">
<FileCode className="h-3 w-3" /> {stats.files.toLocaleString()} files
</span>
)}
{stats.nodes != null && (
<span className="inline-flex items-center gap-1 rounded-md bg-void px-2 py-0.5 text-[11px] text-text-muted">
<Layers className="h-3 w-3" /> {stats.nodes.toLocaleString()} symbols
</span>
)}
{stats.processes != null && stats.processes > 0 && (
<span className="inline-flex items-center gap-1 rounded-md bg-void px-2 py-0.5 text-[11px] text-text-muted">
<Sparkles className="h-3 w-3" /> {stats.processes} flows
</span>
)}
</div>
)}
</button>
);
}
// ── RepoLanding ──────────────────────────────────────────────────────────────
interface RepoLandingProps {
repos: BackendRepo[];
onSelectRepo: (repoName: string) => void;
onAnalyzeComplete: (repoName: string) => void;
}
export const RepoLanding = ({ repos, onSelectRepo, onAnalyzeComplete }: RepoLandingProps) => {
return (
<div className="relative animate-fade-in overflow-hidden rounded-3xl border border-border-default bg-surface p-7">
{/* Ambient glows — mirrors OnboardingGuide aesthetic */}
<div className="pointer-events-none absolute -top-28 -right-28 h-72 w-72 rounded-full bg-accent/6 blur-3xl" />
<div className="pointer-events-none absolute -bottom-24 -left-24 h-56 w-56 rounded-full bg-node-function/6 blur-3xl" />
{/* Header */}
<div className="relative mb-6">
<div className="text-center">
<div className="mb-2 inline-flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-accent/70" />
<span className="text-[11px] font-medium tracking-widest text-accent/80 uppercase">
GitNexus
</span>
</div>
<h2 className="text-lg leading-snug font-semibold text-text-primary">
Choose a repository
</h2>
<p className="mx-auto mt-1.5 max-w-xs text-sm leading-relaxed text-text-secondary">
Select an indexed repository to explore, or analyze a new one.
</p>
</div>
</div>
{/* Repo list */}
<div className="relative mb-5 space-y-2">
{repos.map((repo) => (
<RepoCard key={repo.name} repo={repo} onClick={() => onSelectRepo(repo.name)} />
))}
</div>
{/* Divider */}
<div className="mb-5 flex items-center gap-3">
<div className="h-px flex-1 bg-border-subtle" />
<span className="text-[11px] tracking-widest text-text-muted uppercase">
or analyze new
</span>
<div className="h-px flex-1 bg-border-subtle" />
</div>
{/* Analyzer form */}
<div className="relative">
<RepoAnalyzer variant="onboarding" onComplete={onAnalyzeComplete} />
</div>
{/* Footer hint */}
<p className="mt-5 text-center text-[11px] leading-relaxed text-text-muted">
Public &amp; private repos &middot; Cloned locally by the server &middot; No data leaves
your machine
</p>
</div>
);
};

View file

@ -2,6 +2,45 @@
All notable changes to GitNexus will be documented in this file.
## [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
### 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

View file

@ -1,12 +1,12 @@
{
"name": "gitnexus",
"version": "1.4.10",
"version": "1.5.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitnexus",
"version": "1.4.10",
"version": "1.5.3",
"license": "PolyForm-Noncommercial-1.0.0",
"dependencies": {
"@huggingface/transformers": "^3.0.0",
@ -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"
}

View file

@ -1,6 +1,6 @@
{
"name": "gitnexus",
"version": "1.4.10",
"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",
@ -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",
"prepack": "npm run build && chmod +x dist/cli/index.js"
"prepare": "node scripts/build.js",
"prepack": "node scripts/build.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",

73
gitnexus/scripts/build.js Normal file
View file

@ -0,0 +1,73 @@
#!/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);
// ── 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.`);

View file

@ -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<typeof saveCLIConfig>[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)

View file

@ -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<string>).has(text)) return text;
}
// Modifier wrapper

View file

@ -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<string> = 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,
});

View file

@ -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<string> = 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,
});

View file

@ -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;
},
};

View file

@ -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<MethodVisibility>(['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<MethodExtractionConfig, 'language'> = {
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,
};

View file

@ -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);

View file

@ -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

View file

@ -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).

View file

@ -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,

View file

@ -152,11 +152,11 @@ export async function callLLM(
messages,
};
if (reasoning) {
body.max_completion_tokens = config.maxTokens;
// Do NOT include temperature, top_p, presence_penalty, frequency_penalty
} else {
body.max_tokens = config.maxTokens;
// max_tokens is deprecated; use max_completion_tokens for all models
body.max_completion_tokens = config.maxTokens;
// Only send temperature for non-Azure providers — some Azure models reject non-default values
if (!reasoning && !azure && config.temperature !== undefined) {
body.temperature = config.temperature;
}

View file

@ -535,6 +535,153 @@ 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');
});
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);

View file

@ -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',
]);

View file

@ -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');
});
});
// ---------------------------------------------------------------------------

File diff suppressed because it is too large Load diff

View file

@ -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', () => {

View file

@ -207,7 +207,7 @@ describe('callLLM — reasoning model params', () => {
expect(body.temperature).toBeUndefined();
});
it('uses max_tokens and temperature for non-reasoning models', async () => {
it('uses max_completion_tokens and temperature for non-reasoning models', async () => {
const fetchSpy = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ choices: [{ message: { content: 'answer' } }], usage: {} }), {
status: 200,
@ -230,8 +230,8 @@ describe('callLLM — reasoning model params', () => {
RequestInit & { headers: Record<string, string> },
];
const body = JSON.parse(init.body as string);
expect(body.max_tokens).toBe(500);
expect(body.max_completion_tokens).toBeUndefined();
expect(body.max_completion_tokens).toBe(500);
expect(body.max_tokens).toBeUndefined();
expect(body.temperature).toBe(0.5);
});
});