diff --git a/README.md b/README.md
index f0f1d7571..65ac7d332 100644
--- a/README.md
+++ b/README.md
@@ -52,7 +52,7 @@ https://github.com/user-attachments/assets/172685ba-8e54-4ea7-9ad1-e31a3398da72
| **What** | Index repos locally, connect AI agents via MCP | Visual graph explorer + AI chat in browser |
| **For** | Daily development with Cursor, Claude Code, Codex, Windsurf, OpenCode | Quick exploration, demos, one-off analysis |
| **Scale** | Full repos, any size | Limited by browser memory (~5k files), or unlimited via backend mode |
-| **Install** | `npm install -g gitnexus` | No install —[gitnexus.vercel.app](https://gitnexus.vercel.app) |
+| **Install** | `npm install -g gitnexus` | No install — [gitnexus.vercel.app](https://gitnexus.vercel.app) |
| **Storage** | LadybugDB native (fast, persistent) | LadybugDB WASM (in-memory, per session) |
| **Parsing** | Tree-sitter native bindings | Tree-sitter WASM |
| **Privacy** | Everything local, no network | Everything in-browser, no server |
diff --git a/gitnexus-web/e2e/server-connect.spec.ts b/gitnexus-web/e2e/server-connect.spec.ts
index eb241da10..0705b3a27 100644
--- a/gitnexus-web/e2e/server-connect.spec.ts
+++ b/gitnexus-web/e2e/server-connect.spec.ts
@@ -1,4 +1,4 @@
-import { test, expect, type TestInfo } from '@playwright/test';
+import { test, expect } from '@playwright/test';
/**
* E2E tests for the GitNexus web UI — exploring view features.
@@ -58,36 +58,41 @@ test.beforeAll(async () => {
* 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) {
+async function waitForGraphLoaded(page: import('@playwright/test').Page) {
await page.goto('/');
- const landingCard = page.locator('[data-testid="landing-repo-card"]').first();
+ const landingCards = page.locator('[data-testid="landing-repo-card"]');
+ const preferredLandingCard = landingCards
+ .filter({ hasText: /GitNexus|local-integration/ })
+ .first();
try {
- await landingCard.waitFor({ state: 'visible', timeout: 15_000 });
+ await landingCards.first().waitFor({ state: 'visible', timeout: 15_000 });
+ const landingCard =
+ (await preferredLandingCard.count()) > 0 ? preferredLandingCard : landingCards.first();
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') });
+ const statusBar = page.getByRole('contentinfo');
+ await expect(statusBar.getByText('Ready', { exact: true })).toBeVisible({ timeout: 45_000 });
+ await expect(statusBar).toContainText(/nodes/, {
+ timeout: 20_000,
+ });
}
test.describe('Server Connection & Graph Loading', () => {
- 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 });
+ test('selects a repo from landing and loads graph', async ({ page }) => {
+ await waitForGraphLoaded(page);
});
});
test.describe('Nexus AI', () => {
- test('panel opens and agent initializes without error', async ({ page }, testInfo) => {
- await waitForGraphLoaded(page, testInfo);
+ test('panel opens and agent initializes without error', async ({ page }) => {
+ await waitForGraphLoaded(page);
await page.getByRole('button', { name: 'Nexus AI' }).click();
await expect(page.getByText('Ask me anything')).toBeVisible({ timeout: 15_000 });
- await page.screenshot({ path: testInfo.outputPath('nexus-ai-panel.png'), fullPage: true });
const errorBanner = page.getByText('Database not ready');
expect(await errorBanner.isVisible().catch(() => false)).toBe(false);
@@ -95,8 +100,8 @@ test.describe('Nexus AI', () => {
});
test.describe('Processes Panel', () => {
- test('shows process list and View button works', async ({ page }, testInfo) => {
- await waitForGraphLoaded(page, testInfo);
+ test('shows process list and View button works', async ({ page }) => {
+ await waitForGraphLoaded(page);
await page.getByRole('button', { name: 'Nexus AI' }).click();
await page.getByText('Processes').click();
@@ -104,7 +109,6 @@ test.describe('Processes Panel', () => {
await expect(page.locator('[data-testid="process-list-loaded"]')).toBeVisible({
timeout: 15_000,
});
- await page.screenshot({ path: testInfo.outputPath('processes-panel.png'), fullPage: true });
const processRow = page.locator('[data-testid="process-row"]').first();
await expect(processRow).toBeVisible({ timeout: 10_000 });
@@ -114,14 +118,10 @@ test.describe('Processes Panel', () => {
await viewBtn.waitFor({ state: 'visible', timeout: 5_000 });
await viewBtn.click();
await expect(page.locator('[data-testid="process-modal"]')).toBeVisible({ timeout: 5_000 });
- await page.screenshot({
- path: testInfo.outputPath('process-view-clicked.png'),
- fullPage: true,
- });
});
- test('lightbulb highlights nodes in graph', async ({ page }, testInfo) => {
- await waitForGraphLoaded(page, testInfo);
+ test('lightbulb highlights nodes in graph', async ({ page }) => {
+ await waitForGraphLoaded(page);
await page.getByRole('button', { name: 'Nexus AI' }).click();
await page.getByText('Processes').click();
@@ -137,13 +137,12 @@ test.describe('Processes Panel', () => {
await lightbulb.waitFor({ state: 'visible', timeout: 5_000 });
await lightbulb.click();
await expect(processRow).toHaveClass(/bg-amber-950/, { timeout: 5_000 });
- await page.screenshot({ path: testInfo.outputPath('after-highlight.png'), fullPage: true });
});
});
test.describe('Turn Off All Highlights', () => {
- test('selecting a node dims others, button clears it', async ({ page }, testInfo) => {
- await waitForGraphLoaded(page, testInfo);
+ test('selecting a node dims others, button clears it', async ({ page }) => {
+ await waitForGraphLoaded(page);
await expect(page.locator('canvas').first()).toBeVisible({ timeout: 10_000 });
@@ -160,6 +159,5 @@ test.describe('Turn Off All Highlights', () => {
await expect(highlightToggle).toHaveAttribute('title', 'Turn on AI highlights', {
timeout: 5_000,
});
- await page.screenshot({ path: testInfo.outputPath('highlights-cleared.png'), fullPage: true });
});
});
diff --git a/gitnexus-web/src/components/OnboardingGuide.tsx b/gitnexus-web/src/components/OnboardingGuide.tsx
index cbfd16236..796e0f148 100644
--- a/gitnexus-web/src/components/OnboardingGuide.tsx
+++ b/gitnexus-web/src/components/OnboardingGuide.tsx
@@ -201,7 +201,7 @@ interface OnboardingGuideProps {
}
export const OnboardingGuide = ({ isPolling }: OnboardingGuideProps) => {
- const primary = isDev ? 'cd gitnexus && npm run serve' : 'npx gitnexus@latest serve';
+ const primary = isDev ? 'npm run --prefix gitnexus serve' : 'npx gitnexus@latest serve';
const termLabel = isDev ? 'Start backend' : 'Terminal';
// Step states: step 1 = copy command, step 2 = run/wait, step 3 = auto-connect
@@ -277,7 +277,9 @@ export const OnboardingGuide = ({ isPolling }: OnboardingGuideProps) => {
state={step2State}
number={2}
title={isPolling ? 'Waiting for server to start' : 'Paste and run in your terminal'}
- description={isPolling ? undefined : 'Open a new terminal window, paste, and hit Enter.'}
+ description={
+ isPolling ? undefined : 'Open a terminal at the project root, paste, and hit Enter.'
+ }
>
{isPolling && }
diff --git a/gitnexus-web/src/components/StatusBar.tsx b/gitnexus-web/src/components/StatusBar.tsx
index a618c3c1c..7468072fa 100644
--- a/gitnexus-web/src/components/StatusBar.tsx
+++ b/gitnexus-web/src/components/StatusBar.tsx
@@ -64,7 +64,7 @@ export const StatusBar = () => {
{/* Right - Stats */}
-
+
{graph && (
<>
{nodeCount} nodes
diff --git a/gitnexus-web/src/services/backend-client.ts b/gitnexus-web/src/services/backend-client.ts
index 256219eb6..2c04b66dd 100644
--- a/gitnexus-web/src/services/backend-client.ts
+++ b/gitnexus-web/src/services/backend-client.ts
@@ -404,13 +404,18 @@ export const fetchGraph = async (
onProgress?: (downloaded: number, total: number | null) => void;
},
): Promise<{ nodes: GraphNode[]; relationships: GraphRelationship[] }> => {
- const params = [repoParam(repo), opts?.includeContent ? 'includeContent=true' : '']
+ const params = [repoParam(repo), opts?.includeContent ? 'includeContent=true' : '', 'stream=true']
.filter(Boolean)
.join('&');
const url = `${_backendUrl}/api/graph${params ? `?${params}` : ''}`;
const response = await fetchWithTimeout(url, { signal: opts?.signal }, 60_000);
await assertOk(response);
+ const contentType = response.headers.get('Content-Type') || '';
+ if (contentType.includes('application/x-ndjson')) {
+ return parseNdjsonGraphResponse(response, opts?.onProgress);
+ }
+
if (!opts?.onProgress || !response.body) {
return response.json() as Promise<{ nodes: GraphNode[]; relationships: GraphRelationship[] }>;
}
@@ -439,6 +444,66 @@ export const fetchGraph = async (
return JSON.parse(new TextDecoder().decode(combined));
};
+const parseNdjsonGraphResponse = async (
+ response: Response,
+ onProgress?: (downloaded: number, total: number | null) => void,
+): Promise<{ nodes: GraphNode[]; relationships: GraphRelationship[] }> => {
+ if (!response.body) {
+ throw new BackendError('No response body', response.status, 'server');
+ }
+
+ const contentLength = response.headers.get('Content-Length');
+ const total = contentLength ? parseInt(contentLength, 10) : null;
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ const nodes: GraphNode[] = [];
+ const relationships: GraphRelationship[] = [];
+ let buffer = '';
+ let downloaded = 0;
+
+ const parseLine = (line: string) => {
+ const trimmed = line.trim();
+ if (!trimmed) return;
+
+ const record = JSON.parse(trimmed) as
+ | { type: 'node'; data: GraphNode }
+ | { type: 'relationship'; data: GraphRelationship }
+ | { type: 'error'; error: string };
+
+ if (record.type === 'node') {
+ nodes.push(record.data);
+ return;
+ }
+ if (record.type === 'relationship') {
+ relationships.push(record.data);
+ return;
+ }
+ if (record.type === 'error') {
+ throw new BackendError(record.error, response.status || 500, 'server');
+ }
+ };
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ downloaded += value.length;
+ onProgress?.(downloaded, total);
+ buffer += decoder.decode(value, { stream: true });
+
+ const lines = buffer.split('\n');
+ buffer = lines.pop() || '';
+ for (const line of lines) {
+ parseLine(line);
+ }
+ }
+
+ buffer += decoder.decode();
+ parseLine(buffer);
+
+ return { nodes, relationships };
+};
+
/** Execute a Cypher query. Returns rows. */
export const runQuery = async (
cypher: string,
diff --git a/gitnexus-web/test/unit/server-connection.test.ts b/gitnexus-web/test/unit/server-connection.test.ts
index a818adb13..f5ee43c53 100644
--- a/gitnexus-web/test/unit/server-connection.test.ts
+++ b/gitnexus-web/test/unit/server-connection.test.ts
@@ -1,5 +1,5 @@
-import { describe, expect, it } from 'vitest';
-import { normalizeServerUrl } from '../../src/services/backend-client';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { fetchGraph, normalizeServerUrl, setBackendUrl } from '../../src/services/backend-client';
describe('normalizeServerUrl', () => {
it('adds http:// to localhost', () => {
@@ -31,3 +31,137 @@ describe('normalizeServerUrl', () => {
expect(normalizeServerUrl('https://gitnexus.example.com')).toBe('https://gitnexus.example.com');
});
});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe('fetchGraph', () => {
+ it('requests streamed graph responses from the backend', async () => {
+ setBackendUrl('http://localhost:4747');
+
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response('{"nodes":[],"relationships":[]}', {
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ }),
+ );
+ vi.stubGlobal('fetch', fetchMock);
+
+ await fetchGraph('big-repo');
+
+ expect(fetchMock).toHaveBeenCalledWith(
+ expect.stringContaining('/api/graph?repo=big-repo&stream=true'),
+ expect.any(Object),
+ );
+ });
+
+ it('parses NDJSON graph streams incrementally', async () => {
+ setBackendUrl('http://localhost:4747');
+
+ const encoder = new TextEncoder();
+ const stream = new ReadableStream
({
+ start(controller) {
+ controller.enqueue(
+ encoder.encode(
+ [
+ '{"type":"node","data":{"id":"File:src/app.ts","label":"File","properties":{"name":"app.ts","filePath":"src/app.ts"}}}\n',
+ '{"type":"relationship","data":{"id":"File:src/app.ts_CONTAINS_Function:src/app.ts:main","type":"CONTAINS","sourceId":"File:src/app.ts","targetId":"Function:src/app.ts:main"}}\n',
+ ].join(''),
+ ),
+ );
+ controller.close();
+ },
+ });
+
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue(
+ new Response(stream, {
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/x-ndjson',
+ },
+ }),
+ ),
+ );
+
+ const progress = vi.fn();
+ const result = await fetchGraph('big-repo', { onProgress: progress });
+
+ expect(result.nodes).toHaveLength(1);
+ expect(result.relationships).toHaveLength(1);
+ expect(result.nodes[0].id).toBe('File:src/app.ts');
+ expect(result.relationships[0].type).toBe('CONTAINS');
+ expect(progress).toHaveBeenCalled();
+ });
+
+ it('parses NDJSON graph lines split across chunks', async () => {
+ setBackendUrl('http://localhost:4747');
+
+ const encoder = new TextEncoder();
+ const stream = new ReadableStream({
+ start(controller) {
+ controller.enqueue(
+ encoder.encode(
+ '{"type":"node","data":{"id":"File:src/app.ts","label":"File","properties":{"name":"app.ts"',
+ ),
+ );
+ controller.enqueue(
+ encoder.encode(
+ ',"filePath":"src/app.ts"}}}\n{"type":"relationship","data":{"id":"File:src/app.ts_CONTAINS_Function:src/app.ts:main","type":"CONTAINS","sourceId":"File:src/app.ts","targetId":"Function:src/app.ts:main"}}\n',
+ ),
+ );
+ controller.close();
+ },
+ });
+
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue(
+ new Response(stream, {
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/x-ndjson',
+ },
+ }),
+ ),
+ );
+
+ const result = await fetchGraph('big-repo');
+
+ expect(result.nodes).toHaveLength(1);
+ expect(result.relationships).toHaveLength(1);
+ expect(result.nodes[0].properties.filePath).toBe('src/app.ts');
+ });
+
+ it('throws backend errors emitted in the NDJSON stream', async () => {
+ setBackendUrl('http://localhost:4747');
+
+ const encoder = new TextEncoder();
+ const stream = new ReadableStream({
+ start(controller) {
+ controller.enqueue(encoder.encode('{"type":"error","error":"stream failed"}\n'));
+ controller.close();
+ },
+ });
+
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue(
+ new Response(stream, {
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/x-ndjson',
+ },
+ }),
+ ),
+ );
+
+ await expect(fetchGraph('big-repo')).rejects.toMatchObject({
+ message: 'stream failed',
+ });
+ });
+});
diff --git a/gitnexus/package.json b/gitnexus/package.json
index 7699208e2..07723f3e3 100644
--- a/gitnexus/package.json
+++ b/gitnexus/package.json
@@ -104,7 +104,8 @@
"overrides": {
"@huggingface/transformers": {
"onnxruntime-node": "$onnxruntime-node"
- }
+ },
+ "tree-sitter-c": "0.23.2"
},
"engines": {
"node": ">=20.0.0"
diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts
index b01426e55..115664bb1 100644
--- a/gitnexus/src/core/ingestion/call-processor.ts
+++ b/gitnexus/src/core/ingestion/call-processor.ts
@@ -1,9 +1,11 @@
import { KnowledgeGraph } from '../graph/types.js';
import { ASTCache } from './ast-cache.js';
import type { SymbolDefinition, SymbolTable } from './symbol-table.js';
+import { CLASS_TYPES } from './symbol-table.js';
import Parser from 'tree-sitter';
import type { ResolutionContext } from './resolution-context.js';
import { TIER_CONFIDENCE, type ResolutionTier } from './resolution-context.js';
+import type { TieredCandidates } from './resolution-context.js';
import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/parser-loader.js';
import { getProvider } from './languages/index.js';
import { generateId } from '../../lib/utils.js';
@@ -53,8 +55,51 @@ import { extractParsedCallSite } from './call-sites/extract-language-call-site.j
* Populated during call processing, consumed by Phase 14 re-resolution pass. */
export type ExportedTypeMap = Map>;
-/** Types that represent class-like declarations (used for receiver/owner resolution). */
-const CLASS_LIKE_TYPES = new Set(['Class', 'Struct', 'Interface', 'Enum', 'Record', 'Impl']);
+/**
+ * Type labels treated as class-like **method-dispatch receivers** by the call
+ * resolver — the set walked by the MRO / heritage path for member and static
+ * method calls.
+ *
+ * Derived from `CLASS_TYPES` (the heritage-index set in symbol-table) plus
+ * `Impl` — Rust `impl` blocks are the definition site of methods for a struct
+ * and must be walkable as receiver-type candidates even though they are not
+ * indexed by `lookupClassByName` (which keys off struct/trait names). Keeping
+ * this set a strict superset of `CLASS_TYPES` guarantees that anything
+ * reachable via `lookupClassByName` also passes this filter, so the two call
+ * paths cannot diverge silently.
+ *
+ * `Interface` is included even though interfaces cannot be directly
+ * instantiated in Java/C#/TypeScript: the resolver still needs to reach
+ * interface nodes for static-method dispatch (`Interface.staticMethod()`) and
+ * default-method resolution via the MRO walker.
+ *
+ * **Do not reuse this set for constructor-fallback filtering.** Constructors
+ * can only instantiate a narrower subset — see `INSTANTIABLE_CLASS_TYPES`
+ * below. `resolveStaticCall`'s step-5 class-node fallback uses the narrower
+ * set to prevent false `CALLS` edges from constructor-shaped calls to
+ * `Interface`, `Trait`, or `Impl` nodes.
+ */
+const CLASS_LIKE_TYPES = new Set([...CLASS_TYPES, 'Impl']);
+
+/**
+ * Type labels that can be the target of a constructor-shaped call when no
+ * explicit `Constructor` symbol is indexed — the "return the type itself as
+ * the call target" fallback set.
+ *
+ * Strict subset of both `CLASS_LIKE_TYPES` and `CONSTRUCTOR_TARGET_TYPES`.
+ * Excludes:
+ * - `Interface` / `Trait` — not instantiable by definition in any
+ * supported language.
+ * - `Impl` — Rust `impl` blocks are method-definition containers, not
+ * the type itself; the owning `Struct` is the correct target.
+ * - `Enum` — excluded pending language-specific support with motivating
+ * test fixtures (matches `CONSTRUCTOR_TARGET_TYPES`).
+ *
+ * Used exclusively by `resolveStaticCall`'s step-5 class-node fallback.
+ * Keep in sync with `CONSTRUCTOR_TARGET_TYPES` (which additionally contains
+ * `'Constructor'` for explicit-constructor-node filtering) when extending.
+ */
+const INSTANTIABLE_CLASS_TYPES = new Set(['Class', 'Struct', 'Record']);
const MAX_EXPORTS_PER_FILE = 500;
const MAX_TYPE_NAME_LENGTH = 256;
@@ -793,6 +838,8 @@ export const processCalls = async (
ctx,
undefined,
widenCache,
+ undefined,
+ heritageMap,
);
if (!resolved) return;
@@ -1039,6 +1086,8 @@ export const processCalls = async (
ctx,
hints,
widenCache,
+ undefined,
+ heritageMap,
);
if (!resolved) return;
@@ -1193,9 +1242,15 @@ const toResolveResult = (definition: SymbolDefinition, tier: ResolutionTier): Re
returnType: definition.returnType,
});
-/** Optional hints for overload disambiguation via argument literal types.
- * Only available on the sequential path (has AST); worker path passes undefined. */
-interface OverloadHints {
+/**
+ * Optional hints for overload disambiguation via argument literal types.
+ * Only available on the sequential path (has AST); worker path passes undefined.
+ *
+ * @internal Exported so tests can exercise the D0 skip-condition path without
+ * constructing a real SyntaxNode. Do not use outside `call-processor.ts`
+ * and its unit tests.
+ */
+export interface OverloadHints {
callNode: SyntaxNode;
inferLiteralType: LiteralTypeInferrer;
typeEnv?: TypeEnvironment;
@@ -1268,6 +1323,44 @@ const tryOverloadDisambiguation = (
return matchCandidatesByArgTypes(candidates, argTypes);
};
+/**
+ * Collapse Swift-extension duplicate Class/Struct candidates to the primary
+ * definition, preferring the shortest file path.
+ *
+ * Swift extensions (`extension User { ... }` in a separate file) create
+ * multiple `Class` nodes sharing the same symbol name — one for the primary
+ * declaration and one per extension file. When overload disambiguation and
+ * receiver narrowing both fail to converge on a single candidate, this
+ * heuristic picks the primary definition based on the assumption that it
+ * lives at the shortest file path (e.g. `User.swift` over `UserExtensions.swift`).
+ *
+ * Intentionally narrower than {@link INSTANTIABLE_CLASS_TYPES}: only `Class`
+ * and `Struct` are considered, not `Record`. Swift extensions only produce
+ * `Class` duplicates in practice, and C#/Kotlin records do not exhibit the
+ * same multi-file-definition pattern, so widening this set risks accidental
+ * dedup of legitimately distinct record types.
+ *
+ * Returns a `ResolveResult` when the heuristic fires, `null` when the
+ * candidate pool does not match the shape (mixed types, non-Class/Struct
+ * kinds, or `length <= 1`). Callers should fall through to their own null
+ * return when this helper returns `null`.
+ *
+ * Shared between `resolveCallTarget` and `resolveFreeCall` — SM-13 originally
+ * duplicated this block into both functions. Having a single source of truth
+ * prevents the two copies from drifting if the heuristic is ever tuned.
+ */
+const dedupSwiftExtensionCandidates = (
+ candidates: readonly SymbolDefinition[],
+ tier: ResolutionTier,
+): ResolveResult | null => {
+ if (candidates.length <= 1) return null;
+ const allSameType = candidates.every((c) => c.type === candidates[0].type);
+ if (!allSameType) return null;
+ if (candidates[0].type !== 'Class' && candidates[0].type !== 'Struct') return null;
+ const sorted = [...candidates].sort((a, b) => a.filePath.length - b.filePath.length);
+ return toResolveResult(sorted[0], tier);
+};
+
/**
* Resolve a function call to its target node ID using priority strategy:
* A. Narrow candidates by scope tier via ctx.resolve()
@@ -1281,6 +1374,31 @@ const tryOverloadDisambiguation = (
/** Per-file cache for the widen path's lookupFuzzy calls. Cleared between files. */
type WidenCache = Map;
+/** @internal Exported for unit tests of D0 skip conditions (SM-11). Do not use outside tests. */
+export const _resolveCallTargetForTesting = (
+ call: Pick<
+ ExtractedCall,
+ 'calledName' | 'argCount' | 'callForm' | 'receiverTypeName' | 'receiverName'
+ >,
+ currentFile: string,
+ ctx: ResolutionContext,
+ opts?: {
+ overloadHints?: OverloadHints;
+ widenCache?: WidenCache;
+ preComputedArgTypes?: (string | undefined)[];
+ heritageMap?: HeritageMap;
+ },
+): ResolveResult | null =>
+ resolveCallTarget(
+ call,
+ currentFile,
+ ctx,
+ opts?.overloadHints,
+ opts?.widenCache,
+ opts?.preComputedArgTypes,
+ opts?.heritageMap,
+ );
+
const resolveCallTarget = (
call: Pick<
ExtractedCall,
@@ -1291,30 +1409,53 @@ const resolveCallTarget = (
overloadHints?: OverloadHints,
widenCache?: WidenCache,
preComputedArgTypes?: (string | undefined)[],
+ heritageMap?: HeritageMap,
): ResolveResult | null => {
const tiered = ctx.resolve(call.calledName, currentFile);
if (!tiered) return null;
+ // SM-13: Free function calls route through resolveFreeCall.
+ // Handles pure free calls (foo()) and Swift/Kotlin implicit constructors (User()).
+ if (call.callForm === 'free') {
+ return resolveFreeCall(
+ call.calledName,
+ currentFile,
+ ctx,
+ call.argCount,
+ tiered,
+ overloadHints,
+ preComputedArgTypes,
+ );
+ }
+
let filteredCandidates = filterCallableCandidates(
tiered.candidates,
call.argCount,
call.callForm,
);
- // Swift/Kotlin: constructor calls look like free function calls (no `new` keyword).
- // If free-form filtering found no callable candidates but the symbol resolves to a
- // Class/Struct, retry with constructor form so CONSTRUCTOR_TARGET_TYPES applies.
- if (filteredCandidates.length === 0 && call.callForm === 'free') {
- const hasTypeTarget = tiered.candidates.some(
- (c) => c.type === 'Class' || c.type === 'Struct' || c.type === 'Enum',
+ // S0. Constructor/static fast path (SM-12): O(1) class + constructor lookup
+ // via lookupClassByName + lookupMethodByOwner.
+ // Handles callForm === 'constructor' — explicit `new User()` in Java/TS/C#/etc.
+ // Free-form class targets (Swift/Kotlin `User()`) are handled by
+ // resolveFreeCall above (SM-13).
+ //
+ // Known gaps (handled by the existing tail fallback at the bottom of
+ // this function, not S0):
+ // - `callForm === 'member'` constructor patterns (e.g. Python
+ // `models.User()` after `import models`, Ruby `User.new`). Extending
+ // S0 to cover them would require threading receiver-type resolution
+ // through the module-alias logic; revisit if it shows up as a hot
+ // spot.
+ if (call.callForm === 'constructor') {
+ const staticResult = resolveStaticCall(
+ call.calledName,
+ currentFile,
+ ctx,
+ call.argCount,
+ tiered,
);
- if (hasTypeTarget) {
- filteredCandidates = filterCallableCandidates(
- tiered.candidates,
- call.argCount,
- 'constructor',
- );
- }
+ if (staticResult) return staticResult;
}
// Module-qualified constructor pattern: e.g. Python `import models; models.User()`.
@@ -1329,6 +1470,10 @@ const resolveCallTarget = (
// selects auth.py via moduleAliasMap. Runs for ALL member calls with a known module alias,
// not just ambiguous ones — same-file tier may shadow the correct cross-module target when
// the caller defines a function with the same name as the callee (Issue #417).
+ //
+ // Tracks `aliasNarrowed` so the D2 widening step below does NOT undo the alias filtering
+ // by calling lookupFuzzy again (which would re-introduce homonym candidates from other files).
+ let aliasNarrowed = false;
if (call.callForm === 'member' && call.receiverName) {
const aliasMap = ctx.moduleAliasMap?.get(currentFile);
if (aliasMap) {
@@ -1337,6 +1482,7 @@ const resolveCallTarget = (
const aliasFiltered = filteredCandidates.filter((c) => c.filePath === moduleFile);
if (aliasFiltered.length > 0) {
filteredCandidates = aliasFiltered;
+ aliasNarrowed = true;
} else {
// Same-file tier returned a local match, but the alias points elsewhere.
// Widen to global candidates and filter to the aliased module's file.
@@ -1351,7 +1497,10 @@ const resolveCallTarget = (
const widened = filterCallableCandidates(fuzzyDefs, call.argCount, call.callForm).filter(
(c) => c.filePath === moduleFile,
);
- if (widened.length > 0) filteredCandidates = widened;
+ if (widened.length > 0) {
+ filteredCandidates = widened;
+ aliasNarrowed = true;
+ }
}
}
}
@@ -1366,6 +1515,47 @@ const resolveCallTarget = (
// belong to the wrong class (e.g. super.save() should hit the parent's save,
// not the child's own save method in the same file).
if (call.callForm === 'member' && call.receiverTypeName) {
+ // D0. Delegate to resolveMemberCall (SM-11): owner-scoped + MRO lookup
+ // before falling back to the expensive D1-D4 fuzzy widening.
+ // Skip conditions:
+ // (a) overloadHints or preComputedArgTypes present — the MRO lookup may
+ // pick the wrong overload for same-return-type overloads since it
+ // does not consider argument types. D1-D4+E handles those correctly.
+ // (b) A module alias on call.receiverName is active for this file — the
+ // alias block above already narrowed `filteredCandidates` to a
+ // specific file. resolveMemberCall re-resolves `receiverTypeName`
+ // from scratch via `ctx.resolve`, which ignores that narrowing and
+ // could pick a homonymous class from the wrong file. Fall through to
+ // D1-D4 which respects the alias-filtered candidate pool.
+ // D0 skip for overload disambiguation: only fires when the name actually
+ // has multiple candidates in the tiered pool. The sequential path sets
+ // `overloadHints` for every call regardless of whether the method is
+ // overloaded — skipping D0 unconditionally would make this fast path
+ // dead code for the sequential pipeline. By gating on
+ // `filteredCandidates.length > 1`, we preserve the original intent
+ // (let D1-D4+E pick the right overload when there are multiple) while
+ // allowing D0 to fire for the common single-candidate case.
+ const hasOverloadConcern =
+ (!!overloadHints || !!preComputedArgTypes) && filteredCandidates.length > 1;
+ // D0 skip for active module alias: only fires when the alias block above
+ // actually narrowed filteredCandidates. In Python, a local variable can
+ // shadow an imported module name (e.g. `from models.c import C; c = C()`
+ // creates both a module alias `c → models/c.py` AND a typed local `c`).
+ // Checking `aliasNarrowed` rather than `ctx.moduleAliasMap.has(receiverName)`
+ // ensures D0 still runs when the method isn't in the aliased module —
+ // which means the receiver is a typed local variable, not a module reference.
+ if (!hasOverloadConcern && !aliasNarrowed) {
+ const memberResult = resolveMemberCall(
+ call.receiverTypeName,
+ call.calledName,
+ currentFile,
+ ctx,
+ heritageMap,
+ call.argCount,
+ );
+ if (memberResult) return memberResult;
+ }
+
// D1. Resolve the receiver type
const typeResolved = ctx.resolve(call.receiverTypeName, currentFile);
if (typeResolved && typeResolved.candidates.length > 0) {
@@ -1375,8 +1565,13 @@ const resolveCallTarget = (
// D2. Widen candidates: same-file tier may miss the parent's method when
// it lives in another file. Query the symbol table directly for all
// global methods with this name, then apply arity/kind filtering.
+ //
+ // When the candidate set was already narrowed by module-alias
+ // disambiguation, do NOT widen back to the full fuzzy pool — that
+ // would undo the alias narrowing and reintroduce homonym candidates
+ // from other files.
const methodPool =
- filteredCandidates.length <= 1
+ filteredCandidates.length <= 1 && !aliasNarrowed
? filterCallableCandidates(
ctx.symbols.lookupFuzzy(call.calledName),
call.argCount,
@@ -1407,6 +1602,23 @@ const resolveCallTarget = (
if (disambiguated) return toResolveResult(disambiguated, tiered.tier);
return null;
}
+
+ // Zero-match null-route: we committed to receiver narrowing (D1 succeeded)
+ // but both file-based (D3) and owner-based (D4) filters produced zero
+ // matches. The lone candidate in `filteredCandidates` does not belong to
+ // this receiver type — refuse to emit a CALLS edge rather than fall
+ // through to the permissive single-candidate tail return.
+ //
+ // Addresses Codex review finding R3 (PR #744): member calls where
+ // fuzzy fallback picked a globally-matching symbol that has no
+ // relationship to the receiver's class hierarchy were silently
+ // producing false-positive edges. Example: Rust `c.trait_only()` where
+ // `trait_only` is captured as a Function node with no ownerId — it
+ // matches the name but fails both file and owner narrowing, so the
+ // old tail return would pick it incorrectly.
+ if (fileFiltered.length === 0 && ownerFiltered.length === 0) {
+ return null;
+ }
}
}
@@ -1423,22 +1635,11 @@ const resolveCallTarget = (
}
if (filteredCandidates.length !== 1) {
- // Deduplicate: Swift extensions create multiple Class nodes with the same name.
- // When all candidates share the same type and differ only by file (extension vs
- // primary definition), they represent the same symbol. Prefer the primary
- // definition (shortest file path: Product.swift over ProductExtension.swift).
- if (filteredCandidates.length > 1) {
- const allSameType = filteredCandidates.every((c) => c.type === filteredCandidates[0].type);
- if (
- allSameType &&
- (filteredCandidates[0].type === 'Class' || filteredCandidates[0].type === 'Struct')
- ) {
- const sorted = [...filteredCandidates].sort(
- (a, b) => a.filePath.length - b.filePath.length,
- );
- return toResolveResult(sorted[0], tiered.tier);
- }
- }
+ // See `dedupSwiftExtensionCandidates` — returns non-null only when the
+ // Swift-extension same-name collision heuristic applies. Otherwise null-
+ // route (ambiguous candidates should not produce a wrong edge).
+ const deduped = dedupSwiftExtensionCandidates(filteredCandidates, tiered.tier);
+ if (deduped) return deduped;
return null;
}
@@ -1602,9 +1803,33 @@ const resolveFieldOwnership = (
/**
* Resolve a method by owner type name using the eagerly-populated methodByOwner index.
- * Returns the SymbolDefinition if an unambiguous method is found, undefined otherwise.
- * Falls through to undefined for: unknown type, no class-like candidates, ambiguous overloads.
- * When heritageMap is provided, falls back to MRO-aware parent chain walking.
+ * Returns `{ def, tier }` when an unambiguous method is found, `undefined` otherwise.
+ *
+ * **Multi-candidate iteration (homonym disambiguation):** when `ctx.resolve(ownerType)`
+ * returns multiple class-like candidates (e.g. two classes named `User` in different
+ * files reachable from the call site), each is probed with `lookupMethodByOwnerWithMRO`.
+ * Results are deduplicated by `nodeId` so that:
+ *
+ * - homonym classes that both walk up to the SAME ancestor's method collapse to 1 hit
+ * - aliased re-exports that produce two candidates pointing at the same def collapse too
+ *
+ * After deduplication:
+ *
+ * - 0 unique matches → `undefined` (owner-scoped path has no answer; D1-D4 fuzzy
+ * fallback in `resolveCallTarget` may still find something via lookupFuzzy)
+ * - 1 unique match → return it
+ * - ≥2 unique matches → `undefined` (genuine homonym ambiguity; don't silently pick one)
+ *
+ * This absorbs what was previously D4's job inside `resolveCallTarget` — "filter fuzzy
+ * candidates to those whose ownerId is in the receiver type's nodeId set" — into the
+ * owner-scoped path, aligning with the plan's target:
+ *
+ * `resolveCallTarget` D2 widening → `model.lookupMethodWithMRO(ownerNodeId, name)`
+ *
+ * The returned `tier` reflects how the owner TYPE was resolved (not the method name).
+ * Threaded out here so callers don't need a second `ctx.resolve(ownerType, ...)` call —
+ * this decouples callers from `ctx.resolve`'s per-file caching contract, which SM-16
+ * will restructure when it replaces the `lookupFuzzy` data source.
*/
const resolveMethodByOwner = (
receiverTypeName: string,
@@ -1612,31 +1837,386 @@ const resolveMethodByOwner = (
filePath: string,
ctx: ResolutionContext,
heritageMap?: HeritageMap,
-): SymbolDefinition | undefined => {
+ argCount?: number,
+): { def: SymbolDefinition; tier: ResolutionTier } | undefined => {
const typeResolved = ctx.resolve(receiverTypeName, filePath);
if (!typeResolved) return undefined;
- const classDef = typeResolved.candidates.find((d) => CLASS_LIKE_TYPES.has(d.type));
- if (!classDef) return undefined;
- // When HeritageMap is available, delegate to MRO-aware lookup which performs
- // the direct owner lookup itself before walking ancestors — avoids a double
- // direct lookup on the hot path.
- if (heritageMap) {
- const language = getLanguageFromFilename(filePath);
- if (language) {
- return lookupMethodByOwnerWithMRO(
- classDef.nodeId,
- methodName,
- heritageMap,
- ctx.symbols,
- language,
- );
+ // MRO walking needs a language hint; compute once and reuse for every candidate.
+ // Unknown extension → fall back to plain direct lookup (D1-D4 still runs on miss).
+ const language = heritageMap ? getLanguageFromFilename(filePath) : null;
+ const canWalkMRO = heritageMap != null && language != null;
+
+ // Iterate all class-like candidates tracking the first unambiguous hit.
+ // Zero-allocation fast path: the common case is exactly one class candidate,
+ // so we avoid building a Map. A second hit with a different `nodeId` flips
+ // `ambiguous` and short-circuits the loop. Diamond MRO convergence on the
+ // same inherited method collapses to one hit because `nodeId` matches.
+ //
+ // firstDef === undefined → owner-scoped resolution found nothing
+ // firstDef && !ambiguous → unambiguous answer
+ // ambiguous → genuine homonym ambiguity — refuse to pick
+ //
+ // argCount is threaded through so arity-differing overloads
+ // (e.g. C++ `greet()` vs `greet(string)`) are disambiguated inside the
+ // owner-scoped lookup rather than collapsing to an arbitrary first pick.
+ let firstDef: SymbolDefinition | undefined;
+ let ambiguous = false;
+ for (const candidate of typeResolved.candidates) {
+ if (!CLASS_LIKE_TYPES.has(candidate.type)) continue;
+ const def = canWalkMRO
+ ? lookupMethodByOwnerWithMRO(
+ candidate.nodeId,
+ methodName,
+ heritageMap,
+ ctx.symbols,
+ language,
+ argCount,
+ )
+ : ctx.symbols.lookupMethodByOwner(candidate.nodeId, methodName, argCount);
+ if (!def) continue;
+ if (!firstDef) {
+ firstDef = def;
+ } else if (def.nodeId !== firstDef.nodeId) {
+ ambiguous = true;
+ break;
}
}
- // Fallback when no HeritageMap (or the file extension is unrecognized):
- // plain direct lookup with no ancestor walk.
- return ctx.symbols.lookupMethodByOwner(classDef.nodeId, methodName);
+ if (!firstDef || ambiguous) return undefined;
+ return { def: firstDef, tier: typeResolved.tier };
+};
+
+// ---------------------------------------------------------------------------
+// SM-11: Owner-scoped + MRO member-call resolution (no fuzzy lookup)
+// ---------------------------------------------------------------------------
+
+/**
+ * Resolve a member call using owner-scoped + MRO resolution only (no fuzzy lookup).
+ * Used for `obj.method()` calls where the receiver type is known.
+ *
+ * Delegates to {@link resolveMethodByOwner} which performs an O(1) owner-scoped
+ * method lookup and, when a {@link HeritageMap} is provided, walks the MRO chain
+ * via {@link lookupMethodByOwnerWithMRO}.
+ *
+ * {@link resolveCallTarget} delegates here for member calls before falling back
+ * to the more expensive fuzzy-widening path (D1-D4).
+ *
+ * **SEMANTIC CHANGE (2026-04-09):** The confidence tier now reflects how the
+ * owner TYPE was resolved, not how the method NAME was resolved globally. The
+ * previous D0 fast path in `resolveCallTarget` used `tiered.tier` from
+ * `ctx.resolve(calledName, ...)` — a name-based tier that matched what D1-D4
+ * fuzzy widening would produce. The new tier is owner-type-based, which is
+ * more accurate for owner-scoped resolution (the discriminant IS the class,
+ * not the method name). Downstream consumers that filter CALLS edges by
+ * confidence threshold may see shifted values on otherwise-unchanged code.
+ * See the "returns result with correct confidence tier" tests below for the
+ * locked-in behavior.
+ *
+ * **Performance:** Callers that only need the return type (e.g. `walkMixedChain`)
+ * should call {@link resolveMethodByOwner} directly and use the `.def.returnType`
+ * field instead, to avoid building a throwaway `ResolveResult`.
+ *
+ * @param ownerType - The receiver's type name (e.g. 'User')
+ * @param methodName - The method being called (e.g. 'save')
+ * @param currentFile - File path of the call site
+ * @param ctx - Resolution context
+ * @param heritageMap - Optional heritage map for MRO-aware ancestor walking
+ */
+export const resolveMemberCall = (
+ ownerType: string,
+ methodName: string,
+ currentFile: string,
+ ctx: ResolutionContext,
+ heritageMap?: HeritageMap,
+ argCount?: number,
+): ResolveResult | null => {
+ const resolved = resolveMethodByOwner(
+ ownerType,
+ methodName,
+ currentFile,
+ ctx,
+ heritageMap,
+ argCount,
+ );
+ if (!resolved) return null;
+ return toResolveResult(resolved.def, resolved.tier);
+};
+
+// ---------------------------------------------------------------------------
+// SM-13: Free-function call resolution
+// ---------------------------------------------------------------------------
+
+/**
+ * Resolve a free-function call using `lookupExact` (same-file) + import-scoped
+ * resolution via `ctx.resolve()`.
+ *
+ * Used for `foo()`, `doStuff()` — unqualified calls with no receiver.
+ * Also handles Swift/Kotlin implicit constructors (`User()` without `new`)
+ * by delegating to {@link resolveStaticCall} when the tiered pool contains
+ * class-like targets.
+ *
+ * {@link resolveCallTarget} delegates here for `callForm === 'free'` before
+ * processing constructor and member calls.
+ *
+ * **Design note (SM-13):** This path still falls through to Tier 3 (global)
+ * via `ctx.resolve()`. Fuzzy global resolution remains until Phase 5 replaces
+ * `lookupFuzzy` with a scoped data source.
+ *
+ * **Asymmetry vs `resolveCallTarget`:** `resolveFreeCall` intentionally does
+ * NOT take a `widenCache` parameter and does NOT run a D2 fuzzy-widening
+ * pass. Member calls (`resolveCallTarget`'s main body) widen via
+ * `lookupFuzzy` to reach parent-class methods defined in different files;
+ * free calls have no receiver type and rely exclusively on the tiered pool
+ * from `ctx.resolve()`. Phase 5 will revisit whether free calls need a
+ * scoped widening pass once `lookupFuzzy` is retired.
+ *
+ * @param calledName - The called function name (e.g. 'doStuff')
+ * @param filePath - File path of the call site
+ * @param ctx - Resolution context
+ * @param argCount - Optional argument count for arity filtering
+ * @param tieredOverride - Pre-computed tiered candidates from an upstream
+ * `ctx.resolve` call. When provided, skips the redundant
+ * lookup inside this function.
+ * @param overloadHints - Optional AST-based overload disambiguation hints
+ * @param preComputedArgTypes - Optional pre-computed argument types (worker path)
+ */
+export const resolveFreeCall = (
+ calledName: string,
+ filePath: string,
+ ctx: ResolutionContext,
+ argCount?: number,
+ tieredOverride?: TieredCandidates,
+ overloadHints?: OverloadHints,
+ preComputedArgTypes?: (string | undefined)[],
+): ResolveResult | null => {
+ const tiered = tieredOverride ?? ctx.resolve(calledName, filePath);
+ if (!tiered) return null;
+
+ let filteredCandidates = filterCallableCandidates(tiered.candidates, argCount, 'free');
+
+ // Class-target fast path: Swift/Kotlin `User()` — free-form call targeting a
+ // class. Delegates to resolveStaticCall for O(1) class + constructor lookup.
+ // The `.some()` trigger must stay aligned with `INSTANTIABLE_CLASS_TYPES` —
+ // any type admitted here that is not in that set will cause resolveStaticCall
+ // to return null, wasting two lookup passes per call. `Enum` is deliberately
+ // excluded; `Record` is included so C# records and Kotlin data classes reach
+ // the fast path.
+ // Align with INSTANTIABLE_CLASS_TYPES by reusing the set directly rather
+ // than enumerating literal strings. This converts an invariant that was
+ // previously enforced by a comment ("keep this list aligned with
+ // INSTANTIABLE_CLASS_TYPES") into one enforced structurally — any future
+ // extension of the set (e.g. Kotlin `object`) propagates here automatically.
+ // The `dedupSwiftExtensionCandidates` helper used in the tail of this
+ // function deliberately uses a narrower literal `'Class' | 'Struct'` check
+ // — Swift extensions only produce Class duplicates in practice, so Record
+ // is excluded there by design. Do not collapse that helper into
+ // INSTANTIABLE_CLASS_TYPES.
+ const hasClassTarget =
+ filteredCandidates.length === 0 &&
+ tiered.candidates.some((c) => INSTANTIABLE_CLASS_TYPES.has(c.type));
+ if (hasClassTarget) {
+ const staticResult = resolveStaticCall(calledName, filePath, ctx, argCount, tiered);
+ if (staticResult) return staticResult;
+ // Retry with constructor form: Swift/Kotlin constructor calls look like
+ // free function calls (no `new` keyword). If resolveStaticCall didn't
+ // match, re-filter with constructor form so CONSTRUCTOR_TARGET_TYPES
+ // applies.
+ //
+ // The retry fires for every null return from `resolveStaticCall`, which
+ // can happen for three distinct reasons — all three are handled below:
+ //
+ // (a) No explicit `Constructor` node found and zero instantiable
+ // class candidates (e.g. Interface/Trait/Impl only — the SM-12
+ // null-route contract). `filterCallableCandidates` with
+ // `'constructor'` form will also return nothing → we fall
+ // through to the final null return. Correct.
+ //
+ // (b) Homonym ambiguity — two or more instantiable class candidates
+ // share the name (e.g. `User` in two files, same tier). The
+ // retry repopulates `filteredCandidates` with both Classes and
+ // they flow into `dedupSwiftExtensionCandidates` below, which
+ // either picks the shortest-path primary or null-routes.
+ // Covered by the R7 Swift-extension dedup test.
+ //
+ // (c) `resolveStaticCall` step 4 bailed because the tiered pool
+ // contains ownerless `Constructor` nodes (some extractors emit
+ // constructors without `ownerId`). Those `Constructor` nodes
+ // survive the constructor-form filter below and reach overload
+ // disambiguation, giving the existing filter path a chance to
+ // pick the right one. Correct but currently uncovered by a
+ // dedicated test — the R5 `preComputedArgTypes` path exercises
+ // overload disambiguation for Functions, which is structurally
+ // the same code.
+ filteredCandidates = filterCallableCandidates(tiered.candidates, argCount, 'constructor');
+ }
+
+ // E. Overload disambiguation
+ if (filteredCandidates.length > 1) {
+ const disambiguated = overloadHints
+ ? tryOverloadDisambiguation(filteredCandidates, overloadHints)
+ : preComputedArgTypes
+ ? matchCandidatesByArgTypes(filteredCandidates, preComputedArgTypes)
+ : null;
+ if (disambiguated) return toResolveResult(disambiguated, tiered.tier);
+ }
+
+ if (filteredCandidates.length !== 1) {
+ // See `dedupSwiftExtensionCandidates` — shared helper, single source of
+ // truth for the Swift-extension same-name collision heuristic.
+ const deduped = dedupSwiftExtensionCandidates(filteredCandidates, tiered.tier);
+ if (deduped) return deduped;
+ return null;
+ }
+
+ return toResolveResult(filteredCandidates[0], tiered.tier);
+};
+
+// ---------------------------------------------------------------------------
+// SM-12: Constructor/static call resolution (no fuzzy lookup)
+// ---------------------------------------------------------------------------
+
+/**
+ * Resolve a constructor or static call using class-scoped lookup (no fuzzy lookup).
+ * Used for `new User()` / `User()` calls where the calledName targets a class.
+ *
+ * Uses {@link SymbolTable.lookupClassByName} for O(1) class lookup and
+ * {@link SymbolTable.lookupMethodByOwner} for constructor resolution.
+ * {@link resolveCallTarget} delegates here for constructor and free-form calls
+ * that target a class, before falling back to the more expensive fuzzy-widening
+ * path (D1-D4).
+ *
+ * Resolution strategy:
+ * 1. `lookupClassByName(className)` — O(1) pre-check; bail early if no class exists.
+ * 2. `ctx.resolve(className, currentFile)` — import-scoped tier for confidence.
+ * 3. Filter to class-like candidates via `CLASS_LIKE_TYPES` and walk each
+ * with `lookupMethodByOwner(classNodeId, className, argCount)` — O(1)
+ * constructor lookup. Only accept results with `type === 'Constructor'`.
+ * 4. If step 3 found nothing and the tiered pool contains ownerless
+ * `Constructor` nodes (common in some extractors), bail out so
+ * `filterCallableCandidates` downstream handles Constructor-vs-Class
+ * preference correctly.
+ * 5. Class-node fallback: filter `classCandidates` through
+ * `INSTANTIABLE_CLASS_TYPES` and return the sole survivor when there is
+ * exactly one. Null-route on zero survivors (Interface / Trait / Impl
+ * stripped) or multiple (homonym ambiguity).
+ *
+ * @param className - The class name (e.g. 'User'). Also used as the method
+ * name for the `lookupMethodByOwner` scan, because the
+ * only constructor-shaped call we handle today is
+ * `ClassName(...)` / `new ClassName(...)`. Named
+ * constructors like Dart `User.fromJson()` arrive as
+ * member calls and route through `resolveMemberCall`,
+ * so this function does not yet need a separate
+ * `methodName` parameter. Revisit if a language surfaces
+ * a static-method-shaped call with a distinct member
+ * name.
+ * @param currentFile - File path of the call site
+ * @param ctx - Resolution context
+ * @param argCount - Optional argument count for arity filtering
+ * @param tieredOverride - Pre-computed tiered candidates for `className` from
+ * an upstream `ctx.resolve` call. When provided, skips
+ * the redundant lookup inside this function. Leave
+ * unset for direct callers without a prior resolution.
+ */
+export const resolveStaticCall = (
+ className: string,
+ currentFile: string,
+ ctx: ResolutionContext,
+ argCount?: number,
+ tieredOverride?: TieredCandidates,
+): ResolveResult | null => {
+ // 1. Pre-check: does a class with this name exist at all? (O(1))
+ // This guards against the expensive `ctx.resolve` walk when the name
+ // is clearly not class-like (e.g. plain functions). When `tieredOverride`
+ // is supplied, the caller has already paid for the tiered lookup, so this
+ // pre-check still prevents the class-candidate filter + lookupMethodByOwner
+ // loop from running on obviously non-class targets.
+ const allClasses = ctx.symbols.lookupClassByName(className);
+ if (allClasses.length === 0) return null;
+
+ // 2. Scope via ctx.resolve for import-tier information. Reuse the caller's
+ // tiered result when provided — it is computed from the same name and
+ // file context, so re-running the walk would be a pure waste.
+ const typeResolved = tieredOverride ?? ctx.resolve(className, currentFile);
+ if (!typeResolved) return null;
+
+ const classCandidates = typeResolved.candidates.filter((c) => CLASS_LIKE_TYPES.has(c.type));
+ if (classCandidates.length === 0) return null;
+
+ // 3. Try lookupMethodByOwner for explicit Constructor nodes.
+ // Only accept results with type === 'Constructor' — a Method or Function
+ // that happens to share the class name (e.g. C++ methods named after
+ // their class) is not a constructor for resolution purposes.
+ // Same dedup logic as resolveMethodByOwner: diamond inheritance converging
+ // on the same constructor collapses to one hit.
+ //
+ // Same-name assumption: the lookup key is `${candidate.nodeId}\0${className}`,
+ // so this finds Constructor nodes whose symbol name equals the class name
+ // (`class User` with a `Constructor` named `User`). Constructors indexed
+ // under a different name (e.g. Python `__init__`) will not be found here —
+ // but they also won't appear in the tiered pool for `ctx.resolve(className)`
+ // for the same reason, so step 4's Constructor-presence check will not
+ // see them either. The two miss cases are symmetric. If a future extractor
+ // indexes Constructor nodes under an alternative name while still setting
+ // `ownerId`, this assumption will need revisiting.
+ let firstDef: SymbolDefinition | undefined;
+ let ambiguous = false;
+ for (const candidate of classCandidates) {
+ const def = ctx.symbols.lookupMethodByOwner(candidate.nodeId, className, argCount);
+ if (!def || def.type !== 'Constructor') continue;
+ if (!firstDef) {
+ firstDef = def;
+ } else if (def.nodeId !== firstDef.nodeId) {
+ ambiguous = true;
+ break;
+ }
+ }
+
+ if (firstDef && !ambiguous) {
+ return toResolveResult(firstDef, typeResolved.tier);
+ }
+
+ // 4. lookupMethodByOwner found nothing — check whether the tiered pool
+ // contains Constructor nodes that lack ownerId (common in some extractors).
+ // If so, bail out so the existing filterCallableCandidates path handles
+ // Constructor-vs-Class preference correctly.
+ //
+ // This branch also catches the step-3 ambiguous case (`ambiguous = true`
+ // with two distinct Constructor nodes across multiple class candidates):
+ // the same Constructor nodes are indexed under the class name in the
+ // tiered pool, so `.some(Constructor)` is true here and we defer to
+ // `filterCallableCandidates` downstream rather than guess which overload
+ // to pick. Do not remove this check without also handling the ambiguous
+ // step-3 path explicitly.
+ if (typeResolved.candidates.some((c) => c.type === 'Constructor')) {
+ return null;
+ }
+
+ // 5. No constructor nodes at all — fall back to the class node itself, but
+ // ONLY when it is actually instantiable. Interface / Trait / Impl / Enum
+ // are deliberately excluded via `INSTANTIABLE_CLASS_TYPES` to prevent
+ // false `CALLS` edges from constructor-shaped calls to non-instantiable
+ // nodes. This also disambiguates the Rust same-file shadowing case
+ // (`struct User` + `impl User` both present at same-file tier): the
+ // Impl is stripped, leaving the Struct as the sole instantiable target.
+ // Addresses Codex review finding on PR #754.
+ const instantiableCandidates = classCandidates.filter((c) =>
+ INSTANTIABLE_CLASS_TYPES.has(c.type),
+ );
+ // Three outcomes below, in order of likelihood after the fix:
+ // length === 0 → all candidates were stripped as non-instantiable (e.g.
+ // Interface / Trait / Impl). Null-route via the fall-through `return
+ // null` — this is the dominant Codex-fix case.
+ // length === 1 → a single instantiable candidate remains, return it.
+ // length > 1 → two or more instantiable classes share the name (e.g.
+ // homonym classes across files with no import narrowing). Fall through
+ // to `return null` so the caller null-routes rather than guess.
+ if (instantiableCandidates.length === 1) {
+ return toResolveResult(instantiableCandidates[0], typeResolved.tier);
+ }
+
+ return null;
};
// ---------------------------------------------------------------------------
@@ -1729,9 +2309,12 @@ export const lookupMethodByOwnerWithMRO = (
heritageMap: HeritageMap,
symbols: SymbolTable,
language: SupportedLanguages,
+ argCount?: number,
): SymbolDefinition | undefined => {
- // Direct lookup first (child override — no walk needed)
- const direct = symbols.lookupMethodByOwner(ownerNodeId, methodName);
+ // Direct lookup first (child override — no walk needed).
+ // argCount is threaded through so arity-differing overloads on the direct
+ // owner can be disambiguated before the MRO walk starts.
+ const direct = symbols.lookupMethodByOwner(ownerNodeId, methodName, argCount);
if (direct) return direct;
const strategy = getProvider(language).mroStrategy;
@@ -1758,9 +2341,10 @@ export const lookupMethodByOwnerWithMRO = (
ancestors = heritageMap.getAncestors(ownerNodeId);
}
- // Walk ancestors in MRO order — first match wins
+ // Walk ancestors in MRO order — first match wins.
+ // argCount narrows overloaded ancestors the same way as the direct lookup.
for (const ancestorId of ancestors) {
- const method = symbols.lookupMethodByOwner(ancestorId, methodName);
+ const method = symbols.lookupMethodByOwner(ancestorId, methodName, argCount);
if (method) return method;
}
@@ -1829,12 +2413,16 @@ const walkMixedChain = (
continue;
}
// Fast path: O(1) owner-scoped method lookup via methodByOwner index.
- // Avoids fuzzy lookup when the owner type is known and the method is unambiguous.
// Note: CALLS edges for intermediate chain steps are NOT emitted here — walkMixedChain
// only threads types. CALLS edges come from the outer per-call-expression loop in processCalls.
- const methodDef = resolveMethodByOwner(currentType, step.name, filePath, ctx, heritageMap);
- if (methodDef?.returnType) {
- const fastRetType = extractReturnTypeName(methodDef.returnType);
+ //
+ // We call `resolveMethodByOwner` directly (NOT `resolveMemberCall`) because this is
+ // a hot path — called per chain step per call expression — and we only need the
+ // return type string. Going through `resolveMemberCall` would allocate a throwaway
+ // `ResolveResult` with confidence/reason that we immediately discard.
+ const owned = resolveMethodByOwner(currentType, step.name, filePath, ctx, heritageMap);
+ if (owned?.def.returnType) {
+ const fastRetType = extractReturnTypeName(owned.def.returnType);
if (fastRetType) {
currentType = fastRetType;
continue;
@@ -1845,6 +2433,10 @@ const walkMixedChain = (
{ calledName: step.name, callForm: 'member', receiverTypeName: currentType },
filePath,
ctx,
+ undefined,
+ undefined,
+ undefined,
+ heritageMap,
);
if (!resolved) {
// Stdlib passthrough: unwrap(), clone(), etc. preserve the receiver type
@@ -1994,6 +2586,7 @@ export const processCallsFromExtracted = async (
undefined,
widenCache,
effectiveCall.argTypes,
+ heritageMap,
);
if (!resolved) {
// Vue template component fallback: match calledName against imported .vue basenames
diff --git a/gitnexus/src/core/ingestion/filesystem-walker.ts b/gitnexus/src/core/ingestion/filesystem-walker.ts
index 11721d6a7..575efc0f3 100644
--- a/gitnexus/src/core/ingestion/filesystem-walker.ts
+++ b/gitnexus/src/core/ingestion/filesystem-walker.ts
@@ -1,3 +1,4 @@
+import { isVerboseIngestionEnabled } from './utils/verbose.js';
import fs from 'fs/promises';
import path from 'path';
import { glob } from 'glob';
@@ -43,6 +44,7 @@ export const walkRepositoryPaths = async (
const entries: ScannedFile[] = [];
let processed = 0;
let skippedLarge = 0;
+ const skippedLargePaths: string[] = [];
for (let start = 0; start < filtered.length; start += READ_CONCURRENCY) {
const batch = filtered.slice(start, start + READ_CONCURRENCY);
@@ -52,6 +54,7 @@ export const walkRepositoryPaths = async (
const stat = await fs.stat(fullPath);
if (stat.size > MAX_FILE_SIZE) {
skippedLarge++;
+ skippedLargePaths.push(relativePath.replace(/\\/g, '/'));
return null;
}
return { path: relativePath.replace(/\\/g, '/'), size: stat.size };
@@ -73,6 +76,11 @@ export const walkRepositoryPaths = async (
console.warn(
` Skipped ${skippedLarge} large files (>${MAX_FILE_SIZE / 1024}KB, likely generated/vendored)`,
);
+ if (isVerboseIngestionEnabled()) {
+ for (const p of skippedLargePaths) {
+ console.warn(` - ${p}`);
+ }
+ }
}
return entries;
diff --git a/gitnexus/src/core/ingestion/named-binding-processor.ts b/gitnexus/src/core/ingestion/named-binding-processor.ts
index cf54cee59..1802f86e8 100644
--- a/gitnexus/src/core/ingestion/named-binding-processor.ts
+++ b/gitnexus/src/core/ingestion/named-binding-processor.ts
@@ -41,7 +41,7 @@ export function walkBindingChain(
const targetName = binding.exportedName;
const resolvedDefs =
targetName !== lookupName || depth > 0
- ? symbolTable.lookupFuzzy(targetName).filter((def) => def.filePath === binding.sourcePath)
+ ? symbolTable.lookupExactAll(binding.sourcePath, targetName)
: allDefs.filter((def) => def.filePath === binding.sourcePath);
if (resolvedDefs.length > 0) return resolvedDefs;
diff --git a/gitnexus/src/core/ingestion/symbol-table.ts b/gitnexus/src/core/ingestion/symbol-table.ts
index 086148f2f..b8d39170c 100644
--- a/gitnexus/src/core/ingestion/symbol-table.ts
+++ b/gitnexus/src/core/ingestion/symbol-table.ts
@@ -1,6 +1,18 @@
import type { NodeLabel } from 'gitnexus-shared';
-export const CLASS_TYPES = new Set(['Class', 'Struct', 'Interface', 'Enum', 'Record']);
+export const CLASS_TYPES = new Set([
+ 'Class',
+ 'Struct',
+ 'Interface',
+ 'Enum',
+ 'Record',
+ // Traits are class-like for heritage resolution: PHP `use Trait;`, Rust
+ // `impl Trait for Struct`, and Scala traits all contribute methods to the
+ // hierarchy of their using/implementing type. Including Trait here lets
+ // buildHeritageMap resolve `h.parentName` to a Trait nodeId so the MRO
+ // walker can visit the trait and find its methods.
+ 'Trait',
+]);
export interface SymbolDefinition {
nodeId: string;
@@ -93,7 +105,24 @@ export interface SymbolTable {
* overloads share the same returnType, undefined when return types differ (ambiguous).
* Used by walkMixedChain for deterministic cross-class chain resolution.
*/
- lookupMethodByOwner: (ownerNodeId: string, methodName: string) => SymbolDefinition | undefined;
+ /**
+ * Lookup a method by owner class + name, optionally filtered by arity.
+ *
+ * When `argCount` is provided, overloads whose parameter count doesn't
+ * accommodate the call's argument count are filtered out before the
+ * returnType dedup runs. This lets D0 (`resolveMemberCall`) disambiguate
+ * arity-differing overloads (e.g. C++ `greet()` vs `greet(string)`) that
+ * would otherwise collide on the shared `ownerId + methodName` key.
+ *
+ * Same-arity, same-returnType overloads (e.g. `save(int)` vs `save(String)`,
+ * both returning `void`) still collapse to the first match — callers must
+ * gate D0 on overload concern before invoking this function for that case.
+ */
+ lookupMethodByOwner: (
+ ownerNodeId: string,
+ methodName: string,
+ argCount?: number,
+ ) => SymbolDefinition | undefined;
/**
* Look up class-like definitions (Class, Struct, Interface, Enum, Record) by name.
@@ -225,9 +254,16 @@ export const createSymbolTable = (): SymbolTable => {
}
globalIndex.get(name)!.push(def);
- // C2. Methods and constructors with ownerId go to methodByOwner index
- // (in addition to globalIndex).
- if ((type === 'Method' || type === 'Constructor') && metadata?.ownerId) {
+ // C2. Methods, constructors, and ownerId-bound Functions go to
+ // methodByOwner index (in addition to globalIndex).
+ //
+ // Some language extractors emit class methods as `Function` with an
+ // `ownerId` — notably Python (`def method(self):` inside a class body),
+ // Rust trait methods, and Kotlin object/companion methods. Treating
+ // `Function` with ownerId the same as `Method` here makes D0
+ // (`resolveMemberCall`) work uniformly across all supported languages
+ // instead of silently falling through to D1-D4 fuzzy widening.
+ if ((type === 'Method' || type === 'Constructor' || type === 'Function') && metadata?.ownerId) {
const key = `${metadata.ownerId}\0${name}`;
const existing = methodByOwner.get(key);
if (existing) {
@@ -303,18 +339,42 @@ export const createSymbolTable = (): SymbolTable => {
const lookupMethodByOwner = (
ownerNodeId: string,
methodName: string,
+ argCount?: number,
): SymbolDefinition | undefined => {
const defs = methodByOwner.get(`${ownerNodeId}\0${methodName}`);
if (!defs || defs.length === 0) return undefined;
- if (defs.length === 1) return defs[0];
- // Multiple overloads: return first if all share the same defined returnType (safe for chain resolution).
- // Return undefined if return types differ or are absent (truly ambiguous — can't determine which overload).
- const firstReturnType = defs[0].returnType;
- if (firstReturnType === undefined) return undefined;
- for (let i = 1; i < defs.length; i++) {
- if (defs[i].returnType !== firstReturnType) return undefined;
+
+ // Arity narrowing: when an argCount is provided and there are multiple
+ // overloads, keep only those whose parameterCount can accommodate the
+ // call. This resolves arity-differing overloads (e.g. C++ `greet()` vs
+ // `greet(string)`) that share the same `ownerId + methodName` key.
+ //
+ // Candidates with `parameterCount === undefined` (extractor didn't
+ // populate the count — typically variadic or unknown) are retained
+ // conservatively so that legitimate variadic matches still resolve.
+ let pool = defs;
+ if (argCount !== undefined && defs.length > 1) {
+ const arityMatched = defs.filter((d) => {
+ if (d.parameterCount === undefined) return true;
+ const min = d.requiredParameterCount ?? d.parameterCount;
+ return argCount >= min && argCount <= d.parameterCount;
+ });
+ // Only adopt the arity-narrowed pool when it found matches; if arity
+ // rules out every candidate, fall back to the unfiltered set so the
+ // caller's fuzzy path still has something to work with.
+ if (arityMatched.length > 0) pool = arityMatched;
}
- return defs[0];
+
+ if (pool.length === 1) return pool[0];
+ // Multiple overloads after arity narrowing: return first if all share
+ // the same defined returnType (safe for chain resolution), undefined if
+ // return types differ (truly ambiguous — can't determine which overload).
+ const firstReturnType = pool[0].returnType;
+ if (firstReturnType === undefined) return undefined;
+ for (let i = 1; i < pool.length; i++) {
+ if (pool[i].returnType !== firstReturnType) return undefined;
+ }
+ return pool[0];
};
const lookupClassByName = (name: string): SymbolDefinition[] => {
diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts
index 1363257d5..88a6e9bba 100644
--- a/gitnexus/src/core/lbug/lbug-adapter.ts
+++ b/gitnexus/src/core/lbug/lbug-adapter.ts
@@ -637,6 +637,34 @@ export const executeQuery = async (cypher: string): Promise => {
return rows;
};
+export const streamQuery = async (
+ cypher: string,
+ onRow: (row: any) => void | Promise,
+): Promise => {
+ if (!conn) {
+ throw new Error('LadybugDB not initialized. Call initLbug first.');
+ }
+
+ const queryResult = await conn.query(cypher);
+ const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
+ let rowCount = 0;
+
+ try {
+ while (await result.hasNext()) {
+ const row = await result.getNext();
+ await onRow(row);
+ rowCount++;
+ }
+ return rowCount;
+ } finally {
+ try {
+ await result.close();
+ } catch {
+ // Best-effort cleanup only.
+ }
+ }
+};
+
/**
* Execute a single parameterized query (prepare/execute pattern).
* Prevents Cypher injection by binding values as parameters.
diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts
index 8422af6ba..8111c287b 100644
--- a/gitnexus/src/server/api.ts
+++ b/gitnexus/src/server/api.ts
@@ -18,6 +18,7 @@ import {
executeQuery,
executePrepared,
executeWithReusedStatement,
+ streamQuery,
closeLbug,
withLbugDb,
} from '../core/lbug/lbug-adapter.js';
@@ -105,75 +106,228 @@ export const isAllowedOrigin = (origin: string | undefined): boolean => {
return false;
};
+type GraphStreamRecord =
+ | { type: 'node'; data: GraphNode }
+ | { type: 'relationship'; data: GraphRelationship }
+ | { type: 'error'; error: string };
+
+export class ClientDisconnectedError extends Error {
+ constructor() {
+ super('Client disconnected during graph stream');
+ this.name = 'ClientDisconnectedError';
+ }
+}
+
+export const isIgnorableGraphQueryError = (err: unknown): boolean => {
+ const message = err instanceof Error ? err.message : String(err);
+ return (
+ message.includes('does not exist') ||
+ message.includes('not found') ||
+ message.includes('No table named')
+ );
+};
+
+const ensureStreamIsWritable = (res: express.Response, signal?: AbortSignal): void => {
+ if (signal?.aborted || res.destroyed || res.writableEnded) {
+ throw new ClientDisconnectedError();
+ }
+};
+
+const waitForDrain = async (res: express.Response, signal?: AbortSignal): Promise => {
+ ensureStreamIsWritable(res, signal);
+
+ await new Promise((resolve, reject) => {
+ const cleanup = () => {
+ res.off('drain', onDrain);
+ res.off('close', onClose);
+ signal?.removeEventListener('abort', onAbort);
+ };
+
+ const onDrain = () => {
+ cleanup();
+ resolve();
+ };
+ const onClose = () => {
+ cleanup();
+ reject(new ClientDisconnectedError());
+ };
+ const onAbort = () => {
+ cleanup();
+ reject(new ClientDisconnectedError());
+ };
+
+ res.once('drain', onDrain);
+ res.once('close', onClose);
+ signal?.addEventListener('abort', onAbort, { once: true });
+
+ if (signal?.aborted || res.destroyed || res.writableEnded) {
+ onAbort();
+ }
+ });
+
+ ensureStreamIsWritable(res, signal);
+};
+
+const isClientDisconnectWriteError = (err: unknown): boolean => {
+ if (!(err instanceof Error)) return false;
+ return (
+ (err as NodeJS.ErrnoException).code === 'ERR_STREAM_DESTROYED' ||
+ (err as NodeJS.ErrnoException).code === 'EPIPE' ||
+ (err as NodeJS.ErrnoException).code === 'ECONNRESET' ||
+ err.message.includes('write after end')
+ );
+};
+
+export const writeNdjsonRecord = async (
+ res: express.Response,
+ record: GraphStreamRecord,
+ signal?: AbortSignal,
+): Promise => {
+ ensureStreamIsWritable(res, signal);
+
+ try {
+ const canContinue = res.write(JSON.stringify(record) + '\n');
+ if (!canContinue) {
+ await waitForDrain(res, signal);
+ }
+ } catch (err) {
+ if (isClientDisconnectWriteError(err)) {
+ throw new ClientDisconnectedError();
+ }
+ throw err;
+ }
+};
+
const buildGraph = async (
includeContent = false,
): Promise<{ nodes: GraphNode[]; relationships: GraphRelationship[] }> => {
const nodes: GraphNode[] = [];
for (const table of NODE_TABLES) {
try {
- let query = '';
- if (table === 'File') {
- query = includeContent
- ? `MATCH (n:File) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.content AS content`
- : `MATCH (n:File) RETURN n.id AS id, n.name AS name, n.filePath AS filePath`;
- } else if (table === 'Folder') {
- query = `MATCH (n:Folder) RETURN n.id AS id, n.name AS name, n.filePath AS filePath`;
- } else if (table === 'Community') {
- query = `MATCH (n:Community) RETURN n.id AS id, n.label AS label, n.heuristicLabel AS heuristicLabel, n.cohesion AS cohesion, n.symbolCount AS symbolCount`;
- } else if (table === 'Process') {
- query = `MATCH (n:Process) RETURN n.id AS id, n.label AS label, n.heuristicLabel AS heuristicLabel, n.processType AS processType, n.stepCount AS stepCount, n.communities AS communities, n.entryPointId AS entryPointId, n.terminalId AS terminalId`;
- } else {
- query = includeContent
- ? `MATCH (n:${table}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine, n.content AS content`
- : `MATCH (n:${table}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine`;
- }
-
- const rows = await executeQuery(query);
+ const rows = await executeQuery(getNodeQuery(table, includeContent));
for (const row of rows) {
- nodes.push({
- id: row.id ?? row[0],
- label: table as GraphNode['label'],
- properties: {
- name: row.name ?? row.label ?? row[1],
- filePath: row.filePath ?? row[2],
- startLine: row.startLine,
- endLine: row.endLine,
- content: includeContent ? row.content : undefined,
- heuristicLabel: row.heuristicLabel,
- cohesion: row.cohesion,
- symbolCount: row.symbolCount,
- processType: row.processType,
- stepCount: row.stepCount,
- communities: row.communities,
- entryPointId: row.entryPointId,
- terminalId: row.terminalId,
- } as GraphNode['properties'],
- });
+ nodes.push(mapGraphNodeRow(table, row, includeContent));
+ }
+ } catch (err) {
+ if (!isIgnorableGraphQueryError(err)) {
+ throw err;
}
- } catch {
- // ignore empty tables
}
}
const relationships: GraphRelationship[] = [];
- const relRows = await executeQuery(
- `MATCH (a)-[r:CodeRelation]->(b) RETURN a.id AS sourceId, b.id AS targetId, r.type AS type, r.confidence AS confidence, r.reason AS reason, r.step AS step`,
- );
+ const relRows = await executeQuery(GRAPH_RELATIONSHIP_QUERY);
for (const row of relRows) {
- relationships.push({
- id: `${row.sourceId}_${row.type}_${row.targetId}`,
- type: row.type,
- sourceId: row.sourceId,
- targetId: row.targetId,
- confidence: row.confidence,
- reason: row.reason,
- step: row.step,
- });
+ relationships.push(mapGraphRelationshipRow(row));
}
return { nodes, relationships };
};
+const GRAPH_RELATIONSHIP_QUERY =
+ `MATCH (a)-[r:CodeRelation]->(b) RETURN a.id AS sourceId, b.id AS targetId, ` +
+ `r.type AS type, r.confidence AS confidence, r.reason AS reason, r.step AS step`;
+
+const quoteNodeTable = (table: string): string => `\`${table.replace(/`/g, '``')}\``;
+
+const getNodeQuery = (table: string, includeContent: boolean): string => {
+ const tableLabel = quoteNodeTable(table);
+
+ if (table === 'File') {
+ return includeContent
+ ? `MATCH (n:${tableLabel}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.content AS content`
+ : `MATCH (n:${tableLabel}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath`;
+ }
+ if (table === 'Folder') {
+ return `MATCH (n:${tableLabel}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath`;
+ }
+ if (table === 'Community') {
+ return `MATCH (n:${tableLabel}) RETURN n.id AS id, n.label AS label, n.heuristicLabel AS heuristicLabel, n.cohesion AS cohesion, n.symbolCount AS symbolCount`;
+ }
+ if (table === 'Process') {
+ return `MATCH (n:${tableLabel}) RETURN n.id AS id, n.label AS label, n.heuristicLabel AS heuristicLabel, n.processType AS processType, n.stepCount AS stepCount, n.communities AS communities, n.entryPointId AS entryPointId, n.terminalId AS terminalId`;
+ }
+ if (table === 'Route') {
+ return `MATCH (n:${tableLabel}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.responseKeys AS responseKeys, n.errorKeys AS errorKeys, n.middleware AS middleware`;
+ }
+ if (table === 'Tool') {
+ return `MATCH (n:${tableLabel}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.description AS description`;
+ }
+ return includeContent
+ ? `MATCH (n:${tableLabel}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine, n.content AS content`
+ : `MATCH (n:${tableLabel}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine`;
+};
+
+const mapGraphNodeRow = (table: string, row: any, includeContent: boolean): GraphNode => ({
+ id: row.id ?? row[0],
+ label: table as GraphNode['label'],
+ properties: {
+ name: row.name ?? row.label ?? row[1],
+ filePath: row.filePath ?? row[2],
+ startLine: row.startLine,
+ endLine: row.endLine,
+ content: includeContent ? row.content : undefined,
+ responseKeys: row.responseKeys,
+ errorKeys: row.errorKeys,
+ middleware: row.middleware,
+ heuristicLabel: row.heuristicLabel,
+ cohesion: row.cohesion,
+ symbolCount: row.symbolCount,
+ description: row.description,
+ processType: row.processType,
+ stepCount: row.stepCount,
+ communities: row.communities,
+ entryPointId: row.entryPointId,
+ terminalId: row.terminalId,
+ } as GraphNode['properties'],
+});
+
+const mapGraphRelationshipRow = (row: any): GraphRelationship => ({
+ id: `${row.sourceId}_${row.type}_${row.targetId}`,
+ type: row.type,
+ sourceId: row.sourceId,
+ targetId: row.targetId,
+ confidence: row.confidence,
+ reason: row.reason,
+ step: row.step,
+});
+
+export const streamGraphNdjson = async (
+ res: express.Response,
+ includeContent = false,
+ signal?: AbortSignal,
+): Promise => {
+ for (const table of NODE_TABLES) {
+ try {
+ await streamQuery(getNodeQuery(table, includeContent), async (row) => {
+ await writeNdjsonRecord(
+ res,
+ {
+ type: 'node',
+ data: mapGraphNodeRow(table, row, includeContent),
+ },
+ signal,
+ );
+ });
+ } catch (err) {
+ if (!isIgnorableGraphQueryError(err)) {
+ throw err;
+ }
+ }
+ }
+
+ await streamQuery(GRAPH_RELATIONSHIP_QUERY, async (row) => {
+ await writeNdjsonRecord(
+ res,
+ {
+ type: 'relationship',
+ data: mapGraphRelationshipRow(row),
+ },
+ signal,
+ );
+ });
+};
+
/**
* Mount an SSE progress endpoint for a JobManager.
* Handles: initial state, terminal events, heartbeat, event IDs, client disconnect.
@@ -464,10 +618,60 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
}
const lbugPath = path.join(entry.storagePath, 'lbug');
const includeContent = req.query.includeContent === 'true';
+ const stream = req.query.stream === 'true';
+
+ if (stream) {
+ const abortController = new AbortController();
+ let responseFinished = false;
+ const markFinished = () => {
+ responseFinished = true;
+ };
+ const abortStreaming = () => {
+ if (!responseFinished) {
+ abortController.abort();
+ }
+ };
+
+ res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8');
+ res.setHeader('Cache-Control', 'no-cache');
+ res.flushHeaders();
+
+ req.once('aborted', abortStreaming);
+ res.once('finish', markFinished);
+ res.once('close', abortStreaming);
+
+ try {
+ await withLbugDb(lbugPath, async () =>
+ streamGraphNdjson(res, includeContent, abortController.signal),
+ );
+ if (!abortController.signal.aborted && !res.writableEnded) {
+ res.end();
+ }
+ } finally {
+ req.off('aborted', abortStreaming);
+ res.off('finish', markFinished);
+ res.off('close', abortStreaming);
+ }
+ return;
+ }
+
const graph = await withLbugDb(lbugPath, async () => buildGraph(includeContent));
res.json(graph);
} catch (err: any) {
- res.status(500).json({ error: err.message || 'Failed to build graph' });
+ if (err instanceof ClientDisconnectedError) {
+ return;
+ }
+ const message = err.message || 'Failed to build graph';
+ if (res.headersSent) {
+ try {
+ res.write(JSON.stringify({ type: 'error', error: message }) + '\n');
+ } catch {
+ // Best-effort only after streaming has started.
+ }
+ res.end();
+ return;
+ }
+ res.status(500).json({ error: message });
}
});
diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts
index cc447aa4c..b233d44fb 100644
--- a/gitnexus/src/storage/repo-manager.ts
+++ b/gitnexus/src/storage/repo-manager.ts
@@ -212,7 +212,7 @@ export const addToGitignore = async (repoPath: string): Promise => {
* Get the path to the global GitNexus directory
*/
export const getGlobalDir = (): string => {
- return path.join(os.homedir(), '.gitnexus');
+ return process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus');
};
/**
diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/A.h b/gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/A.h
new file mode 100644
index 000000000..314c3556f
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/A.h
@@ -0,0 +1,10 @@
+#pragma once
+#include "Base.h"
+
+// Virtual inheritance: together with `B : virtual public Base`, this creates
+// a single shared `Base` subobject under `Derived`, so `d.method()` is an
+// unambiguous call in real C++. Without the `virtual` keyword, a non-virtual
+// diamond would produce two separate `Base` subobjects and the call would
+// be ambiguous, requiring `d.A::method()` or `d.B::method()` to disambiguate.
+class A : virtual public Base {
+};
diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/B.h b/gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/B.h
new file mode 100644
index 000000000..7a9b90fff
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/B.h
@@ -0,0 +1,7 @@
+#pragma once
+#include "Base.h"
+
+// See the comment in A.h — both sides of the diamond use virtual inheritance
+// so there is exactly one `Base` subobject under `Derived`.
+class B : virtual public Base {
+};
diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/Base.h b/gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/Base.h
new file mode 100644
index 000000000..2b8382711
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/Base.h
@@ -0,0 +1,6 @@
+#pragma once
+
+class Base {
+public:
+ int method() { return 42; }
+};
diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/Derived.h b/gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/Derived.h
new file mode 100644
index 000000000..2b6e89b46
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/Derived.h
@@ -0,0 +1,6 @@
+#pragma once
+#include "A.h"
+#include "B.h"
+
+class Derived : public A, public B {
+};
diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/app.cpp
new file mode 100644
index 000000000..8bc9b4969
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/app.cpp
@@ -0,0 +1,6 @@
+#include "Derived.h"
+
+void run() {
+ Derived d;
+ d.method();
+}
diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-interface-default-method/App.cs b/gitnexus/test/fixtures/lang-resolution/csharp-interface-default-method/App.cs
new file mode 100644
index 000000000..2e516fd93
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/csharp-interface-default-method/App.cs
@@ -0,0 +1,15 @@
+namespace InterfaceDefault;
+
+public class App
+{
+ public static void Run()
+ {
+ // Default interface methods in C# 8.0+ are reachable ONLY through
+ // the interface type, not as inherited class members. Declaring the
+ // variable as IValidator is the idiomatic way to invoke Validate().
+ // `User user = new User(...); user.Validate();` would be a compile
+ // error because User does not expose Validate as a class member.
+ IValidator user = new User("alice");
+ user.Validate();
+ }
+}
diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-interface-default-method/User.cs b/gitnexus/test/fixtures/lang-resolution/csharp-interface-default-method/User.cs
new file mode 100644
index 000000000..3d380a83f
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/csharp-interface-default-method/User.cs
@@ -0,0 +1,11 @@
+namespace InterfaceDefault;
+
+public class User : IValidator
+{
+ public string Name { get; }
+
+ public User(string name)
+ {
+ Name = name;
+ }
+}
diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-interface-default-method/Validator.cs b/gitnexus/test/fixtures/lang-resolution/csharp-interface-default-method/Validator.cs
new file mode 100644
index 000000000..620e0a207
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/csharp-interface-default-method/Validator.cs
@@ -0,0 +1,6 @@
+namespace InterfaceDefault;
+
+public interface IValidator
+{
+ bool Validate() => true;
+}
diff --git a/gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/app.dart b/gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/app.dart
new file mode 100644
index 000000000..0c431a071
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/app.dart
@@ -0,0 +1,8 @@
+import 'child.dart';
+
+class App {
+ void run() {
+ final c = Child();
+ c.parentMethod();
+ }
+}
diff --git a/gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/child.dart b/gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/child.dart
new file mode 100644
index 000000000..fafe63719
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/child.dart
@@ -0,0 +1,3 @@
+import 'parent.dart';
+
+class Child extends Parent {}
diff --git a/gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/parent.dart b/gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/parent.dart
new file mode 100644
index 000000000..28c09da37
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/parent.dart
@@ -0,0 +1,5 @@
+class Parent {
+ String parentMethod() {
+ return 'parent';
+ }
+}
diff --git a/gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/go.mod b/gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/go.mod
new file mode 100644
index 000000000..192e075e8
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/go.mod
@@ -0,0 +1,3 @@
+module example.com/app
+
+go 1.21
diff --git a/gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/models/child.go b/gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/models/child.go
new file mode 100644
index 000000000..aac88e503
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/models/child.go
@@ -0,0 +1,5 @@
+package models
+
+type Child struct {
+ Parent
+}
diff --git a/gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/models/parent.go b/gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/models/parent.go
new file mode 100644
index 000000000..4aa63ee03
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/models/parent.go
@@ -0,0 +1,7 @@
+package models
+
+type Parent struct{}
+
+func (p *Parent) ParentMethod() string {
+ return "parent"
+}
diff --git a/gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/services/app.go b/gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/services/app.go
new file mode 100644
index 000000000..cde995386
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/services/app.go
@@ -0,0 +1,8 @@
+package services
+
+import "example.com/app/models"
+
+func Run() {
+ c := &models.Child{}
+ c.ParentMethod()
+}
diff --git a/gitnexus/test/fixtures/lang-resolution/java-interface-default-method/App.java b/gitnexus/test/fixtures/lang-resolution/java-interface-default-method/App.java
new file mode 100644
index 000000000..57564f7d2
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/java-interface-default-method/App.java
@@ -0,0 +1,6 @@
+public class App {
+ public static void run() {
+ User user = new User("alice");
+ user.validate();
+ }
+}
diff --git a/gitnexus/test/fixtures/lang-resolution/java-interface-default-method/User.java b/gitnexus/test/fixtures/lang-resolution/java-interface-default-method/User.java
new file mode 100644
index 000000000..ceab78b1c
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/java-interface-default-method/User.java
@@ -0,0 +1,7 @@
+public class User implements Validator {
+ private String name;
+
+ public User(String name) {
+ this.name = name;
+ }
+}
diff --git a/gitnexus/test/fixtures/lang-resolution/java-interface-default-method/Validator.java b/gitnexus/test/fixtures/lang-resolution/java-interface-default-method/Validator.java
new file mode 100644
index 000000000..40796bc53
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/java-interface-default-method/Validator.java
@@ -0,0 +1,5 @@
+public interface Validator {
+ default boolean validate() {
+ return true;
+ }
+}
diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-interface-default-method/src/App.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-interface-default-method/src/App.kt
new file mode 100644
index 000000000..42e0897c7
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/kotlin-interface-default-method/src/App.kt
@@ -0,0 +1,6 @@
+package example
+
+fun run() {
+ val user = User("alice")
+ user.validate()
+}
diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-interface-default-method/src/User.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-interface-default-method/src/User.kt
new file mode 100644
index 000000000..279e5a6fa
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/kotlin-interface-default-method/src/User.kt
@@ -0,0 +1,3 @@
+package example
+
+class User(val name: String) : Validator
diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-interface-default-method/src/Validator.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-interface-default-method/src/Validator.kt
new file mode 100644
index 000000000..7ddd1631f
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/kotlin-interface-default-method/src/Validator.kt
@@ -0,0 +1,5 @@
+package example
+
+interface Validator {
+ fun validate(): Boolean = true
+}
diff --git a/gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/App.php b/gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/App.php
new file mode 100644
index 000000000..71ddc605a
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/App.php
@@ -0,0 +1,14 @@
+parentMethod();
+ }
+}
diff --git a/gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/Child.php b/gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/Child.php
new file mode 100644
index 000000000..a031de193
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/Child.php
@@ -0,0 +1,7 @@
+ None:
+ c = Child()
+ c.gp_method()
diff --git a/gitnexus/test/fixtures/lang-resolution/python-multi-level-mro/child.py b/gitnexus/test/fixtures/lang-resolution/python-multi-level-mro/child.py
new file mode 100644
index 000000000..be1852fa6
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/python-multi-level-mro/child.py
@@ -0,0 +1,5 @@
+from parent import Parent
+
+
+class Child(Parent):
+ pass
diff --git a/gitnexus/test/fixtures/lang-resolution/python-multi-level-mro/grandparent.py b/gitnexus/test/fixtures/lang-resolution/python-multi-level-mro/grandparent.py
new file mode 100644
index 000000000..675d7a412
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/python-multi-level-mro/grandparent.py
@@ -0,0 +1,3 @@
+class Grandparent:
+ def gp_method(self) -> str:
+ return "grandparent"
diff --git a/gitnexus/test/fixtures/lang-resolution/python-multi-level-mro/parent.py b/gitnexus/test/fixtures/lang-resolution/python-multi-level-mro/parent.py
new file mode 100644
index 000000000..6a24add39
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/python-multi-level-mro/parent.py
@@ -0,0 +1,5 @@
+from grandparent import Grandparent
+
+
+class Parent(Grandparent):
+ pass
diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-child-extends-parent/lib/app.rb b/gitnexus/test/fixtures/lang-resolution/ruby-child-extends-parent/lib/app.rb
new file mode 100644
index 000000000..f32b4d8f7
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/ruby-child-extends-parent/lib/app.rb
@@ -0,0 +1,8 @@
+require_relative 'child'
+
+class App
+ def run
+ c = Child.new
+ c.parent_method
+ end
+end
diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-child-extends-parent/lib/child.rb b/gitnexus/test/fixtures/lang-resolution/ruby-child-extends-parent/lib/child.rb
new file mode 100644
index 000000000..1236fb070
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/ruby-child-extends-parent/lib/child.rb
@@ -0,0 +1,4 @@
+require_relative 'parent'
+
+class Child < Parent
+end
diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-child-extends-parent/lib/parent.rb b/gitnexus/test/fixtures/lang-resolution/ruby-child-extends-parent/lib/parent.rb
new file mode 100644
index 000000000..947568ab3
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/ruby-child-extends-parent/lib/parent.rb
@@ -0,0 +1,5 @@
+class Parent
+ def parent_method
+ "parent"
+ end
+end
diff --git a/gitnexus/test/fixtures/lang-resolution/rust-child-extends-parent/src/child.rs b/gitnexus/test/fixtures/lang-resolution/rust-child-extends-parent/src/child.rs
new file mode 100644
index 000000000..1e9755c90
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/rust-child-extends-parent/src/child.rs
@@ -0,0 +1,16 @@
+use crate::parent::Parent;
+
+pub struct Child;
+
+impl Child {
+ // Direct impl method — MUST resolve via resolveMemberCall owner-scoped path.
+ pub fn own_method(&self) -> &str {
+ "child-own"
+ }
+}
+
+// Trait implementation — `trait_only` is provided by the trait's default impl
+// but is NOT reachable via direct `obj.trait_only()` in Rust without the trait
+// being in scope. The resolver correctly treats qualified-syntax MRO as opaque
+// to direct member calls.
+impl Parent for Child {}
diff --git a/gitnexus/test/fixtures/lang-resolution/rust-child-extends-parent/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-child-extends-parent/src/main.rs
new file mode 100644
index 000000000..bac6fd160
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/rust-child-extends-parent/src/main.rs
@@ -0,0 +1,17 @@
+mod child;
+mod parent;
+
+use crate::child::Child;
+
+fn run() {
+ let c = Child;
+ // Direct impl method — SHOULD resolve to Child::own_method.
+ c.own_method();
+ // Trait-inherited default — direct member-call SHOULD NOT resolve to
+ // Parent::trait_only under Rust's qualified-syntax MRO strategy.
+ c.trait_only();
+}
+
+fn main() {
+ run();
+}
diff --git a/gitnexus/test/fixtures/lang-resolution/rust-child-extends-parent/src/parent.rs b/gitnexus/test/fixtures/lang-resolution/rust-child-extends-parent/src/parent.rs
new file mode 100644
index 000000000..e14a6f86c
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/rust-child-extends-parent/src/parent.rs
@@ -0,0 +1,11 @@
+// Trait "parent" — methods on a Rust trait are NOT reachable via direct
+// `obj.method()` syntax on structs that implement the trait unless the trait
+// itself is in scope. Our qualified-syntax MRO strategy reflects this: direct
+// member calls do NOT walk trait ancestry, so `c.trait_only()` below should
+// produce NO CALLS edge to `Parent::trait_only`.
+
+pub trait Parent {
+ fn trait_only(&self) -> &str {
+ "parent-default"
+ }
+}
diff --git a/gitnexus/test/fixtures/lang-resolution/swift-child-extends-parent/Sources/App.swift b/gitnexus/test/fixtures/lang-resolution/swift-child-extends-parent/Sources/App.swift
new file mode 100644
index 000000000..08a19033b
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/swift-child-extends-parent/Sources/App.swift
@@ -0,0 +1,6 @@
+class App {
+ func run() {
+ let c = Child()
+ c.parentMethod()
+ }
+}
diff --git a/gitnexus/test/fixtures/lang-resolution/swift-child-extends-parent/Sources/Child.swift b/gitnexus/test/fixtures/lang-resolution/swift-child-extends-parent/Sources/Child.swift
new file mode 100644
index 000000000..3dc59641b
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/swift-child-extends-parent/Sources/Child.swift
@@ -0,0 +1,2 @@
+class Child: Parent {
+}
diff --git a/gitnexus/test/fixtures/lang-resolution/swift-child-extends-parent/Sources/Parent.swift b/gitnexus/test/fixtures/lang-resolution/swift-child-extends-parent/Sources/Parent.swift
new file mode 100644
index 000000000..ba9d7b2bd
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/swift-child-extends-parent/Sources/Parent.swift
@@ -0,0 +1,5 @@
+class Parent {
+ func parentMethod() -> String {
+ return "parent"
+ }
+}
diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-constructor-type-inference/src/app.ts b/gitnexus/test/fixtures/lang-resolution/typescript-constructor-type-inference/src/app.ts
index 33433b777..14add7d54 100644
--- a/gitnexus/test/fixtures/lang-resolution/typescript-constructor-type-inference/src/app.ts
+++ b/gitnexus/test/fixtures/lang-resolution/typescript-constructor-type-inference/src/app.ts
@@ -2,8 +2,8 @@ import { User } from './user';
import { Repo } from './repo';
export function processEntities(): void {
- const user = new User();
- const repo = new Repo();
+ const user = new User('alice');
+ const repo = new Repo('/tmp/repo');
user.save();
repo.save();
}
diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-constructor-type-inference/src/repo.ts b/gitnexus/test/fixtures/lang-resolution/typescript-constructor-type-inference/src/repo.ts
index 19631246b..671de6458 100644
--- a/gitnexus/test/fixtures/lang-resolution/typescript-constructor-type-inference/src/repo.ts
+++ b/gitnexus/test/fixtures/lang-resolution/typescript-constructor-type-inference/src/repo.ts
@@ -1,5 +1,7 @@
export class Repo {
+ constructor(private readonly path: string) {}
+
save(): boolean {
- return false;
+ return this.path.length > 0;
}
}
diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-constructor-type-inference/src/user.ts b/gitnexus/test/fixtures/lang-resolution/typescript-constructor-type-inference/src/user.ts
index e2af97ca7..f20c2200f 100644
--- a/gitnexus/test/fixtures/lang-resolution/typescript-constructor-type-inference/src/user.ts
+++ b/gitnexus/test/fixtures/lang-resolution/typescript-constructor-type-inference/src/user.ts
@@ -1,5 +1,7 @@
export class User {
+ constructor(private readonly name: string) {}
+
save(): boolean {
- return true;
+ return this.name.length > 0;
}
}
diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts
index 630c86696..0839d59f0 100644
--- a/gitnexus/test/integration/resolvers/cpp.test.ts
+++ b/gitnexus/test/integration/resolvers/cpp.test.ts
@@ -1548,3 +1548,37 @@ describe('C++ Child extends Parent — inherited method resolution (SM-9)', () =
expect(parentMethodCall!.source).toBe('run');
});
});
+
+describe('C++ Derived : A, B — diamond inheritance via leftmost-base MRO (SM-11)', () => {
+ let result: PipelineResult;
+
+ beforeAll(async () => {
+ result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-diamond-inheritance'), () => {});
+ }, 60000);
+
+ it('detects Base, A, B, and Derived classes', () => {
+ const classes = getNodesByLabel(result, 'Class');
+ expect(classes).toContain('Base');
+ expect(classes).toContain('A');
+ expect(classes).toContain('B');
+ expect(classes).toContain('Derived');
+ });
+
+ it('emits EXTENDS edges for both branches: A → Base, B → Base, Derived → A, Derived → B', () => {
+ const extends_ = getRelationships(result, 'EXTENDS');
+ const edges = edgeSet(extends_);
+ expect(edges).toContain('A → Base');
+ expect(edges).toContain('B → Base');
+ expect(edges).toContain('Derived → A');
+ expect(edges).toContain('Derived → B');
+ });
+
+ it('resolves d.method() to Base::method via leftmost-base MRO walk', () => {
+ const calls = getRelationships(result, 'CALLS');
+ const methodCall = calls.find(
+ (c) => c.target === 'method' && c.targetFilePath.includes('Base.h'),
+ );
+ expect(methodCall).toBeDefined();
+ expect(methodCall!.source).toBe('run');
+ });
+});
diff --git a/gitnexus/test/integration/resolvers/csharp.test.ts b/gitnexus/test/integration/resolvers/csharp.test.ts
index 5db630bf2..af2dbb786 100644
--- a/gitnexus/test/integration/resolvers/csharp.test.ts
+++ b/gitnexus/test/integration/resolvers/csharp.test.ts
@@ -1963,3 +1963,37 @@ describe('C# Child extends Parent — inherited method resolution (SM-9)', () =>
expect(parentMethodCall!.source).toBe('Run');
});
});
+
+// ---------------------------------------------------------------------------
+// SM-11: C# User : IValidator — interface default method via implements-split
+// ---------------------------------------------------------------------------
+
+describe('C# User implements IValidator — interface default method (SM-11)', () => {
+ let result: PipelineResult;
+
+ beforeAll(async () => {
+ result = await runPipelineFromRepo(
+ path.join(FIXTURES, 'csharp-interface-default-method'),
+ () => {},
+ );
+ }, 60000);
+
+ it('detects IValidator interface and User class', () => {
+ expect(getNodesByLabel(result, 'Interface')).toContain('IValidator');
+ expect(getNodesByLabel(result, 'Class')).toContain('User');
+ });
+
+ it('emits IMPLEMENTS edge: User → IValidator', () => {
+ const impls = getRelationships(result, 'IMPLEMENTS');
+ expect(edgeSet(impls)).toContain('User → IValidator');
+ });
+
+ it('resolves user.Validate() to IValidator.Validate via implements-split MRO', () => {
+ const calls = getRelationships(result, 'CALLS');
+ const validateCall = calls.find(
+ (c) => c.target === 'Validate' && c.targetFilePath.includes('Validator.cs'),
+ );
+ expect(validateCall).toBeDefined();
+ expect(validateCall!.source).toBe('Run');
+ });
+});
diff --git a/gitnexus/test/integration/resolvers/dart.test.ts b/gitnexus/test/integration/resolvers/dart.test.ts
index 7f995e09d..82fa77f22 100644
--- a/gitnexus/test/integration/resolvers/dart.test.ts
+++ b/gitnexus/test/integration/resolvers/dart.test.ts
@@ -474,3 +474,41 @@ describe.skipIf(!dartAvailable)('Dart interface dispatch (METHOD_IMPLEMENTS)', (
expect(saveEdge).toBeDefined();
});
});
+
+// ---------------------------------------------------------------------------
+// SM-9/SM-10: lookupMethodByOwnerWithMRO + D0 fast path — Dart first-wins
+// ---------------------------------------------------------------------------
+
+describe.skipIf(!dartAvailable)(
+ 'Dart Child extends Parent — inherited method resolution (SM-9)',
+ () => {
+ let result: PipelineResult;
+
+ beforeAll(async () => {
+ result = await runPipelineFromRepo(
+ path.join(FIXTURES, 'dart-child-extends-parent'),
+ () => {},
+ );
+ }, 60000);
+
+ it('detects Parent and Child classes', () => {
+ const classes = getNodesByLabel(result, 'Class');
+ expect(classes).toContain('Parent');
+ expect(classes).toContain('Child');
+ });
+
+ it('emits EXTENDS edge: Child → Parent', () => {
+ const extends_ = getRelationships(result, 'EXTENDS');
+ expect(edgeSet(extends_)).toContain('Child → Parent');
+ });
+
+ it('resolves c.parentMethod() to Parent.parentMethod via first-wins MRO walk', () => {
+ const calls = getRelationships(result, 'CALLS');
+ const parentMethodCall = calls.find(
+ (c) => c.target === 'parentMethod' && c.targetFilePath.includes('parent.dart'),
+ );
+ expect(parentMethodCall).toBeDefined();
+ expect(parentMethodCall!.source).toBe('run');
+ });
+ },
+);
diff --git a/gitnexus/test/integration/resolvers/go.test.ts b/gitnexus/test/integration/resolvers/go.test.ts
index 0ec9b2f6c..69761f889 100644
--- a/gitnexus/test/integration/resolvers/go.test.ts
+++ b/gitnexus/test/integration/resolvers/go.test.ts
@@ -1345,3 +1345,35 @@ describe('Go method enrichment', () => {
expect(classifyCall).toBeDefined();
});
});
+
+// ---------------------------------------------------------------------------
+// SM-9/SM-10: lookupMethodByOwnerWithMRO + D0 fast path — Go struct embedding
+// ---------------------------------------------------------------------------
+
+describe('Go Child embeds Parent — inherited method resolution (SM-9)', () => {
+ let result: PipelineResult;
+
+ beforeAll(async () => {
+ result = await runPipelineFromRepo(path.join(FIXTURES, 'go-child-extends-parent'), () => {});
+ }, 60000);
+
+ it('detects Parent and Child structs', () => {
+ const structs = getNodesByLabel(result, 'Struct');
+ expect(structs).toContain('Parent');
+ expect(structs).toContain('Child');
+ });
+
+ it('emits EXTENDS edge: Child → Parent (struct embedding)', () => {
+ const extends_ = getRelationships(result, 'EXTENDS');
+ expect(edgeSet(extends_)).toContain('Child → Parent');
+ });
+
+ it('resolves c.ParentMethod() to Parent.ParentMethod via first-wins MRO walk', () => {
+ const calls = getRelationships(result, 'CALLS');
+ const parentMethodCall = calls.find(
+ (c) => c.target === 'ParentMethod' && c.targetFilePath.includes('parent.go'),
+ );
+ expect(parentMethodCall).toBeDefined();
+ expect(parentMethodCall!.source).toBe('Run');
+ });
+});
diff --git a/gitnexus/test/integration/resolvers/java.test.ts b/gitnexus/test/integration/resolvers/java.test.ts
index 8a09030ed..d9bae90bf 100644
--- a/gitnexus/test/integration/resolvers/java.test.ts
+++ b/gitnexus/test/integration/resolvers/java.test.ts
@@ -2133,3 +2133,37 @@ describe('Java Child extends Parent — inherited method resolution (SM-9)', ()
expect(parentMethodCall!.source).toBe('run');
});
});
+
+// ---------------------------------------------------------------------------
+// SM-11: Java User implements Validator — interface default method (Java 8+)
+// ---------------------------------------------------------------------------
+
+describe('Java User implements Validator — interface default method (SM-11)', () => {
+ let result: PipelineResult;
+
+ beforeAll(async () => {
+ result = await runPipelineFromRepo(
+ path.join(FIXTURES, 'java-interface-default-method'),
+ () => {},
+ );
+ }, 60000);
+
+ it('detects Validator interface and User class', () => {
+ expect(getNodesByLabel(result, 'Interface')).toContain('Validator');
+ expect(getNodesByLabel(result, 'Class')).toContain('User');
+ });
+
+ it('emits IMPLEMENTS edge: User → Validator', () => {
+ const impls = getRelationships(result, 'IMPLEMENTS');
+ expect(edgeSet(impls)).toContain('User → Validator');
+ });
+
+ it('resolves user.validate() to Validator.validate via implements-split MRO', () => {
+ const calls = getRelationships(result, 'CALLS');
+ const validateCall = calls.find(
+ (c) => c.target === 'validate' && c.targetFilePath.includes('Validator.java'),
+ );
+ expect(validateCall).toBeDefined();
+ expect(validateCall!.source).toBe('run');
+ });
+});
diff --git a/gitnexus/test/integration/resolvers/kotlin.test.ts b/gitnexus/test/integration/resolvers/kotlin.test.ts
index 1163538e4..056a2514a 100644
--- a/gitnexus/test/integration/resolvers/kotlin.test.ts
+++ b/gitnexus/test/integration/resolvers/kotlin.test.ts
@@ -2026,3 +2026,37 @@ describe('Kotlin Child extends Parent — inherited method resolution (SM-9)', (
expect(parentMethodCall!.source).toBe('run');
});
});
+
+// ---------------------------------------------------------------------------
+// SM-11: Kotlin User : Validator — interface default method via implements-split
+// ---------------------------------------------------------------------------
+
+describe('Kotlin User implements Validator — interface default method (SM-11)', () => {
+ let result: PipelineResult;
+
+ beforeAll(async () => {
+ result = await runPipelineFromRepo(
+ path.join(FIXTURES, 'kotlin-interface-default-method'),
+ () => {},
+ );
+ }, 60000);
+
+ it('detects Validator interface and User class', () => {
+ expect(getNodesByLabel(result, 'Interface')).toContain('Validator');
+ expect(getNodesByLabel(result, 'Class')).toContain('User');
+ });
+
+ it('emits IMPLEMENTS edge: User → Validator', () => {
+ const impls = getRelationships(result, 'IMPLEMENTS');
+ expect(edgeSet(impls)).toContain('User → Validator');
+ });
+
+ it('resolves user.validate() to Validator.validate via implements-split MRO', () => {
+ const calls = getRelationships(result, 'CALLS');
+ const validateCall = calls.find(
+ (c) => c.target === 'validate' && c.targetFilePath.includes('Validator.kt'),
+ );
+ expect(validateCall).toBeDefined();
+ expect(validateCall!.source).toBe('run');
+ });
+});
diff --git a/gitnexus/test/integration/resolvers/php.test.ts b/gitnexus/test/integration/resolvers/php.test.ts
index a72da9439..e120d92f4 100644
--- a/gitnexus/test/integration/resolvers/php.test.ts
+++ b/gitnexus/test/integration/resolvers/php.test.ts
@@ -1780,3 +1780,30 @@ describe('PHP abstract dispatch', () => {
expect(names).toEqual(['find', 'save']);
});
});
+
+// ---------------------------------------------------------------------------
+// SM-9/SM-10: lookupMethodByOwnerWithMRO + D0 fast path — PHP first-wins
+// ---------------------------------------------------------------------------
+
+describe('PHP Child extends ParentClass — inherited method resolution (SM-9)', () => {
+ let result: PipelineResult;
+
+ beforeAll(async () => {
+ result = await runPipelineFromRepo(path.join(FIXTURES, 'php-child-extends-parent'), () => {});
+ }, 60000);
+
+ it('detects ParentClass and Child classes', () => {
+ const classes = getNodesByLabel(result, 'Class');
+ expect(classes).toContain('ParentClass');
+ expect(classes).toContain('Child');
+ });
+
+ it('resolves $c->parentMethod() to ParentClass::parentMethod via first-wins MRO walk', () => {
+ const calls = getRelationships(result, 'CALLS');
+ const parentMethodCall = calls.find(
+ (c) => c.target === 'parentMethod' && c.targetFilePath.includes('Parent.php'),
+ );
+ expect(parentMethodCall).toBeDefined();
+ expect(parentMethodCall!.source).toBe('run');
+ });
+});
diff --git a/gitnexus/test/integration/resolvers/python.test.ts b/gitnexus/test/integration/resolvers/python.test.ts
index f2219463e..b7d60b20e 100644
--- a/gitnexus/test/integration/resolvers/python.test.ts
+++ b/gitnexus/test/integration/resolvers/python.test.ts
@@ -2146,3 +2146,33 @@ describe('Python Child extends Parent — inherited method resolution (SM-9)', (
expect(parentMethodCall!.source).toBe('run');
});
});
+
+describe('Python Grandchild→Child→Parent — 3-level C3 MRO walk (SM-11)', () => {
+ let result: PipelineResult;
+
+ beforeAll(async () => {
+ result = await runPipelineFromRepo(path.join(FIXTURES, 'python-multi-level-mro'), () => {});
+ }, 60000);
+
+ it('detects Grandparent, Parent, and Child classes', () => {
+ const classes = getNodesByLabel(result, 'Class');
+ expect(classes).toContain('Grandparent');
+ expect(classes).toContain('Parent');
+ expect(classes).toContain('Child');
+ });
+
+ it('emits EXTENDS chain: Child → Parent, Parent → Grandparent', () => {
+ const extends_ = getRelationships(result, 'EXTENDS');
+ expect(edgeSet(extends_)).toContain('Child → Parent');
+ expect(edgeSet(extends_)).toContain('Parent → Grandparent');
+ });
+
+ it('resolves c.gp_method() to Grandparent.gp_method via 3-level C3 walk', () => {
+ const calls = getRelationships(result, 'CALLS');
+ const gpCall = calls.find(
+ (c) => c.target === 'gp_method' && c.targetFilePath.includes('grandparent.py'),
+ );
+ expect(gpCall).toBeDefined();
+ expect(gpCall!.source).toBe('run');
+ });
+});
diff --git a/gitnexus/test/integration/resolvers/ruby.test.ts b/gitnexus/test/integration/resolvers/ruby.test.ts
index 865c41c4a..7e508e756 100644
--- a/gitnexus/test/integration/resolvers/ruby.test.ts
+++ b/gitnexus/test/integration/resolvers/ruby.test.ts
@@ -1330,3 +1330,30 @@ describe('Ruby overload dispatch (format vs format_with_prefix)', () => {
expect(methods).toContain('run');
});
});
+
+// ---------------------------------------------------------------------------
+// SM-9/SM-10: lookupMethodByOwnerWithMRO + D0 fast path — Ruby first-wins
+// ---------------------------------------------------------------------------
+
+describe('Ruby Child extends Parent — inherited method resolution (SM-9)', () => {
+ let result: PipelineResult;
+
+ beforeAll(async () => {
+ result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-child-extends-parent'), () => {});
+ }, 60000);
+
+ it('detects Parent and Child classes', () => {
+ const classes = getNodesByLabel(result, 'Class');
+ expect(classes).toContain('Parent');
+ expect(classes).toContain('Child');
+ });
+
+ it('resolves c.parent_method to Parent#parent_method via first-wins MRO walk', () => {
+ const calls = getRelationships(result, 'CALLS');
+ const parentMethodCall = calls.find(
+ (c) => c.target === 'parent_method' && c.targetFilePath.includes('parent.rb'),
+ );
+ expect(parentMethodCall).toBeDefined();
+ expect(parentMethodCall!.source).toBe('run');
+ });
+});
diff --git a/gitnexus/test/integration/resolvers/rust.test.ts b/gitnexus/test/integration/resolvers/rust.test.ts
index 496f15e9b..5e03da39b 100644
--- a/gitnexus/test/integration/resolvers/rust.test.ts
+++ b/gitnexus/test/integration/resolvers/rust.test.ts
@@ -1857,3 +1857,66 @@ describe('Rust abstract dispatch (Repository trait)', () => {
expect(names).toEqual(['find', 'save']);
});
});
+
+// ---------------------------------------------------------------------------
+// SM-11: Rust Child extends Parent — qualified-syntax MRO
+//
+// Companion integration test for the unit-level Rust qualified-syntax tests
+// in symbol-table.test.ts. Validates end-to-end that:
+//
+// 1. Direct `impl` methods on a struct resolve through the D0 owner-scoped
+// path (`resolveMemberCall`) — the positive control.
+//
+// 2. Trait-inherited default methods are NOT reachable via direct
+// `obj.trait_method()` syntax. Rust requires the trait to be in scope
+// and uses qualified syntax for trait dispatch; the resolver correctly
+// treats direct member calls as opaque to trait ancestry.
+//
+// Previously this case emitted a false-positive CALLS edge via the
+// permissive tail-return in resolveCallTarget — Codex review finding
+// R3 (PR #744). The tail-return is now null-routed when D1-D4 receiver
+// filtering produces zero matches on both file and owner dimensions.
+// ---------------------------------------------------------------------------
+
+describe('Rust Child extends Parent — qualified-syntax MRO (SM-11)', () => {
+ let result: PipelineResult;
+
+ beforeAll(async () => {
+ result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-child-extends-parent'), () => {});
+ }, 60000);
+
+ it('detects Child struct and Parent trait', () => {
+ const structs = getNodesByLabel(result, 'Struct');
+ expect(structs).toContain('Child');
+ const traits = getNodesByLabel(result, 'Trait');
+ expect(traits).toContain('Parent');
+ });
+
+ it('resolves c.own_method() to Child::own_method via D0 owner-scoped path', () => {
+ // Direct impl method — D0 short-circuits to lookupMethodByOwner which
+ // returns Child::own_method without falling through to D1-D4 fuzzy.
+ const calls = getRelationships(result, 'CALLS');
+ const ownCall = calls.find(
+ (c) =>
+ c.target === 'own_method' && c.source === 'run' && c.targetFilePath.includes('child.rs'),
+ );
+ expect(ownCall).toBeDefined();
+ });
+
+ it('does NOT resolve c.trait_only() to Parent::trait_only via direct member call', () => {
+ // Qualified-syntax MRO: direct member calls on structs do not walk trait
+ // ancestry. `c.trait_only()` must null-route because `trait_only` is
+ // defined on the trait, not on the Child struct.
+ //
+ // The resolveCallTarget tail-return tightening (R3) is what makes this
+ // assertion testable: before the fix, resolveCallTarget would fall
+ // through D1-D4 (zero file matches, zero owner matches) and silently
+ // pick the single fuzzy candidate as a false-positive edge.
+ const calls = getRelationships(result, 'CALLS');
+ const traitCall = calls.find(
+ (c) =>
+ c.target === 'trait_only' && c.source === 'run' && c.targetFilePath.includes('parent.rs'),
+ );
+ expect(traitCall).toBeUndefined();
+ });
+});
diff --git a/gitnexus/test/integration/resolvers/swift.test.ts b/gitnexus/test/integration/resolvers/swift.test.ts
index 272770527..f00435165 100644
--- a/gitnexus/test/integration/resolvers/swift.test.ts
+++ b/gitnexus/test/integration/resolvers/swift.test.ts
@@ -865,3 +865,36 @@ describe.skipIf(!swiftAvailable)('Swift overloaded method disambiguation', () =>
expect(mi.length).toBe(3);
});
});
+
+// ---------------------------------------------------------------------------
+// SM-9/SM-10: lookupMethodByOwnerWithMRO + D0 fast path — Swift first-wins
+// ---------------------------------------------------------------------------
+
+describe.skipIf(!swiftAvailable)(
+ 'Swift Child extends Parent — inherited method resolution (SM-9)',
+ () => {
+ let result: PipelineResult;
+
+ beforeAll(async () => {
+ result = await runPipelineFromRepo(
+ path.join(FIXTURES, 'swift-child-extends-parent'),
+ () => {},
+ );
+ }, 60000);
+
+ it('detects Parent and Child classes', () => {
+ const classes = getNodesByLabel(result, 'Class');
+ expect(classes).toContain('Parent');
+ expect(classes).toContain('Child');
+ });
+
+ it('resolves c.parentMethod() to Parent.parentMethod via first-wins MRO walk', () => {
+ const calls = getRelationships(result, 'CALLS');
+ const parentMethodCall = calls.find(
+ (c) => c.target === 'parentMethod' && c.targetFilePath.includes('Parent.swift'),
+ );
+ expect(parentMethodCall).toBeDefined();
+ expect(parentMethodCall!.source).toBe('run');
+ });
+ },
+);
diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts
index 53c2287c5..c883678ac 100644
--- a/gitnexus/test/integration/resolvers/typescript.test.ts
+++ b/gitnexus/test/integration/resolvers/typescript.test.ts
@@ -519,6 +519,16 @@ describe('TypeScript constructor-inferred type resolution', () => {
expect(saveMethods.length).toBe(2);
});
+ it('resolves explicit constructor calls for User and Repo', () => {
+ const calls = getRelationships(result, 'CALLS');
+ const userCtor = calls.find((c) => c.target === 'User' && c.targetFilePath === 'src/user.ts');
+ const repoCtor = calls.find((c) => c.target === 'Repo' && c.targetFilePath === 'src/repo.ts');
+ expect(userCtor).toBeDefined();
+ expect(repoCtor).toBeDefined();
+ expect(userCtor!.targetLabel).toBe('Class');
+ expect(repoCtor!.targetLabel).toBe('Class');
+ });
+
it('resolves user.save() to src/user.ts via constructor-inferred type', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find((c) => c.target === 'save' && c.targetFilePath === 'src/user.ts');
@@ -538,6 +548,14 @@ describe('TypeScript constructor-inferred type resolution', () => {
const saveCalls = calls.filter((c) => c.target === 'save');
expect(saveCalls.length).toBe(2);
});
+
+ it('resolves constructor calls for both User and Repo', () => {
+ const calls = getRelationships(result, 'CALLS');
+ const userCtor = calls.find((c) => c.target === 'User');
+ const repoCtor = calls.find((c) => c.target === 'Repo');
+ expect(userCtor).toBeDefined();
+ expect(repoCtor).toBeDefined();
+ });
});
// ---------------------------------------------------------------------------
diff --git a/gitnexus/test/unit/api-graph-streaming.test.ts b/gitnexus/test/unit/api-graph-streaming.test.ts
new file mode 100644
index 000000000..0cc69833c
--- /dev/null
+++ b/gitnexus/test/unit/api-graph-streaming.test.ts
@@ -0,0 +1,235 @@
+import { EventEmitter } from 'node:events';
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+
+const { lbugMocks } = vi.hoisted(() => ({
+ lbugMocks: {
+ streamQuery: vi.fn(),
+ },
+}));
+
+vi.mock('../../src/core/lbug/lbug-adapter.js', async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, ...lbugMocks };
+});
+
+import { ClientDisconnectedError, streamGraphNdjson } from '../../src/server/api.js';
+
+const createMockResponse = (writeImpl?: (chunk: string) => boolean) => {
+ const response = new EventEmitter() as any;
+ response.writableEnded = false;
+ response.destroyed = false;
+ response.write = vi.fn((chunk: string) => (writeImpl ? writeImpl(chunk) : true));
+ return response;
+};
+
+describe('streamGraphNdjson', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('waits for drain when writes hit backpressure', async () => {
+ lbugMocks.streamQuery.mockImplementation(
+ async (query: string, onRow: (row: any) => Promise) => {
+ if (query.includes('MATCH (n:`File`)')) {
+ await onRow({ id: 'File:src/app.ts', name: 'app.ts', filePath: 'src/app.ts' });
+ return 1;
+ }
+ if (query.includes('CodeRelation')) {
+ await onRow({
+ sourceId: 'File:src/app.ts',
+ targetId: 'Function:src/app.ts:main',
+ type: 'CONTAINS',
+ });
+ return 1;
+ }
+ return 0;
+ },
+ );
+
+ const writes: string[] = [];
+ let firstWrite = true;
+ const response = createMockResponse((chunk) => {
+ writes.push(chunk);
+ if (firstWrite) {
+ firstWrite = false;
+ return false;
+ }
+ return true;
+ });
+
+ let settled = false;
+ const pending = streamGraphNdjson(response, false).then(() => {
+ settled = true;
+ });
+
+ await Promise.resolve();
+ expect(writes).toHaveLength(1);
+ expect(settled).toBe(false);
+
+ response.emit('drain');
+ await pending;
+
+ expect(writes).toHaveLength(2);
+ });
+
+ it('stops streaming when the client disconnects', async () => {
+ const controller = new AbortController();
+ lbugMocks.streamQuery.mockImplementation(
+ async (query: string, onRow: (row: any) => Promise) => {
+ if (!query.includes('MATCH (n:`File`)')) {
+ return 0;
+ }
+ await onRow({ id: 'File:src/app.ts', name: 'app.ts', filePath: 'src/app.ts' });
+ controller.abort();
+ await onRow({ id: 'File:src/other.ts', name: 'other.ts', filePath: 'src/other.ts' });
+ return 2;
+ },
+ );
+
+ const response = createMockResponse();
+
+ await expect(streamGraphNdjson(response, false, controller.signal)).rejects.toBeInstanceOf(
+ ClientDisconnectedError,
+ );
+ expect(response.write).toHaveBeenCalledTimes(1);
+ });
+
+ it('rethrows non-missing table errors', async () => {
+ lbugMocks.streamQuery.mockImplementation(async (query: string) => {
+ if (query.includes('MATCH (n:`File`)')) {
+ throw new Error('database unavailable');
+ }
+ return 0;
+ });
+
+ const response = createMockResponse();
+ await expect(streamGraphNdjson(response, false)).rejects.toThrow('database unavailable');
+ });
+
+ it('ignores missing-table errors while continuing the stream', async () => {
+ lbugMocks.streamQuery.mockImplementation(
+ async (query: string, onRow: (row: any) => Promise) => {
+ if (query.includes('MATCH (n:`File`)')) {
+ throw new Error('Table File does not exist');
+ }
+ if (query.includes('CodeRelation')) {
+ await onRow({
+ sourceId: 'File:src/app.ts',
+ targetId: 'Function:src/app.ts:main',
+ type: 'CONTAINS',
+ });
+ return 1;
+ }
+ return 0;
+ },
+ );
+
+ const response = createMockResponse();
+ await expect(streamGraphNdjson(response, false)).resolves.toBeUndefined();
+ expect(response.write).toHaveBeenCalledTimes(1);
+ });
+
+ it('quotes node table names in generated Cypher queries', async () => {
+ lbugMocks.streamQuery.mockImplementation(async () => 0);
+
+ const response = createMockResponse();
+ await expect(streamGraphNdjson(response, false)).resolves.toBeUndefined();
+
+ expect(lbugMocks.streamQuery).toHaveBeenCalledWith(
+ expect.stringContaining('MATCH (n:`Macro`)'),
+ expect.any(Function),
+ );
+ });
+
+ it('streams Route and Tool nodes without requiring startLine fields', async () => {
+ lbugMocks.streamQuery.mockImplementation(
+ async (query: string, onRow: (row: any) => Promise) => {
+ if (query.includes('MATCH (n:`Route`)')) {
+ expect(query).not.toContain('startLine');
+ await onRow({
+ id: 'Route:/api/graph:GET',
+ name: 'GET /api/graph',
+ filePath: 'src/server/api.ts',
+ responseKeys: ['nodes', 'relationships'],
+ errorKeys: ['error'],
+ middleware: ['withAuth'],
+ });
+ return 1;
+ }
+ if (query.includes('MATCH (n:`Tool`)')) {
+ expect(query).not.toContain('startLine');
+ await onRow({
+ id: 'Tool:gitnexus_query',
+ name: 'gitnexus_query',
+ filePath: 'src/mcp/resources.ts',
+ description: 'Query the code graph',
+ });
+ return 1;
+ }
+ return 0;
+ },
+ );
+
+ const writes: string[] = [];
+ const response = createMockResponse((chunk) => {
+ writes.push(chunk);
+ return true;
+ });
+
+ await expect(streamGraphNdjson(response, false)).resolves.toBeUndefined();
+
+ const records = writes.map((chunk) => JSON.parse(chunk));
+ expect(records).toContainEqual({
+ type: 'node',
+ data: {
+ id: 'Route:/api/graph:GET',
+ label: 'Route',
+ properties: {
+ name: 'GET /api/graph',
+ filePath: 'src/server/api.ts',
+ startLine: undefined,
+ endLine: undefined,
+ content: undefined,
+ responseKeys: ['nodes', 'relationships'],
+ errorKeys: ['error'],
+ middleware: ['withAuth'],
+ heuristicLabel: undefined,
+ cohesion: undefined,
+ symbolCount: undefined,
+ description: undefined,
+ processType: undefined,
+ stepCount: undefined,
+ communities: undefined,
+ entryPointId: undefined,
+ terminalId: undefined,
+ },
+ },
+ });
+ expect(records).toContainEqual({
+ type: 'node',
+ data: {
+ id: 'Tool:gitnexus_query',
+ label: 'Tool',
+ properties: {
+ name: 'gitnexus_query',
+ filePath: 'src/mcp/resources.ts',
+ startLine: undefined,
+ endLine: undefined,
+ content: undefined,
+ responseKeys: undefined,
+ errorKeys: undefined,
+ middleware: undefined,
+ heuristicLabel: undefined,
+ cohesion: undefined,
+ symbolCount: undefined,
+ description: 'Query the code graph',
+ processType: undefined,
+ stepCount: undefined,
+ communities: undefined,
+ entryPointId: undefined,
+ terminalId: undefined,
+ },
+ },
+ });
+ });
+});
diff --git a/gitnexus/test/unit/call-processor.test.ts b/gitnexus/test/unit/call-processor.test.ts
index bb4590d5a..b0c2b04eb 100644
--- a/gitnexus/test/unit/call-processor.test.ts
+++ b/gitnexus/test/unit/call-processor.test.ts
@@ -1591,3 +1591,366 @@ describe('processCallsFromExtracted — interface dispatch', () => {
expect(toB?.reason).toBe('interface-dispatch');
});
});
+
+// ---------------------------------------------------------------------------
+// SM-10: D0 MRO fast path in resolveCallTarget
+// ---------------------------------------------------------------------------
+
+describe('processCalls — D0 MRO fast path (SM-10)', () => {
+ let graph: ReturnType;
+ let ctx: ResolutionContext;
+
+ beforeEach(() => {
+ graph = createKnowledgeGraph();
+ ctx = createResolutionContext();
+ });
+
+ const setupChildParent = () => {
+ const parentFile = 'src/models/Parent.java';
+ const childFile = 'src/models/Child.java';
+ const appFile = 'src/services/App.java';
+ const parentId = 'class:models/Parent.java:Parent';
+ const childId = 'class:models/Child.java:Child';
+ const parentMethodId = 'method:models/Parent.java:parentMethod';
+
+ ctx.symbols.add(parentFile, 'Parent', parentId, 'Class');
+ ctx.symbols.add(childFile, 'Child', childId, 'Class');
+ ctx.symbols.add(parentFile, 'parentMethod', parentMethodId, 'Method', {
+ ownerId: parentId,
+ returnType: 'String',
+ });
+ ctx.importMap.set(appFile, new Set([childFile, parentFile]));
+ return { parentFile, childFile, appFile, parentId, childId, parentMethodId };
+ };
+
+ it('D0 hit: child.parentMethod() resolves via MRO walk when heritageMap is provided', async () => {
+ const { parentMethodId, appFile, parentFile, childFile } = setupChildParent();
+
+ const heritage: ExtractedHeritage[] = [
+ {
+ filePath: childFile,
+ className: 'Child',
+ parentName: 'Parent',
+ kind: 'extends',
+ },
+ ];
+ const heritageMap = buildHeritageMap(heritage, ctx);
+
+ await processCalls(
+ graph,
+ [
+ {
+ path: parentFile,
+ content:
+ 'package models;\npublic class Parent {\n public String parentMethod() { return ""; }\n}\n',
+ },
+ {
+ path: childFile,
+ content: 'package models;\npublic class Child extends Parent {}\n',
+ },
+ {
+ path: appFile,
+ content:
+ 'package services;\nimport models.Child;\npublic class App {\n public void run() {\n Child c = new Child();\n c.parentMethod();\n }\n}\n',
+ },
+ ],
+ createASTCache(),
+ ctx,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ heritageMap,
+ );
+
+ const parentMethodCalls = graph.relationships.filter(
+ (r) => r.type === 'CALLS' && r.targetId === parentMethodId,
+ );
+ expect(parentMethodCalls).toHaveLength(1);
+ });
+
+ it('D0 miss: heritageMap provided but method not in MRO chain falls through to D1-D4', async () => {
+ // Setup: Class Obj has a method `doWork` that is findable via tiered
+ // resolution (import-scoped lookup), but intentionally NOT registered in
+ // methodByOwner (no `ownerId` property). heritageMap is provided but has
+ // no ancestry entry for class:Obj. Expected flow:
+ // D0: lookupMethodByOwner(classId, 'doWork') → undefined
+ // heritageMap.getAncestors(classId) → []
+ // lookupMethodByOwnerWithMRO returns undefined → D0 miss
+ // D1-D4: receiver type resolves to Obj; D2 widens via lookupFuzzy;
+ // D3 file-filter picks the only candidate in Obj's file.
+ // Guarantees D0 miss does not swallow the call — D1-D4 still runs.
+ const classFile = 'src/models/Obj.java';
+ const appFile = 'src/services/App.java';
+ const classId = 'class:models/Obj.java:Obj';
+ const doWorkId = 'method:models/Obj.java:doWork';
+
+ ctx.symbols.add(classFile, 'Obj', classId, 'Class');
+ // Intentionally omit ownerId so methodByOwner has no entry — forces D0 miss.
+ ctx.symbols.add(classFile, 'doWork', doWorkId, 'Method', {
+ returnType: 'void',
+ parameterCount: 0,
+ });
+ ctx.importMap.set(appFile, new Set([classFile]));
+
+ // Empty heritage — no ancestry for Obj, so the MRO walk yields no parents.
+ const heritageMap = buildHeritageMap([], ctx);
+
+ const calls: ExtractedCall[] = [
+ {
+ filePath: appFile,
+ calledName: 'doWork',
+ sourceId: 'method:services/App.java:run',
+ argCount: 0,
+ callForm: 'member',
+ receiverTypeName: 'Obj',
+ },
+ ];
+
+ await processCallsFromExtracted(graph, calls, ctx, undefined, undefined, heritageMap);
+
+ const doWorkCalls = graph.relationships.filter(
+ (r) => r.type === 'CALLS' && r.targetId === doWorkId,
+ );
+ expect(doWorkCalls).toHaveLength(1);
+ });
+
+ it('no heritageMap: inherited methods are unresolvable (null-routed, not false-positive)', async () => {
+ // Without a HeritageMap, the resolver cannot know that Parent.parentMethod
+ // belongs to Child's ancestry. The old D1-D4 tail-return would silently
+ // pick the lone fuzzy candidate and emit a CALLS edge — but that was an
+ // accidental match that happened to line up because `parentMethod`
+ // was unique in the global index.
+ //
+ // After the R3 tail-return tightening (PR #744 Codex review), member
+ // calls whose D1-D4 narrowing produces zero file-matched and zero
+ // owner-matched candidates null-route instead of falling through.
+ // The test now asserts the honest answer: without heritage information,
+ // we cannot attribute `c.parentMethod()` to `Parent` and therefore
+ // emit no edge.
+ //
+ // In the real ingestion pipeline, heritageMap is always threaded
+ // through, so this scenario is only reachable in tests that explicitly
+ // omit it. Keeping the test confirms the null-route behavior and
+ // documents the invariant "no heritage → no inherited-method edges".
+ const { parentMethodId, appFile, parentFile, childFile } = setupChildParent();
+
+ await processCalls(
+ graph,
+ [
+ {
+ path: parentFile,
+ content:
+ 'package models;\npublic class Parent {\n public String parentMethod() { return ""; }\n}\n',
+ },
+ {
+ path: childFile,
+ content: 'package models;\npublic class Child extends Parent {}\n',
+ },
+ {
+ path: appFile,
+ content:
+ 'package services;\nimport models.Child;\npublic class App {\n public void run() {\n Child c = new Child();\n c.parentMethod();\n }\n}\n',
+ },
+ ],
+ createASTCache(),
+ ctx,
+ // no heritageMap — D0 MRO walk is unavailable, D1-D4 receiver filtering
+ // also cannot link c.parentMethod() to Parent, so no edge is emitted.
+ );
+
+ const parentMethodCalls = graph.relationships.filter(
+ (r) => r.type === 'CALLS' && r.targetId === parentMethodId,
+ );
+ expect(parentMethodCalls).toHaveLength(0);
+ });
+
+ it('overloadHints guard: D0 skipped so literal-inferred overload disambiguation picks the right overload', async () => {
+ // Java sequential path: processCalls auto-generates `overloadHints` for
+ // languages whose provider exposes `inferLiteralType` (Java/Kotlin/C#/C++).
+ // When two overloads share the same return type, lookupMethodByOwner
+ // returns defs[0] (the first-added overload) regardless of argument
+ // types. Without the D0 guard this would mis-resolve `o.method("hello")`
+ // to method(int). With the guard, D0 is skipped because overloadHints
+ // is present, and the literal-inferred overload path in D2-D4+E picks
+ // method(String) correctly.
+ const classFile = 'src/models/Obj.java';
+ const appFile = 'src/services/App.java';
+ const classId = 'class:models/Obj.java:Obj';
+ const methodIntId = 'method:models/Obj.java:method(int)';
+ const methodStringId = 'method:models/Obj.java:method(String)';
+
+ ctx.symbols.add(classFile, 'Obj', classId, 'Class');
+ // int overload added FIRST so lookupMethodByOwner would return it.
+ ctx.symbols.add(classFile, 'method', methodIntId, 'Method', {
+ ownerId: classId,
+ returnType: 'String',
+ parameterCount: 1,
+ parameterTypes: ['int'],
+ });
+ ctx.symbols.add(classFile, 'method', methodStringId, 'Method', {
+ ownerId: classId,
+ returnType: 'String',
+ parameterCount: 1,
+ parameterTypes: ['String'],
+ });
+ ctx.importMap.set(appFile, new Set([classFile]));
+
+ const heritageMap = buildHeritageMap([], ctx);
+
+ await processCalls(
+ graph,
+ [
+ {
+ path: classFile,
+ content:
+ 'package models;\npublic class Obj {\n public String method(int x) { return ""; }\n public String method(String s) { return ""; }\n}\n',
+ },
+ {
+ path: appFile,
+ content:
+ 'package services;\nimport models.Obj;\npublic class App {\n public void run() {\n Obj o = new Obj();\n o.method("hello");\n }\n}\n',
+ },
+ ],
+ createASTCache(),
+ ctx,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ heritageMap,
+ );
+
+ // Exactly one resolved call, and it must target the String overload.
+ const methodCalls = graph.relationships.filter(
+ (r) => r.type === 'CALLS' && (r.targetId === methodIntId || r.targetId === methodStringId),
+ );
+ expect(methodCalls).toHaveLength(1);
+ expect(methodCalls[0].targetId).toBe(methodStringId);
+ });
+
+ it('preComputedArgTypes guard: D0 skipped so arg-type disambiguation picks the right overload', async () => {
+ // Two overloads of the same method with identical return types live on
+ // the same owner class. Without the D0 guard, lookupMethodByOwner would
+ // return defs[0] (the first overload added) regardless of argument types,
+ // silently mis-resolving an `obj.method("hello")` call to method(int).
+ // With the guard, preComputedArgTypes forces D0 to be skipped and D2-D4+E
+ // disambiguates by parameter type.
+ const classFile = 'src/models/Obj.java';
+ const appFile = 'src/services/App.java';
+ const classId = 'class:models/Obj.java:Obj';
+ const methodIntId = 'method:models/Obj.java:method(int)';
+ const methodStringId = 'method:models/Obj.java:method(String)';
+
+ ctx.symbols.add(classFile, 'Obj', classId, 'Class');
+ // int overload added FIRST — without the guard this would be returned by
+ // lookupMethodByOwner's same-return-type fast path.
+ ctx.symbols.add(classFile, 'method', methodIntId, 'Method', {
+ ownerId: classId,
+ returnType: 'String',
+ parameterCount: 1,
+ parameterTypes: ['int'],
+ });
+ ctx.symbols.add(classFile, 'method', methodStringId, 'Method', {
+ ownerId: classId,
+ returnType: 'String',
+ parameterCount: 1,
+ parameterTypes: ['String'],
+ });
+ ctx.importMap.set(appFile, new Set([classFile]));
+
+ const heritageMap = buildHeritageMap([], ctx);
+
+ const calls: ExtractedCall[] = [
+ {
+ filePath: appFile,
+ calledName: 'method',
+ sourceId: 'method:services/App.java:run',
+ argCount: 1,
+ callForm: 'member',
+ receiverTypeName: 'Obj',
+ argTypes: ['String'],
+ },
+ ];
+
+ await processCallsFromExtracted(graph, calls, ctx, undefined, undefined, heritageMap);
+
+ const methodCalls = graph.relationships.filter((r) => r.type === 'CALLS');
+ // Exactly one resolved call, and it must target the String overload —
+ // NOT the int overload that lookupMethodByOwner would have returned.
+ expect(methodCalls).toHaveLength(1);
+ expect(methodCalls[0].targetId).toBe(methodStringId);
+ });
+
+ it('module-alias guard: D0 skipped when receiverName matches an active module alias', async () => {
+ // Setup: two files each define a class named User with a method save().
+ // The caller has a Python-style module alias `import auth_mod as auth`,
+ // so auth.User().save() must resolve to auth_mod.py, NOT user_mod.py.
+ // D0 would call ctx.resolve('User') and could pick the wrong file; the
+ // alias guard must short-circuit D0 so the alias-filtered D1-D4 path
+ // runs and picks the correct file.
+ const authModFile = 'auth_mod.py';
+ const userModFile = 'user_mod.py';
+ const appFile = 'app.py';
+ const authUserId = 'class:auth_mod.py:User';
+ const userUserId = 'class:user_mod.py:User';
+ const authSaveId = 'method:auth_mod.py:save';
+ const userSaveId = 'method:user_mod.py:save';
+
+ ctx.symbols.add(authModFile, 'User', authUserId, 'Class');
+ ctx.symbols.add(userModFile, 'User', userUserId, 'Class');
+ ctx.symbols.add(authModFile, 'save', authSaveId, 'Method', {
+ ownerId: authUserId,
+ returnType: 'bool',
+ });
+ ctx.symbols.add(userModFile, 'save', userSaveId, 'Method', {
+ ownerId: userUserId,
+ returnType: 'bool',
+ });
+ // Register the module alias: in app.py, `auth` points to auth_mod.py.
+ const aliasMap = new Map([['auth', authModFile]]);
+ ctx.moduleAliasMap.set(appFile, aliasMap);
+ ctx.importMap.set(appFile, new Set([authModFile]));
+
+ const heritageMap = buildHeritageMap([], ctx);
+
+ await processCalls(
+ graph,
+ [
+ {
+ path: authModFile,
+ content: 'class User:\n def save(self):\n return True\n',
+ },
+ {
+ path: userModFile,
+ content: 'class User:\n def save(self):\n return True\n',
+ },
+ {
+ path: appFile,
+ content:
+ 'import auth_mod as auth\n\ndef run():\n user = auth.User()\n user.save()\n',
+ },
+ ],
+ createASTCache(),
+ ctx,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ heritageMap,
+ );
+
+ // save() must resolve to auth_mod.py, NOT user_mod.py.
+ const authSave = graph.relationships.find(
+ (r) => r.type === 'CALLS' && r.targetId === authSaveId,
+ );
+ const userSave = graph.relationships.find(
+ (r) => r.type === 'CALLS' && r.targetId === userSaveId,
+ );
+ expect(authSave).toBeDefined();
+ expect(userSave).toBeUndefined();
+ });
+});
diff --git a/gitnexus/test/unit/symbol-table.test.ts b/gitnexus/test/unit/symbol-table.test.ts
index b2ba7e288..9378a6008 100644
--- a/gitnexus/test/unit/symbol-table.test.ts
+++ b/gitnexus/test/unit/symbol-table.test.ts
@@ -798,8 +798,19 @@ describe('SymbolTable', () => {
expect(table.lookupClassByName('Qux')).toEqual([]);
});
+ it('includes Trait in the class set (PHP use, Rust impl, Scala traits)', () => {
+ // Traits are class-like for heritage resolution — they contribute
+ // methods to the using/implementing type's hierarchy. buildHeritageMap
+ // relies on this to resolve `use Trait;` edges in PHP, `impl Trait for
+ // Struct` in Rust, etc. Added as part of PR #744 (SM-11 Codex review
+ // fixes) after the PHP HasTimestamps trait walk gap was discovered.
+ table.add('src/a.rs', 'Writer', 'trait:Writer', 'Trait');
+ const results = table.lookupClassByName('Writer');
+ expect(results).toHaveLength(1);
+ expect(results[0].nodeId).toBe('trait:Writer');
+ });
+
it('does NOT include other type-like labels outside the allowed class set', () => {
- table.add('src/a.rs', 'User', 'trait:User', 'Trait');
table.add('src/a.ts', 'User', 'type:User', 'Type');
expect(table.lookupClassByName('User')).toEqual([]);
});
@@ -1398,3 +1409,1364 @@ describe('lookupMethodByOwnerWithMRO', () => {
expect(result!.nodeId).toBe('method:User:getName');
});
});
+
+// ---------------------------------------------------------------------------
+// resolveMemberCall — SM-11: owner-scoped + MRO member-call resolution
+// ---------------------------------------------------------------------------
+
+import {
+ _resolveCallTargetForTesting,
+ resolveMemberCall,
+ resolveFreeCall,
+ type OverloadHints,
+} from '../../src/core/ingestion/call-processor.js';
+
+describe('resolveMemberCall', () => {
+ let ctx: ResolutionContext;
+
+ beforeEach(() => {
+ ctx = createResolutionContext();
+ });
+
+ it('resolves direct method on owner type', () => {
+ ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
+ ctx.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', {
+ returnType: 'void',
+ ownerId: 'class:User',
+ });
+ ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
+
+ const result = resolveMemberCall('User', 'save', 'src/app.ts', ctx);
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('method:User:save');
+ expect(result!.returnType).toBe('void');
+ expect(result!.confidence).toBeGreaterThan(0);
+ });
+
+ it('resolves inherited method via MRO walk', () => {
+ ctx.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class');
+ ctx.symbols.add('src/child.java', 'Child', 'class:Child', 'Class');
+ ctx.symbols.add('src/parent.java', 'validate', 'method:Parent:validate', 'Method', {
+ returnType: 'boolean',
+ ownerId: 'class:Parent',
+ });
+ ctx.importMap.set('src/app.java', new Set(['src/child.java', 'src/parent.java']));
+
+ const heritage: ExtractedHeritage[] = [
+ { filePath: 'src/child.java', className: 'Child', parentName: 'Parent', kind: 'extends' },
+ ];
+ const map = buildHeritageMap(heritage, ctx);
+
+ const result = resolveMemberCall('Child', 'validate', 'src/app.java', ctx, map);
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('method:Parent:validate');
+ expect(result!.returnType).toBe('boolean');
+ });
+
+ it('returns null for unknown owner type', () => {
+ const result = resolveMemberCall('NonExistent', 'save', 'src/app.ts', ctx);
+ expect(result).toBeNull();
+ });
+
+ it('returns null for unknown method on known owner', () => {
+ ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
+ ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
+
+ const result = resolveMemberCall('User', 'nonExistentMethod', 'src/app.ts', ctx);
+ expect(result).toBeNull();
+ });
+
+ it('returns result with correct confidence tier for same-file resolution', () => {
+ ctx.symbols.add('src/app.ts', 'User', 'class:User', 'Class');
+ ctx.symbols.add('src/app.ts', 'save', 'method:User:save', 'Method', {
+ returnType: 'void',
+ ownerId: 'class:User',
+ });
+
+ const result = resolveMemberCall('User', 'save', 'src/app.ts', ctx);
+
+ expect(result).not.toBeNull();
+ expect(result!.confidence).toBe(0.95); // same-file tier
+ expect(result!.reason).toBe('same-file');
+ });
+
+ it('returns result with import-scoped tier for cross-file resolution', () => {
+ ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
+ ctx.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', {
+ returnType: 'void',
+ ownerId: 'class:User',
+ });
+ ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
+
+ const result = resolveMemberCall('User', 'save', 'src/app.ts', ctx);
+
+ expect(result).not.toBeNull();
+ expect(result!.confidence).toBe(0.9); // import-scoped tier
+ expect(result!.reason).toBe('import-resolved');
+ });
+
+ it('resolves with heritage map across C3 MRO chain (Python)', () => {
+ ctx.symbols.add('src/a.py', 'A', 'class:A', 'Class');
+ ctx.symbols.add('src/b.py', 'B', 'class:B', 'Class');
+ ctx.symbols.add('src/c.py', 'C', 'class:C', 'Class');
+ ctx.symbols.add('src/a.py', 'foo', 'method:A:foo', 'Method', {
+ returnType: 'str',
+ ownerId: 'class:A',
+ });
+ ctx.importMap.set('src/main.py', new Set(['src/a.py', 'src/b.py', 'src/c.py']));
+
+ const heritage: ExtractedHeritage[] = [
+ { filePath: 'src/c.py', className: 'C', parentName: 'B', kind: 'extends' },
+ { filePath: 'src/b.py', className: 'B', parentName: 'A', kind: 'extends' },
+ ];
+ const map = buildHeritageMap(heritage, ctx);
+
+ const result = resolveMemberCall('C', 'foo', 'src/main.py', ctx, map);
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('method:A:foo');
+ expect(result!.returnType).toBe('str');
+ });
+
+ // -------------------------------------------------------------------------
+ // Locks in the B2 semantic change: tier reflects how the OWNER TYPE was
+ // resolved, not how the method name was resolved globally.
+ // -------------------------------------------------------------------------
+ it('uses owner-type tier: cross-file class resolution → import-scoped confidence', () => {
+ // Scenario: owner class 'User' is defined in user.ts (imported from app.ts).
+ // The method 'save' exists ONLY on User (no homonyms). Old behaviour would
+ // have used the tier of resolving "save" globally; new behaviour uses the
+ // tier of resolving "User". Both happen to yield import-scoped here —
+ // the test locks that the reported tier tracks the class lookup.
+ ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
+ ctx.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', {
+ returnType: 'void',
+ ownerId: 'class:User',
+ });
+ ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
+
+ const result = resolveMemberCall('User', 'save', 'src/app.ts', ctx);
+
+ expect(result).not.toBeNull();
+ expect(result!.confidence).toBe(0.9); // import-scoped
+ expect(result!.reason).toBe('import-resolved');
+ });
+
+ // -------------------------------------------------------------------------
+ // T2: Rust qualified-syntax — trait-inherited methods must return null
+ // because they require `TraitName::method(obj)` call syntax, not `obj.method()`.
+ // Only struct's OWN impl methods are reachable via direct member calls.
+ // -------------------------------------------------------------------------
+ it('Rust: returns null for trait-inherited method (qualified-syntax MRO)', () => {
+ // Trait Writer defines `save`. Struct User has an impl_item but NO save
+ // method of its own — save is only available via trait.
+ ctx.symbols.add('src/writer.rs', 'Writer', 'trait:Writer', 'Trait');
+ ctx.symbols.add('src/user.rs', 'User', 'struct:User', 'Struct');
+ ctx.symbols.add('src/writer.rs', 'save', 'method:Writer:save', 'Method', {
+ returnType: 'bool',
+ ownerId: 'trait:Writer',
+ });
+ ctx.importMap.set('src/app.rs', new Set(['src/writer.rs', 'src/user.rs']));
+
+ const heritage: ExtractedHeritage[] = [
+ // User implements Writer — in Rust this is `impl Writer for User`.
+ { filePath: 'src/user.rs', className: 'User', parentName: 'Writer', kind: 'implements' },
+ ];
+ const map = buildHeritageMap(heritage, ctx);
+
+ // Rust's qualified-syntax strategy short-circuits trait inheritance walks,
+ // so `user.save()` (direct call) does not resolve.
+ const result = resolveMemberCall('User', 'save', 'src/app.rs', ctx, map);
+ expect(result).toBeNull();
+ });
+
+ it('Rust: direct impl methods still resolve (distinction check for T2)', () => {
+ // Positive control: a method defined directly on User (not via trait)
+ // resolves normally — demonstrates the null in the previous test is
+ // specifically due to the trait-inheritance path, not a broken fixture.
+ ctx.symbols.add('src/user.rs', 'User', 'struct:User', 'Struct');
+ ctx.symbols.add('src/user.rs', 'name', 'method:User:name', 'Method', {
+ returnType: 'String',
+ ownerId: 'struct:User',
+ });
+ ctx.importMap.set('src/app.rs', new Set(['src/user.rs']));
+
+ const result = resolveMemberCall('User', 'name', 'src/app.rs', ctx);
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('method:User:name');
+ expect(result!.returnType).toBe('String');
+ });
+
+ // -------------------------------------------------------------------------
+ // T3: C/C++ leftmost-base diamond inheritance at the resolveMemberCall layer.
+ // -------------------------------------------------------------------------
+ // -------------------------------------------------------------------------
+ // Homonym disambiguation: when two class candidates share a name but only
+ // ONE of them owns the method, resolveMemberCall should return that one
+ // without falling through to the fuzzy D2 widening path. Absorbs what was
+ // previously D4's ownerId-filtering job into the owner-scoped path.
+ // -------------------------------------------------------------------------
+ it('disambiguates homonym classes: only one owns the method', () => {
+ // Two classes both named `User` — one in auth.py (has `save`), one in
+ // legacy.py (has `archive` but no `save`). Both are imported from app.py.
+ ctx.symbols.add('src/auth.py', 'User', 'class:auth:User', 'Class');
+ ctx.symbols.add('src/auth.py', 'save', 'method:auth:User:save', 'Method', {
+ returnType: 'None',
+ ownerId: 'class:auth:User',
+ });
+ ctx.symbols.add('src/legacy.py', 'User', 'class:legacy:User', 'Class');
+ ctx.symbols.add('src/legacy.py', 'archive', 'method:legacy:User:archive', 'Method', {
+ returnType: 'None',
+ ownerId: 'class:legacy:User',
+ });
+ ctx.importMap.set('src/app.py', new Set(['src/auth.py', 'src/legacy.py']));
+
+ // `user.save()` is unambiguous — only auth.User has `save`.
+ const saveResult = resolveMemberCall('User', 'save', 'src/app.py', ctx);
+ expect(saveResult).not.toBeNull();
+ expect(saveResult!.nodeId).toBe('method:auth:User:save');
+
+ // `user.archive()` is also unambiguous — only legacy.User has `archive`.
+ const archiveResult = resolveMemberCall('User', 'archive', 'src/app.py', ctx);
+ expect(archiveResult).not.toBeNull();
+ expect(archiveResult!.nodeId).toBe('method:legacy:User:archive');
+ });
+
+ it('returns null when homonym classes BOTH own the method (genuine ambiguity)', () => {
+ // Both homonym Users define a `save` method — resolveMemberCall refuses
+ // to pick one. The caller (resolveCallTarget) falls through to D1-D4 which
+ // may or may not be able to narrow further.
+ ctx.symbols.add('src/auth.py', 'User', 'class:auth:User', 'Class');
+ ctx.symbols.add('src/auth.py', 'save', 'method:auth:User:save', 'Method', {
+ returnType: 'None',
+ ownerId: 'class:auth:User',
+ });
+ ctx.symbols.add('src/legacy.py', 'User', 'class:legacy:User', 'Class');
+ ctx.symbols.add('src/legacy.py', 'save', 'method:legacy:User:save', 'Method', {
+ returnType: 'None',
+ ownerId: 'class:legacy:User',
+ });
+ ctx.importMap.set('src/app.py', new Set(['src/auth.py', 'src/legacy.py']));
+
+ const result = resolveMemberCall('User', 'save', 'src/app.py', ctx);
+ expect(result).toBeNull();
+ });
+
+ it('homonym + shared ancestor: both walk MRO to the same method (dedups to 1)', () => {
+ // Two homonym `User` classes in different files, both extending a common
+ // `BaseUser` that owns `save`. Direct lookup on either User misses; MRO
+ // walks both find BaseUser.save. Dedup by nodeId yields a single result.
+ ctx.symbols.add('src/base.ts', 'BaseUser', 'class:BaseUser', 'Class');
+ ctx.symbols.add('src/base.ts', 'save', 'method:BaseUser:save', 'Method', {
+ returnType: 'void',
+ ownerId: 'class:BaseUser',
+ });
+ ctx.symbols.add('src/a.ts', 'User', 'class:a:User', 'Class');
+ ctx.symbols.add('src/b.ts', 'User', 'class:b:User', 'Class');
+ ctx.importMap.set('src/app.ts', new Set(['src/base.ts', 'src/a.ts', 'src/b.ts']));
+
+ const heritage: ExtractedHeritage[] = [
+ { filePath: 'src/a.ts', className: 'User', parentName: 'BaseUser', kind: 'extends' },
+ { filePath: 'src/b.ts', className: 'User', parentName: 'BaseUser', kind: 'extends' },
+ ];
+ const map = buildHeritageMap(heritage, ctx);
+
+ const result = resolveMemberCall('User', 'save', 'src/app.ts', ctx, map);
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('method:BaseUser:save');
+ });
+
+ it('C++: resolves diamond inheritance via leftmost-base MRO', () => {
+ // Diamond:
+ // Base
+ // / \
+ // A B
+ // \ /
+ // Derived
+ //
+ // Both A and B inherit `method` from Base. Derived extends (A, B).
+ // Leftmost-base strategy walks A's chain first → finds Base::method.
+ ctx.symbols.add('src/base.h', 'Base', 'class:Base', 'Class');
+ ctx.symbols.add('src/a.h', 'A', 'class:A', 'Class');
+ ctx.symbols.add('src/b.h', 'B', 'class:B', 'Class');
+ ctx.symbols.add('src/derived.h', 'Derived', 'class:Derived', 'Class');
+ ctx.symbols.add('src/base.h', 'method', 'method:Base:method', 'Method', {
+ returnType: 'int',
+ ownerId: 'class:Base',
+ });
+ ctx.importMap.set(
+ 'src/app.cpp',
+ new Set(['src/base.h', 'src/a.h', 'src/b.h', 'src/derived.h']),
+ );
+
+ const heritage: ExtractedHeritage[] = [
+ { filePath: 'src/a.h', className: 'A', parentName: 'Base', kind: 'extends' },
+ { filePath: 'src/b.h', className: 'B', parentName: 'Base', kind: 'extends' },
+ { filePath: 'src/derived.h', className: 'Derived', parentName: 'A', kind: 'extends' },
+ { filePath: 'src/derived.h', className: 'Derived', parentName: 'B', kind: 'extends' },
+ ];
+ const map = buildHeritageMap(heritage, ctx);
+
+ const result = resolveMemberCall('Derived', 'method', 'src/app.cpp', ctx, map);
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('method:Base:method');
+ expect(result!.returnType).toBe('int');
+ });
+
+ // -------------------------------------------------------------------------
+ // L1: C# / Kotlin implements-split strategy through resolveMemberCall.
+ // lookupMethodByOwnerWithMRO already has strategy-level coverage for these
+ // languages; these tests add the resolveMemberCall layer (tier resolution
+ // + class candidate iteration + MRO walk) on top.
+ // -------------------------------------------------------------------------
+ it('C#: walks implements-split to find inherited method via interface', () => {
+ // C# uses implements-split MRO: class base chain walked first, then
+ // interfaces. Here IService declares Save which is implemented by the
+ // base class BaseService — MyService inherits Save through the class.
+ ctx.symbols.add('src/iservice.cs', 'IService', 'interface:IService', 'Interface');
+ ctx.symbols.add('src/base.cs', 'BaseService', 'class:BaseService', 'Class');
+ ctx.symbols.add('src/my.cs', 'MyService', 'class:MyService', 'Class');
+ ctx.symbols.add('src/base.cs', 'Save', 'method:BaseService:Save', 'Method', {
+ returnType: 'void',
+ ownerId: 'class:BaseService',
+ });
+ ctx.importMap.set('src/app.cs', new Set(['src/iservice.cs', 'src/base.cs', 'src/my.cs']));
+
+ const heritage: ExtractedHeritage[] = [
+ {
+ filePath: 'src/base.cs',
+ className: 'BaseService',
+ parentName: 'IService',
+ kind: 'implements',
+ },
+ { filePath: 'src/my.cs', className: 'MyService', parentName: 'BaseService', kind: 'extends' },
+ ];
+ const map = buildHeritageMap(heritage, ctx);
+
+ const result = resolveMemberCall('MyService', 'Save', 'src/app.cs', ctx, map);
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('method:BaseService:Save');
+ expect(result!.returnType).toBe('void');
+ });
+
+ it('Kotlin: walks implements-split to find inherited method via interface', () => {
+ // Kotlin shares the implements-split MRO strategy with Java/C#. A class
+ // inheriting from an interface that provides a default method should
+ // resolve `obj.method()` to the interface's implementation.
+ ctx.symbols.add('src/validator.kt', 'Validator', 'interface:Validator', 'Interface');
+ ctx.symbols.add('src/user.kt', 'User', 'class:User', 'Class');
+ ctx.symbols.add('src/validator.kt', 'validate', 'method:Validator:validate', 'Method', {
+ returnType: 'Boolean',
+ ownerId: 'interface:Validator',
+ });
+ ctx.importMap.set('src/app.kt', new Set(['src/validator.kt', 'src/user.kt']));
+
+ const heritage: ExtractedHeritage[] = [
+ {
+ filePath: 'src/user.kt',
+ className: 'User',
+ parentName: 'Validator',
+ kind: 'implements',
+ },
+ ];
+ const map = buildHeritageMap(heritage, ctx);
+
+ const result = resolveMemberCall('User', 'validate', 'src/app.kt', ctx, map);
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('method:Validator:validate');
+ expect(result!.returnType).toBe('Boolean');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// T1: D0 skip-condition tests — verify resolveCallTarget bypasses the
+// resolveMemberCall fast path when overloadHints, preComputedArgTypes, or a
+// module alias is active.
+// ---------------------------------------------------------------------------
+
+describe('resolveCallTarget D0 skip conditions (SM-11)', () => {
+ let ctx: ResolutionContext;
+
+ beforeEach(() => {
+ ctx = createResolutionContext();
+ });
+
+ it('module alias: picks alias-scoped class over homonym (D0 actually bypassed)', () => {
+ // Python-style: `import auth; auth.User.save()` where BOTH auth.py and
+ // other.py define a `User` class with a `save` method. The test proves:
+ //
+ // 1. Without the alias: resolveMemberCall sees two homonym Users,
+ // both own `save`, and correctly returns null (refuses to guess).
+ // 2. With the alias: D0 is skipped via `hasActiveModuleAlias`, and
+ // D1-D4 — respecting the alias-narrowed filteredCandidates — picks
+ // the auth.py User.save method.
+ //
+ // A regression where D0 silently ran would produce null (ambiguous)
+ // instead of the correct answer, so this test actually exercises the
+ // skip path rather than just verifying a single-candidate happy path.
+ ctx.symbols.add('src/auth.py', 'User', 'class:auth:User', 'Class');
+ ctx.symbols.add('src/auth.py', 'save', 'method:auth:User:save', 'Method', {
+ returnType: 'None',
+ ownerId: 'class:auth:User',
+ });
+ ctx.symbols.add('src/other.py', 'User', 'class:other:User', 'Class');
+ ctx.symbols.add('src/other.py', 'save', 'method:other:User:save', 'Method', {
+ returnType: 'None',
+ ownerId: 'class:other:User',
+ });
+ ctx.importMap.set('src/app.py', new Set(['src/auth.py', 'src/other.py']));
+ ctx.moduleAliasMap.set('src/app.py', new Map([['auth', 'src/auth.py']]));
+
+ // Control: without alias narrowing, resolveMemberCall sees both Users
+ // own `save` and correctly refuses to pick one.
+ const ambiguous = resolveMemberCall('User', 'save', 'src/app.py', ctx);
+ expect(ambiguous).toBeNull();
+
+ // With alias narrowing active, D0 is skipped and D1-D4 picks auth.py's
+ // User.save because the alias block already narrowed filteredCandidates
+ // to auth.py (and the D2 widening step is gated on `!aliasNarrowed`).
+ const aliased = _resolveCallTargetForTesting(
+ {
+ calledName: 'save',
+ callForm: 'member',
+ receiverTypeName: 'User',
+ receiverName: 'auth', // triggers hasActiveModuleAlias → D0 skipped
+ },
+ 'src/app.py',
+ ctx,
+ );
+
+ expect(aliased).not.toBeNull();
+ expect(aliased!.nodeId).toBe('method:auth:User:save');
+ });
+
+ it('overloadHints present: D0 bypassed, D1-D4 handles resolution', () => {
+ // When overloadHints is supplied, the D0 fast path must be skipped
+ // because lookupMethodByOwner does not consider argument types and
+ // would pick an arbitrary overload for same-return-type overloads.
+ //
+ // This test verifies that the skip does not break resolution: passing
+ // a dummy overloadHints object should still yield the correct method
+ // via the D1-D4 path.
+ ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
+ ctx.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', {
+ returnType: 'void',
+ ownerId: 'class:User',
+ });
+ ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
+
+ // Minimal stub; D1-D4 only calls tryOverloadDisambiguation when there are
+ // multiple candidates, so an empty object is fine for single-candidate cases.
+ const dummyHints = {} as OverloadHints;
+
+ const result = _resolveCallTargetForTesting(
+ {
+ calledName: 'save',
+ callForm: 'member',
+ receiverTypeName: 'User',
+ },
+ 'src/app.ts',
+ ctx,
+ { overloadHints: dummyHints },
+ );
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('method:User:save');
+ });
+
+ it('preComputedArgTypes present: D0 bypassed, D1-D4 handles resolution', () => {
+ // Analogous to the overloadHints case: when preComputedArgTypes is supplied
+ // (worker path), D0 must be skipped so that type-based overload
+ // disambiguation in D1-D4 is authoritative.
+ ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
+ ctx.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', {
+ returnType: 'void',
+ ownerId: 'class:User',
+ });
+ ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
+
+ const result = _resolveCallTargetForTesting(
+ {
+ calledName: 'save',
+ callForm: 'member',
+ receiverTypeName: 'User',
+ argCount: 0,
+ },
+ 'src/app.ts',
+ ctx,
+ { preComputedArgTypes: [] },
+ );
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('method:User:save');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// resolveStaticCall — SM-12: constructor/static call resolution
+// ---------------------------------------------------------------------------
+
+import { resolveStaticCall } from '../../src/core/ingestion/call-processor.js';
+
+describe('resolveStaticCall', () => {
+ let ctx: ResolutionContext;
+
+ beforeEach(() => {
+ ctx = createResolutionContext();
+ });
+
+ it('resolves constructor with ownerId via lookupMethodByOwner', () => {
+ ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
+ ctx.symbols.add('src/user.ts', 'User', 'ctor:User', 'Constructor', {
+ returnType: 'User',
+ ownerId: 'class:User',
+ });
+ ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
+
+ const result = resolveStaticCall('User', 'src/app.ts', ctx);
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('ctor:User');
+ });
+
+ it('returns class node when no constructor exists', () => {
+ ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
+ ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
+
+ const result = resolveStaticCall('User', 'src/app.ts', ctx);
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('class:User');
+ });
+
+ it('returns null for non-class symbol', () => {
+ ctx.symbols.add('src/utils.ts', 'helper', 'func:helper', 'Function');
+ ctx.importMap.set('src/app.ts', new Set(['src/utils.ts']));
+
+ const result = resolveStaticCall('helper', 'src/app.ts', ctx);
+
+ expect(result).toBeNull();
+ });
+
+ it('returns null when className does not exist', () => {
+ const result = resolveStaticCall('NonExistent', 'src/app.ts', ctx);
+
+ expect(result).toBeNull();
+ });
+
+ it('returns null when Constructor nodes lack ownerId', () => {
+ ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
+ ctx.symbols.add('src/user.ts', 'User', 'ctor:User', 'Constructor', {
+ parameterCount: 1,
+ });
+ ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
+
+ // Constructor lacks ownerId, so lookupMethodByOwner won't find it.
+ // resolveStaticCall detects Constructor nodes and returns null to
+ // let filterCallableCandidates handle the Constructor-vs-Class preference.
+ const result = resolveStaticCall('User', 'src/app.ts', ctx);
+
+ expect(result).toBeNull();
+ });
+
+ it('disambiguates constructor by arity', () => {
+ ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
+ ctx.symbols.add('src/user.ts', 'User', 'ctor:User:0', 'Constructor', {
+ parameterCount: 0,
+ returnType: 'User',
+ ownerId: 'class:User',
+ });
+ ctx.symbols.add('src/user.ts', 'User', 'ctor:User:2', 'Constructor', {
+ parameterCount: 2,
+ returnType: 'User',
+ ownerId: 'class:User',
+ });
+ ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
+
+ const result = resolveStaticCall('User', 'src/app.ts', ctx, 2);
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('ctor:User:2');
+ });
+
+ it('returns correct confidence tier for import-scoped class', () => {
+ ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
+ ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
+
+ const result = resolveStaticCall('User', 'src/app.ts', ctx);
+
+ expect(result).not.toBeNull();
+ expect(result!.confidence).toBe(0.9); // import-scoped tier
+ expect(result!.reason).toBe('import-resolved');
+ });
+
+ it('returns correct confidence tier for same-file class', () => {
+ ctx.symbols.add('src/app.ts', 'User', 'class:User', 'Class');
+
+ const result = resolveStaticCall('User', 'src/app.ts', ctx);
+
+ expect(result).not.toBeNull();
+ expect(result!.confidence).toBe(0.95); // same-file tier
+ expect(result!.reason).toBe('same-file');
+ });
+
+ it('returns null for ambiguous homonym classes without constructor', () => {
+ ctx.symbols.add('src/a.ts', 'User', 'class:a:User', 'Class');
+ ctx.symbols.add('src/b.ts', 'User', 'class:b:User', 'Class');
+ ctx.importMap.set('src/app.ts', new Set(['src/a.ts', 'src/b.ts']));
+
+ const result = resolveStaticCall('User', 'src/app.ts', ctx);
+
+ // Two classes with same name — ambiguous, should return null
+ expect(result).toBeNull();
+ });
+
+ it('routes through resolveCallTarget for constructor callForm', () => {
+ ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
+ ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
+
+ const result = _resolveCallTargetForTesting(
+ {
+ calledName: 'User',
+ callForm: 'constructor',
+ },
+ 'src/app.ts',
+ ctx,
+ );
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('class:User');
+ });
+
+ it('routes through resolveCallTarget for free-form call targeting a class (Swift/Kotlin)', () => {
+ ctx.symbols.add('src/user.swift', 'User', 'class:User', 'Class');
+ ctx.importMap.set('src/app.swift', new Set(['src/user.swift']));
+
+ const result = _resolveCallTargetForTesting(
+ {
+ calledName: 'User',
+ callForm: 'free',
+ },
+ 'src/app.swift',
+ ctx,
+ );
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('class:User');
+ });
+
+ it('reuses the pre-computed tiered result instead of calling ctx.resolve twice', () => {
+ ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
+ ctx.symbols.add('src/user.ts', 'User', 'ctor:User', 'Constructor', {
+ returnType: 'User',
+ ownerId: 'class:User',
+ });
+ ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
+
+ // Spy on ctx.resolve to prove the override short-circuits the second lookup.
+ const originalResolve = ctx.resolve.bind(ctx);
+ let resolveCallCount = 0;
+ ctx.resolve = ((name: string, fromFile: string) => {
+ resolveCallCount++;
+ return originalResolve(name, fromFile);
+ }) as typeof ctx.resolve;
+
+ const tieredOverride = originalResolve('User', 'src/app.ts');
+ expect(tieredOverride).not.toBeNull();
+ resolveCallCount = 0; // reset after the setup call
+
+ const result = resolveStaticCall('User', 'src/app.ts', ctx, undefined, tieredOverride!);
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('ctor:User');
+ expect(resolveCallCount).toBe(0); // ctx.resolve must not have been called again
+ });
+
+ it('routes through resolveCallTarget for Java constructor call (new User())', () => {
+ ctx.symbols.add('src/User.java', 'User', 'class:java:User', 'Class');
+ ctx.symbols.add('src/User.java', 'User', 'ctor:java:User', 'Constructor', {
+ returnType: 'User',
+ ownerId: 'class:java:User',
+ });
+ ctx.importMap.set('src/App.java', new Set(['src/User.java']));
+
+ const result = _resolveCallTargetForTesting(
+ {
+ calledName: 'User',
+ callForm: 'constructor',
+ },
+ 'src/App.java',
+ ctx,
+ );
+
+ expect(result).not.toBeNull();
+ // Prefers Constructor node over Class node when ownerId is present.
+ expect(result!.nodeId).toBe('ctor:java:User');
+ });
+
+ it('routes through resolveCallTarget for Python free-form constructor (User())', () => {
+ ctx.symbols.add('models/user.py', 'User', 'class:py:User', 'Class');
+ ctx.importMap.set('app.py', new Set(['models/user.py']));
+
+ const result = _resolveCallTargetForTesting(
+ {
+ calledName: 'User',
+ callForm: 'free',
+ },
+ 'app.py',
+ ctx,
+ );
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('class:py:User');
+ });
+
+ it('routes through resolveCallTarget for Kotlin free-form constructor (User())', () => {
+ ctx.symbols.add('src/User.kt', 'User', 'class:kt:User', 'Class');
+ ctx.importMap.set('src/App.kt', new Set(['src/User.kt']));
+
+ const result = _resolveCallTargetForTesting(
+ {
+ calledName: 'User',
+ callForm: 'free',
+ },
+ 'src/App.kt',
+ ctx,
+ );
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('class:kt:User');
+ });
+
+ // -------------------------------------------------------------------------
+ // Instantiability guard (Codex review follow-up, plan 2026-04-09-002):
+ // The step-5 class-node fallback must only return instantiable kinds
+ // (Class / Struct / Record). Interface / Trait / Impl / Enum targets are
+ // null-routed to prevent false CALLS edges to non-instantiable nodes.
+ // -------------------------------------------------------------------------
+
+ it('returns a Struct node when no constructor exists (positive regression guard)', () => {
+ ctx.symbols.add('src/user.rs', 'User', 'struct:User', 'Struct');
+ ctx.importMap.set('src/app.rs', new Set(['src/user.rs']));
+
+ const result = resolveStaticCall('User', 'src/app.rs', ctx);
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('struct:User');
+ });
+
+ it('returns a Record node when no constructor exists (positive regression guard)', () => {
+ ctx.symbols.add('src/User.cs', 'User', 'record:User', 'Record');
+ ctx.importMap.set('src/App.cs', new Set(['src/User.cs']));
+
+ const result = resolveStaticCall('User', 'src/App.cs', ctx);
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('record:User');
+ });
+
+ it('null-routes when the sole candidate is an Interface (Java/C#/TS)', () => {
+ // Constructor-shaped call on an interface name — not legal source, but
+ // the resolver must refuse to emit a CALLS edge to a non-instantiable node.
+ ctx.symbols.add('src/validator.java', 'IValidator', 'iface:IValidator', 'Interface');
+ ctx.importMap.set('src/app.java', new Set(['src/validator.java']));
+
+ const result = resolveStaticCall('IValidator', 'src/app.java', ctx);
+
+ expect(result).toBeNull();
+ });
+
+ it('null-routes when the sole candidate is a Trait (PHP/Rust/Scala)', () => {
+ // PHP `HasTimestamps` trait — not instantiable via constructor syntax.
+ ctx.symbols.add('src/timestamps.php', 'HasTimestamps', 'trait:HasTimestamps', 'Trait');
+ ctx.importMap.set('src/model.php', new Set(['src/timestamps.php']));
+
+ const result = resolveStaticCall('HasTimestamps', 'src/model.php', ctx);
+
+ expect(result).toBeNull();
+ });
+
+ it('null-routes when the sole candidate is a Rust Trait (Display)', () => {
+ ctx.symbols.add('src/fmt.rs', 'Display', 'trait:rs:Display', 'Trait');
+ ctx.importMap.set('src/app.rs', new Set(['src/fmt.rs']));
+
+ const result = resolveStaticCall('Display', 'src/app.rs', ctx);
+
+ expect(result).toBeNull();
+ });
+
+ it('prefers the Struct over the Impl when both share the same name and file (Rust shadowing)', () => {
+ // Rust `impl User { ... }` alongside `struct User { ... }` in the same file.
+ // Same-file tier returns both via lookupExactAll, both pass CLASS_LIKE_TYPES,
+ // but the instantiability filter must strip the Impl so the Struct wins.
+ ctx.symbols.add('src/user.rs', 'User', 'struct:rs:User', 'Struct');
+ ctx.symbols.add('src/user.rs', 'User', 'impl:rs:User', 'Impl');
+
+ const result = resolveStaticCall('User', 'src/user.rs', ctx);
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('struct:rs:User');
+ });
+
+ it('null-routes when the sole candidate is a Rust Impl block (no Struct present)', () => {
+ // Pathological extractor output: only the Impl survives tier resolution.
+ // The instantiability filter must reject it rather than emit a wrong edge.
+ ctx.symbols.add('src/user.rs', 'User', 'impl:rs:User', 'Impl');
+
+ const result = resolveStaticCall('User', 'src/user.rs', ctx);
+
+ expect(result).toBeNull();
+ });
+
+ it('still returns an explicit Constructor even when the owner is an Impl (step-3 preservation)', () => {
+ // Step 3 (lookupMethodByOwner walk) must not be affected by the step-5
+ // tightening — a legitimate Constructor node owned by an Impl in a Rust
+ // extractor still resolves correctly. The Struct is also present so that
+ // step-1's lookupClassByName pre-check succeeds (Impl alone isn't in the
+ // classByName index).
+ ctx.symbols.add('src/user.rs', 'User', 'struct:rs:User', 'Struct');
+ ctx.symbols.add('src/user.rs', 'User', 'impl:rs:User', 'Impl');
+ ctx.symbols.add('src/user.rs', 'User', 'ctor:rs:User', 'Constructor', {
+ returnType: 'User',
+ ownerId: 'impl:rs:User',
+ });
+ ctx.importMap.set('src/app.rs', new Set(['src/user.rs']));
+
+ const result = resolveStaticCall('User', 'src/app.rs', ctx);
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('ctor:rs:User');
+ });
+
+ it('routes through resolveCallTarget and null-routes Interface constructor-shaped calls', () => {
+ ctx.symbols.add('src/validator.java', 'IValidator', 'iface:IValidator', 'Interface');
+ ctx.importMap.set('src/app.java', new Set(['src/validator.java']));
+
+ const result = _resolveCallTargetForTesting(
+ {
+ calledName: 'IValidator',
+ callForm: 'constructor',
+ },
+ 'src/app.java',
+ ctx,
+ );
+
+ // Full cascade: S0 → resolveStaticCall → step-5 instantiability filter → null.
+ // If any downstream path silently re-introduces the wrong edge, this fails.
+ expect(result).toBeNull();
+ });
+
+ it('routes through resolveCallTarget and null-routes Trait free-form calls', () => {
+ ctx.symbols.add('src/timestamps.php', 'HasTimestamps', 'trait:HasTimestamps', 'Trait');
+ ctx.importMap.set('src/model.php', new Set(['src/timestamps.php']));
+
+ const result = _resolveCallTargetForTesting(
+ {
+ calledName: 'HasTimestamps',
+ callForm: 'free',
+ },
+ 'src/model.php',
+ ctx,
+ );
+
+ expect(result).toBeNull();
+ });
+
+ it('routes Record free-form constructor call through S0 (C# record / Kotlin data class)', () => {
+ // Verifies that `freeFormHasClassTarget` triggers S0 for Record candidates.
+ // Before the alignment fix, `Record` was absent from the trigger `.some()`,
+ // so S0 was bypassed and Record free-form calls fell through to the
+ // constructor-form retry path. This test would have silently passed with
+ // the old (wasteful) code path — with the fix, S0 resolves it directly.
+ ctx.symbols.add('src/User.cs', 'User', 'record:cs:User', 'Record');
+ ctx.importMap.set('src/App.cs', new Set(['src/User.cs']));
+
+ const result = _resolveCallTargetForTesting(
+ {
+ calledName: 'User',
+ callForm: 'free',
+ },
+ 'src/App.cs',
+ ctx,
+ );
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('record:cs:User');
+ });
+
+ it('threads argCount through resolveCallTarget → S0 → resolveStaticCall for arity disambiguation', () => {
+ // Regression guard: if call.argCount were ever dropped at the S0 call
+ // site, the 2-arg constructor would resolve to the 0-arg overload (or
+ // return null via ambiguity). This test fails in either case.
+ ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
+ ctx.symbols.add('src/user.ts', 'User', 'ctor:User:0', 'Constructor', {
+ parameterCount: 0,
+ returnType: 'User',
+ ownerId: 'class:User',
+ });
+ ctx.symbols.add('src/user.ts', 'User', 'ctor:User:2', 'Constructor', {
+ parameterCount: 2,
+ returnType: 'User',
+ ownerId: 'class:User',
+ });
+ ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
+
+ const result = _resolveCallTargetForTesting(
+ {
+ calledName: 'User',
+ callForm: 'constructor',
+ argCount: 2,
+ },
+ 'src/app.ts',
+ ctx,
+ );
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('ctor:User:2');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// resolveFreeCall — SM-13: free-function call resolution
+// ---------------------------------------------------------------------------
+
+describe('resolveFreeCall', () => {
+ let ctx: ResolutionContext;
+
+ beforeEach(() => {
+ ctx = createResolutionContext();
+ });
+
+ it('resolves a free function call via import-scoped resolution', () => {
+ ctx.symbols.add('src/utils.ts', 'doStuff', 'func:doStuff', 'Function');
+ ctx.importMap.set('src/app.ts', new Set(['src/utils.ts']));
+
+ const result = resolveFreeCall('doStuff', 'src/app.ts', ctx);
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('func:doStuff');
+ expect(result!.confidence).toBe(0.9); // import-scoped tier
+ expect(result!.reason).toBe('import-resolved');
+ });
+
+ it('resolves a free function call via same-file resolution', () => {
+ ctx.symbols.add('src/app.ts', 'helper', 'func:helper', 'Function');
+
+ const result = resolveFreeCall('helper', 'src/app.ts', ctx);
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('func:helper');
+ expect(result!.confidence).toBe(0.95); // same-file tier
+ expect(result!.reason).toBe('same-file');
+ });
+
+ it('returns null when no candidates exist', () => {
+ const result = resolveFreeCall('nonexistent', 'src/app.ts', ctx);
+ expect(result).toBeNull();
+ });
+
+ it('returns null for ambiguous free function calls (multiple candidates)', () => {
+ ctx.symbols.add('src/a.ts', 'doStuff', 'func:a:doStuff', 'Function');
+ ctx.symbols.add('src/b.ts', 'doStuff', 'func:b:doStuff', 'Function');
+ ctx.importMap.set('src/app.ts', new Set(['src/a.ts', 'src/b.ts']));
+
+ const result = resolveFreeCall('doStuff', 'src/app.ts', ctx);
+
+ expect(result).toBeNull();
+ });
+
+ it('delegates to resolveStaticCall for free-form class targets (Swift/Kotlin)', () => {
+ ctx.symbols.add('src/user.swift', 'User', 'class:User', 'Class');
+ ctx.importMap.set('src/app.swift', new Set(['src/user.swift']));
+
+ const result = resolveFreeCall('User', 'src/app.swift', ctx);
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('class:User');
+ });
+
+ it('delegates to resolveStaticCall for Record free-form targets (C#/Kotlin)', () => {
+ ctx.symbols.add('src/User.cs', 'User', 'record:cs:User', 'Record');
+ ctx.importMap.set('src/App.cs', new Set(['src/User.cs']));
+
+ const result = resolveFreeCall('User', 'src/App.cs', ctx);
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('record:cs:User');
+ });
+
+ it('null-routes Trait free-form calls via resolveStaticCall', () => {
+ ctx.symbols.add('src/timestamps.php', 'HasTimestamps', 'trait:HasTimestamps', 'Trait');
+ ctx.importMap.set('src/model.php', new Set(['src/timestamps.php']));
+
+ const result = resolveFreeCall('HasTimestamps', 'src/model.php', ctx);
+
+ expect(result).toBeNull();
+ });
+
+ it('uses tieredOverride when provided', () => {
+ ctx.symbols.add('src/utils.ts', 'doStuff', 'func:doStuff', 'Function');
+ ctx.importMap.set('src/app.ts', new Set(['src/utils.ts']));
+
+ const tiered = ctx.resolve('doStuff', 'src/app.ts');
+ expect(tiered).not.toBeNull();
+
+ // Spy on ctx.resolve to verify it is NOT called again
+ const originalResolve = ctx.resolve.bind(ctx);
+ let resolveCallCount = 0;
+ ctx.resolve = ((name: string, fromFile: string) => {
+ resolveCallCount++;
+ return originalResolve(name, fromFile);
+ }) as typeof ctx.resolve;
+
+ const result = resolveFreeCall('doStuff', 'src/app.ts', ctx, undefined, tiered!);
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('func:doStuff');
+ expect(resolveCallCount).toBe(0);
+ });
+
+ it('routes through resolveCallTarget for free-form calls', () => {
+ ctx.symbols.add('src/utils.ts', 'doStuff', 'func:doStuff', 'Function');
+ ctx.importMap.set('src/app.ts', new Set(['src/utils.ts']));
+
+ const result = _resolveCallTargetForTesting(
+ {
+ calledName: 'doStuff',
+ callForm: 'free',
+ },
+ 'src/app.ts',
+ ctx,
+ );
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('func:doStuff');
+ });
+
+ // -------------------------------------------------------------------------
+ // PR #756 review follow-up (plan 2026-04-09-003): language coverage,
+ // arity threading, Tier 3 resolution, preComputedArgTypes worker path,
+ // Enum null-route, and Swift extension dedup guard.
+ // -------------------------------------------------------------------------
+
+ // R2 — Language coverage: Go, Python, Rust, Java, JavaScript free-function
+ // dispatch through _resolveCallTargetForTesting. resolveFreeCall has no
+ // file-extension branching; these guard the dispatch chain per language.
+
+ it('resolves a Go free function (doStuff())', () => {
+ ctx.symbols.add('src/helper.go', 'doStuff', 'func:go:doStuff', 'Function');
+ ctx.importMap.set('src/main.go', new Set(['src/helper.go']));
+
+ const result = _resolveCallTargetForTesting(
+ { calledName: 'doStuff', callForm: 'free' },
+ 'src/main.go',
+ ctx,
+ );
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('func:go:doStuff');
+ });
+
+ it('resolves a Python free function (def helper(): ... helper())', () => {
+ ctx.symbols.add('helpers.py', 'helper', 'func:py:helper', 'Function');
+ ctx.importMap.set('app.py', new Set(['helpers.py']));
+
+ const result = _resolveCallTargetForTesting(
+ { calledName: 'helper', callForm: 'free' },
+ 'app.py',
+ ctx,
+ );
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('func:py:helper');
+ });
+
+ it('resolves a Rust free function outside any impl block (free_fn())', () => {
+ ctx.symbols.add('src/helpers.rs', 'free_fn', 'func:rs:free_fn', 'Function');
+ ctx.importMap.set('src/main.rs', new Set(['src/helpers.rs']));
+
+ const result = _resolveCallTargetForTesting(
+ { calledName: 'free_fn', callForm: 'free' },
+ 'src/main.rs',
+ ctx,
+ );
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('func:rs:free_fn');
+ });
+
+ it('resolves a Java statically-imported function (doStuff() after import static Utils.doStuff)', () => {
+ // Note: this simulates the extractor output post static import by
+ // indexing the function directly in its declaring file. The test guards
+ // the dispatch chain for .java files, not the extractor's handling of
+ // static imports specifically.
+ ctx.symbols.add('src/Utils.java', 'doStuff', 'func:java:doStuff', 'Function');
+ ctx.importMap.set('src/App.java', new Set(['src/Utils.java']));
+
+ const result = _resolveCallTargetForTesting(
+ { calledName: 'doStuff', callForm: 'free' },
+ 'src/App.java',
+ ctx,
+ );
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('func:java:doStuff');
+ });
+
+ it('resolves a JavaScript module-level function (moduleFn())', () => {
+ ctx.symbols.add('src/helpers.js', 'moduleFn', 'func:js:moduleFn', 'Function');
+ ctx.importMap.set('src/app.js', new Set(['src/helpers.js']));
+
+ const result = _resolveCallTargetForTesting(
+ { calledName: 'moduleFn', callForm: 'free' },
+ 'src/app.js',
+ ctx,
+ );
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('func:js:moduleFn');
+ });
+
+ // R3 — Arity filtering: call.argCount must narrow overloaded free functions
+ // differing only in parameter count.
+
+ it('narrows overloaded free functions by argCount (2-arg overload selected)', () => {
+ ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:0', 'Function', {
+ parameterCount: 0,
+ });
+ ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:2', 'Function', {
+ parameterCount: 2,
+ });
+ ctx.importMap.set('src/app.ts', new Set(['src/utils.ts']));
+
+ const result = _resolveCallTargetForTesting(
+ { calledName: 'helper', callForm: 'free', argCount: 2 },
+ 'src/app.ts',
+ ctx,
+ );
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('func:helper:2');
+ });
+
+ it('narrows overloaded free functions by argCount (0-arg overload selected)', () => {
+ ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:0', 'Function', {
+ parameterCount: 0,
+ });
+ ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:2', 'Function', {
+ parameterCount: 2,
+ });
+ ctx.importMap.set('src/app.ts', new Set(['src/utils.ts']));
+
+ const result = _resolveCallTargetForTesting(
+ { calledName: 'helper', callForm: 'free', argCount: 0 },
+ 'src/app.ts',
+ ctx,
+ );
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('func:helper:0');
+ });
+
+ // R4 — Tier 3 (global) resolution: function globally visible but not
+ // imported. Locks in TIER_CONFIDENCE.global === 0.5 and reason === 'global'
+ // so a silent tier-table refactor surfaces here.
+
+ it('resolves a globally-visible free function via Tier 3 with global confidence', () => {
+ ctx.symbols.add('lib/global.ts', 'helper', 'func:global:helper', 'Function');
+ // No importMap entry — must fall through to Tier 3 (global).
+
+ const result = _resolveCallTargetForTesting(
+ { calledName: 'helper', callForm: 'free' },
+ 'src/app.ts',
+ ctx,
+ );
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('func:global:helper');
+ expect(result!.confidence).toBe(0.5); // TIER_CONFIDENCE.global
+ expect(result!.reason).toBe('global');
+ });
+
+ // R5 — preComputedArgTypes worker path: when parse-worker pre-computes
+ // argument types, the disambiguation routes through matchCandidatesByArgTypes.
+ // Preconditions (verified at feasibility review):
+ // 1. filteredCandidates.length > 1 — both overloads must survive arity
+ // filtering, so argCount left unset here.
+ // 2. overloadHints must be undefined — it takes precedence over
+ // preComputedArgTypes at the disambiguation site.
+
+ it('disambiguates overloads via preComputedArgTypes (String overload matched)', () => {
+ ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:str', 'Function', {
+ parameterCount: 1,
+ parameterTypes: ['String'],
+ });
+ ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:int', 'Function', {
+ parameterCount: 1,
+ parameterTypes: ['Int'],
+ });
+ ctx.importMap.set('src/app.ts', new Set(['src/utils.ts']));
+
+ const result = _resolveCallTargetForTesting(
+ { calledName: 'helper', callForm: 'free', argCount: 1 },
+ 'src/app.ts',
+ ctx,
+ { preComputedArgTypes: ['String'] },
+ );
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('func:helper:str');
+ });
+
+ it('disambiguates overloads via preComputedArgTypes (Int overload matched)', () => {
+ ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:str', 'Function', {
+ parameterCount: 1,
+ parameterTypes: ['String'],
+ });
+ ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:int', 'Function', {
+ parameterCount: 1,
+ parameterTypes: ['Int'],
+ });
+ ctx.importMap.set('src/app.ts', new Set(['src/utils.ts']));
+
+ const result = _resolveCallTargetForTesting(
+ { calledName: 'helper', callForm: 'free', argCount: 1 },
+ 'src/app.ts',
+ ctx,
+ // `Int` is normalized to `int` on the stored side via normalizeJvmTypeName
+ // (matchCandidatesByArgTypes:1287). Real parse-worker-emitted argTypes are
+ // already lowercase primitive names inferred from literals, so this
+ // mirrors production call-site shape.
+ { preComputedArgTypes: ['int'] },
+ );
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('func:helper:int');
+ });
+
+ // R6 — Enum free-form null-route: locks in the current behavior that
+ // `Color()`-style calls on Enum types return null because Enum is
+ // deliberately excluded from INSTANTIABLE_CLASS_TYPES. This is intentional
+ // per PR #754 round 1 (see `call-processor.ts` INSTANTIABLE_CLASS_TYPES
+ // JSDoc — "Enum excluded pending language-specific support with motivating
+ // test fixtures"). If a future extension adds Enum to the set, this test
+ // will need to be updated alongside that work — that is the correct signal.
+
+ it('null-routes Enum free-form calls (Color() — no instantiable fallback)', () => {
+ ctx.symbols.add('src/color.ts', 'Color', 'enum:Color', 'Enum');
+ ctx.importMap.set('src/app.ts', new Set(['src/color.ts']));
+
+ const result = _resolveCallTargetForTesting(
+ { calledName: 'Color', callForm: 'free' },
+ 'src/app.ts',
+ ctx,
+ );
+
+ // Enum not in INSTANTIABLE_CLASS_TYPES → hasClassTarget is false →
+ // resolveStaticCall is not called → tail dedup also doesn't fire →
+ // falls through to the final null return.
+ expect(result).toBeNull();
+ });
+
+ // R7 — Swift extension dedup `filePath.length` heuristic guard:
+ // Two same-name Class entries at different path lengths. The free-form
+ // dispatch chain goes:
+ // 1. filterCallableCandidates(tiered, argCount, 'free') strips Class →
+ // filteredCandidates.length === 0
+ // 2. hasClassTarget is true (both are Class)
+ // 3. resolveStaticCall runs, has 2 homonym Class candidates →
+ // instantiableCandidates.length > 1 → returns null (SM-12 round-1
+ // null-route contract)
+ // 4. Constructor-form retry: filterCallableCandidates(tiered, argCount,
+ // 'constructor') keeps Class entries → filteredCandidates.length === 2
+ // 5. Falls through to the Swift extension dedup block → sorts by
+ // filePath.length → returns the shortest path.
+
+ it('dedupes Swift extension candidates by shortest file path (free-form retry path)', () => {
+ // Two same-name Class entries, different path lengths.
+ ctx.symbols.add('src/User.swift', 'User', 'class:User:primary', 'Class');
+ ctx.symbols.add('src/Extensions/UserExtensions.swift', 'User', 'class:User:extension', 'Class');
+ ctx.importMap.set(
+ 'src/App.swift',
+ new Set(['src/User.swift', 'src/Extensions/UserExtensions.swift']),
+ );
+
+ const result = _resolveCallTargetForTesting(
+ { calledName: 'User', callForm: 'free' },
+ 'src/App.swift',
+ ctx,
+ );
+
+ // The shortest file path wins per the existing heuristic. This is a
+ // behavior guard for finding #4 in the PR #756 review — if the dedup
+ // heuristic changes, this test surfaces that intent.
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('class:User:primary');
+ });
+
+ // -------------------------------------------------------------------------
+ // PR #756 final review follow-up (comment 4215739052):
+ // - Finding #3 low: ownerless-Constructor retry path (previously covered
+ // by comment only) — adds the concrete test the reviewer asked for.
+ // - Low-severity coverage gap: PHP free function (from the language
+ // coverage table in the same review).
+ // -------------------------------------------------------------------------
+
+ it('routes through resolveStaticCall retry when tiered pool contains an ownerless Constructor (free-form)', () => {
+ // This exercises the third null-return reason documented in the retry
+ // comment inside resolveFreeCall: resolveStaticCall's step-4 bailout when
+ // the tiered pool contains Constructor nodes that lack ownerId (common in
+ // some extractors). In that case:
+ // 1. resolveStaticCall step 3 walks classCandidates via lookupMethodByOwner
+ // — the ownerless Constructor is NOT in methodByOwner, so nothing found.
+ // 2. Step 4 detects the Constructor in the tiered pool and bails out
+ // with null so filterCallableCandidates can handle Constructor-vs-
+ // Class preference correctly.
+ // 3. resolveFreeCall's retry re-runs filterCallableCandidates with
+ // 'constructor' form, which — per CONSTRUCTOR_TARGET_TYPES — prefers
+ // the Constructor node over the Class node.
+ // 4. Single survivor → returned as the call target.
+ ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
+ ctx.symbols.add('src/user.ts', 'User', 'ctor:User:ownerless', 'Constructor', {
+ parameterCount: 0,
+ // No ownerId — this is the pathological extractor output the retry path
+ // exists to handle.
+ });
+ ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
+
+ const result = _resolveCallTargetForTesting(
+ { calledName: 'User', callForm: 'free' },
+ 'src/app.ts',
+ ctx,
+ );
+
+ // The Constructor survives filterCallableCandidates's 'constructor' form
+ // filter and is preferred over the Class (CONSTRUCTOR_TARGET_TYPES puts
+ // Constructor first). Guards the (c) case in the retry-reasons comment.
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('ctor:User:ownerless');
+ });
+
+ it('resolves a PHP free function (top-level helper())', () => {
+ // PHP allows top-level function definitions outside any class. The
+ // language coverage table in PR #756 review flagged this as uncovered;
+ // this test exercises the `.php` dispatch path for free calls. Matches
+ // the shape of the existing Go/Python/Rust/Java/JS language tests above.
+ ctx.symbols.add('src/helpers.php', 'helper', 'func:php:helper', 'Function');
+ ctx.importMap.set('src/app.php', new Set(['src/helpers.php']));
+
+ const result = _resolveCallTargetForTesting(
+ { calledName: 'helper', callForm: 'free' },
+ 'src/app.php',
+ ctx,
+ );
+
+ expect(result).not.toBeNull();
+ expect(result!.nodeId).toBe('func:php:helper');
+ });
+});