Merge remote-tracking branch 'origin/main' into sm14-binding-accumulator

This commit is contained in:
Gergo Magyar 2026-04-09 17:42:15 +01:00
commit 47edb754d1
69 changed files with 3906 additions and 172 deletions

View file

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

View file

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

View file

@ -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 && <PollingBar />}
</StepRow>

View file

@ -64,7 +64,7 @@ export const StatusBar = () => {
</a>
{/* Right - Stats */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-3" data-testid="graph-stats">
{graph && (
<>
<span>{nodeCount} nodes</span>

View file

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

View file

@ -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<Uint8Array>({
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<Uint8Array>({
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<Uint8Array>({
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',
});
});
});

View file

@ -104,7 +104,8 @@
"overrides": {
"@huggingface/transformers": {
"onnxruntime-node": "$onnxruntime-node"
}
},
"tree-sitter-c": "0.23.2"
},
"engines": {
"node": ">=20.0.0"

View file

@ -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<string, Map<string, string>>;
/** 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<string>([...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<string>(['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<string, readonly SymbolDefinition[]>;
/** @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

View file

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

View file

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

View file

@ -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[] => {

View file

@ -637,6 +637,34 @@ export const executeQuery = async (cypher: string): Promise<any[]> => {
return rows;
};
export const streamQuery = async (
cypher: string,
onRow: (row: any) => void | Promise<void>,
): Promise<number> => {
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.

View file

@ -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<void> => {
ensureStreamIsWritable(res, signal);
await new Promise<void>((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<void> => {
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<void> => {
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 });
}
});

View file

@ -212,7 +212,7 @@ export const addToGitignore = async (repoPath: string): Promise<void> => {
* 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');
};
/**

View file

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

View file

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

View file

@ -0,0 +1,6 @@
#pragma once
class Base {
public:
int method() { return 42; }
};

View file

@ -0,0 +1,6 @@
#pragma once
#include "A.h"
#include "B.h"
class Derived : public A, public B {
};

View file

@ -0,0 +1,6 @@
#include "Derived.h"
void run() {
Derived d;
d.method();
}

View file

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

View file

@ -0,0 +1,11 @@
namespace InterfaceDefault;
public class User : IValidator
{
public string Name { get; }
public User(string name)
{
Name = name;
}
}

View file

@ -0,0 +1,6 @@
namespace InterfaceDefault;
public interface IValidator
{
bool Validate() => true;
}

View file

@ -0,0 +1,8 @@
import 'child.dart';
class App {
void run() {
final c = Child();
c.parentMethod();
}
}

View file

@ -0,0 +1,3 @@
import 'parent.dart';
class Child extends Parent {}

View file

@ -0,0 +1,5 @@
class Parent {
String parentMethod() {
return 'parent';
}
}

View file

@ -0,0 +1,3 @@
module example.com/app
go 1.21

View file

@ -0,0 +1,5 @@
package models
type Child struct {
Parent
}

View file

@ -0,0 +1,7 @@
package models
type Parent struct{}
func (p *Parent) ParentMethod() string {
return "parent"
}

View file

@ -0,0 +1,8 @@
package services
import "example.com/app/models"
func Run() {
c := &models.Child{}
c.ParentMethod()
}

View file

@ -0,0 +1,6 @@
public class App {
public static void run() {
User user = new User("alice");
user.validate();
}
}

View file

@ -0,0 +1,7 @@
public class User implements Validator {
private String name;
public User(String name) {
this.name = name;
}
}

View file

@ -0,0 +1,5 @@
public interface Validator {
default boolean validate() {
return true;
}
}

View file

@ -0,0 +1,6 @@
package example
fun run() {
val user = User("alice")
user.validate()
}

View file

@ -0,0 +1,3 @@
package example
class User(val name: String) : Validator

View file

@ -0,0 +1,5 @@
package example
interface Validator {
fun validate(): Boolean = true
}

View file

@ -0,0 +1,14 @@
<?php
namespace Services;
use Models\Child;
class App
{
public function run(): void
{
$c = new Child();
$c->parentMethod();
}
}

View file

@ -0,0 +1,7 @@
<?php
namespace Models;
class Child extends ParentClass
{
}

View file

@ -0,0 +1,11 @@
<?php
namespace Models;
class ParentClass
{
public function parentMethod(): string
{
return 'parent';
}
}

View file

@ -0,0 +1,6 @@
from child import Child
def run() -> None:
c = Child()
c.gp_method()

View file

@ -0,0 +1,5 @@
from parent import Parent
class Child(Parent):
pass

View file

@ -0,0 +1,3 @@
class Grandparent:
def gp_method(self) -> str:
return "grandparent"

View file

@ -0,0 +1,5 @@
from grandparent import Grandparent
class Parent(Grandparent):
pass

View file

@ -0,0 +1,8 @@
require_relative 'child'
class App
def run
c = Child.new
c.parent_method
end
end

View file

@ -0,0 +1,4 @@
require_relative 'parent'
class Child < Parent
end

View file

@ -0,0 +1,5 @@
class Parent
def parent_method
"parent"
end
end

View file

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

View file

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

View file

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

View file

@ -0,0 +1,6 @@
class App {
func run() {
let c = Child()
c.parentMethod()
}
}

View file

@ -0,0 +1,2 @@
class Child: Parent {
}

View file

@ -0,0 +1,5 @@
class Parent {
func parentMethod() -> String {
return "parent"
}
}

View file

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

View file

@ -1,5 +1,7 @@
export class Repo {
constructor(private readonly path: string) {}
save(): boolean {
return false;
return this.path.length > 0;
}
}

View file

@ -1,5 +1,7 @@
export class User {
constructor(private readonly name: string) {}
save(): boolean {
return true;
return this.name.length > 0;
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<typeof createKnowledgeGraph>;
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<string, string>([['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();
});
});

File diff suppressed because it is too large Load diff