mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-11 22:53:04 +00:00
Merge branch 'main' into fix/wiki-script-escape-and-llm-token-param
This commit is contained in:
commit
8355ceb8e5
27 changed files with 2460 additions and 136 deletions
21
.github/workflows/publish.yml
vendored
21
.github/workflows/publish.yml
vendored
|
|
@ -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' }}
|
||||
|
|
|
|||
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
148
gitnexus-web/src/components/RepoLanding.tsx
Normal file
148
gitnexus-web/src/components/RepoLanding.tsx
Normal 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 & private repos · Cloned locally by the server · No data leaves
|
||||
your machine
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
7
gitnexus/package-lock.json
generated
7
gitnexus/package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
73
gitnexus/scripts/build.js
Normal 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.`);
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
372
gitnexus/src/core/ingestion/method-extractors/configs/c-cpp.ts
Normal file
372
gitnexus/src/core/ingestion/method-extractors/configs/c-cpp.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue