mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-10 03:27:59 +00:00
Merge remote-tracking branch 'origin/main' into sm14-binding-accumulator
This commit is contained in:
commit
47edb754d1
69 changed files with 3906 additions and 172 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -104,7 +104,8 @@
|
|||
"overrides": {
|
||||
"@huggingface/transformers": {
|
||||
"onnxruntime-node": "$onnxruntime-node"
|
||||
}
|
||||
},
|
||||
"tree-sitter-c": "0.23.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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[] => {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
10
gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/A.h
vendored
Normal file
10
gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/A.h
vendored
Normal 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 {
|
||||
};
|
||||
7
gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/B.h
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/B.h
vendored
Normal 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 {
|
||||
};
|
||||
6
gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/Base.h
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/Base.h
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#pragma once
|
||||
|
||||
class Base {
|
||||
public:
|
||||
int method() { return 42; }
|
||||
};
|
||||
6
gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/Derived.h
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/Derived.h
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#pragma once
|
||||
#include "A.h"
|
||||
#include "B.h"
|
||||
|
||||
class Derived : public A, public B {
|
||||
};
|
||||
6
gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/app.cpp
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/app.cpp
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#include "Derived.h"
|
||||
|
||||
void run() {
|
||||
Derived d;
|
||||
d.method();
|
||||
}
|
||||
15
gitnexus/test/fixtures/lang-resolution/csharp-interface-default-method/App.cs
vendored
Normal file
15
gitnexus/test/fixtures/lang-resolution/csharp-interface-default-method/App.cs
vendored
Normal 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();
|
||||
}
|
||||
}
|
||||
11
gitnexus/test/fixtures/lang-resolution/csharp-interface-default-method/User.cs
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/csharp-interface-default-method/User.cs
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
namespace InterfaceDefault;
|
||||
|
||||
public class User : IValidator
|
||||
{
|
||||
public string Name { get; }
|
||||
|
||||
public User(string name)
|
||||
{
|
||||
Name = name;
|
||||
}
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/csharp-interface-default-method/Validator.cs
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/csharp-interface-default-method/Validator.cs
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace InterfaceDefault;
|
||||
|
||||
public interface IValidator
|
||||
{
|
||||
bool Validate() => true;
|
||||
}
|
||||
8
gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/app.dart
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/app.dart
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import 'child.dart';
|
||||
|
||||
class App {
|
||||
void run() {
|
||||
final c = Child();
|
||||
c.parentMethod();
|
||||
}
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/child.dart
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/child.dart
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import 'parent.dart';
|
||||
|
||||
class Child extends Parent {}
|
||||
5
gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/parent.dart
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/parent.dart
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
class Parent {
|
||||
String parentMethod() {
|
||||
return 'parent';
|
||||
}
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/go.mod
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/go.mod
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module example.com/app
|
||||
|
||||
go 1.21
|
||||
5
gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/models/child.go
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/models/child.go
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package models
|
||||
|
||||
type Child struct {
|
||||
Parent
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/models/parent.go
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/models/parent.go
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package models
|
||||
|
||||
type Parent struct{}
|
||||
|
||||
func (p *Parent) ParentMethod() string {
|
||||
return "parent"
|
||||
}
|
||||
8
gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/services/app.go
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/services/app.go
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package services
|
||||
|
||||
import "example.com/app/models"
|
||||
|
||||
func Run() {
|
||||
c := &models.Child{}
|
||||
c.ParentMethod()
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/java-interface-default-method/App.java
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/java-interface-default-method/App.java
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
public class App {
|
||||
public static void run() {
|
||||
User user = new User("alice");
|
||||
user.validate();
|
||||
}
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/java-interface-default-method/User.java
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/java-interface-default-method/User.java
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
public class User implements Validator {
|
||||
private String name;
|
||||
|
||||
public User(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/java-interface-default-method/Validator.java
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/java-interface-default-method/Validator.java
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
public interface Validator {
|
||||
default boolean validate() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/kotlin-interface-default-method/src/App.kt
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/kotlin-interface-default-method/src/App.kt
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
package example
|
||||
|
||||
fun run() {
|
||||
val user = User("alice")
|
||||
user.validate()
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/kotlin-interface-default-method/src/User.kt
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/kotlin-interface-default-method/src/User.kt
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
package example
|
||||
|
||||
class User(val name: String) : Validator
|
||||
5
gitnexus/test/fixtures/lang-resolution/kotlin-interface-default-method/src/Validator.kt
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/kotlin-interface-default-method/src/Validator.kt
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package example
|
||||
|
||||
interface Validator {
|
||||
fun validate(): Boolean = true
|
||||
}
|
||||
14
gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/App.php
vendored
Normal file
14
gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/App.php
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
|
||||
namespace Services;
|
||||
|
||||
use Models\Child;
|
||||
|
||||
class App
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$c = new Child();
|
||||
$c->parentMethod();
|
||||
}
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/Child.php
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/Child.php
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?php
|
||||
|
||||
namespace Models;
|
||||
|
||||
class Child extends ParentClass
|
||||
{
|
||||
}
|
||||
11
gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/Parent.php
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/Parent.php
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
|
||||
namespace Models;
|
||||
|
||||
class ParentClass
|
||||
{
|
||||
public function parentMethod(): string
|
||||
{
|
||||
return 'parent';
|
||||
}
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/python-multi-level-mro/app.py
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/python-multi-level-mro/app.py
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from child import Child
|
||||
|
||||
|
||||
def run() -> None:
|
||||
c = Child()
|
||||
c.gp_method()
|
||||
5
gitnexus/test/fixtures/lang-resolution/python-multi-level-mro/child.py
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/python-multi-level-mro/child.py
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from parent import Parent
|
||||
|
||||
|
||||
class Child(Parent):
|
||||
pass
|
||||
3
gitnexus/test/fixtures/lang-resolution/python-multi-level-mro/grandparent.py
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/python-multi-level-mro/grandparent.py
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
class Grandparent:
|
||||
def gp_method(self) -> str:
|
||||
return "grandparent"
|
||||
5
gitnexus/test/fixtures/lang-resolution/python-multi-level-mro/parent.py
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/python-multi-level-mro/parent.py
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from grandparent import Grandparent
|
||||
|
||||
|
||||
class Parent(Grandparent):
|
||||
pass
|
||||
8
gitnexus/test/fixtures/lang-resolution/ruby-child-extends-parent/lib/app.rb
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/ruby-child-extends-parent/lib/app.rb
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
require_relative 'child'
|
||||
|
||||
class App
|
||||
def run
|
||||
c = Child.new
|
||||
c.parent_method
|
||||
end
|
||||
end
|
||||
4
gitnexus/test/fixtures/lang-resolution/ruby-child-extends-parent/lib/child.rb
vendored
Normal file
4
gitnexus/test/fixtures/lang-resolution/ruby-child-extends-parent/lib/child.rb
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
require_relative 'parent'
|
||||
|
||||
class Child < Parent
|
||||
end
|
||||
5
gitnexus/test/fixtures/lang-resolution/ruby-child-extends-parent/lib/parent.rb
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/ruby-child-extends-parent/lib/parent.rb
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
class Parent
|
||||
def parent_method
|
||||
"parent"
|
||||
end
|
||||
end
|
||||
16
gitnexus/test/fixtures/lang-resolution/rust-child-extends-parent/src/child.rs
vendored
Normal file
16
gitnexus/test/fixtures/lang-resolution/rust-child-extends-parent/src/child.rs
vendored
Normal 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 {}
|
||||
17
gitnexus/test/fixtures/lang-resolution/rust-child-extends-parent/src/main.rs
vendored
Normal file
17
gitnexus/test/fixtures/lang-resolution/rust-child-extends-parent/src/main.rs
vendored
Normal 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();
|
||||
}
|
||||
11
gitnexus/test/fixtures/lang-resolution/rust-child-extends-parent/src/parent.rs
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/rust-child-extends-parent/src/parent.rs
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/swift-child-extends-parent/Sources/App.swift
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/swift-child-extends-parent/Sources/App.swift
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
class App {
|
||||
func run() {
|
||||
let c = Child()
|
||||
c.parentMethod()
|
||||
}
|
||||
}
|
||||
2
gitnexus/test/fixtures/lang-resolution/swift-child-extends-parent/Sources/Child.swift
vendored
Normal file
2
gitnexus/test/fixtures/lang-resolution/swift-child-extends-parent/Sources/Child.swift
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
class Child: Parent {
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/swift-child-extends-parent/Sources/Parent.swift
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/swift-child-extends-parent/Sources/Parent.swift
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
class Parent {
|
||||
func parentMethod() -> String {
|
||||
return "parent"
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
export class Repo {
|
||||
constructor(private readonly path: string) {}
|
||||
|
||||
save(): boolean {
|
||||
return false;
|
||||
return this.path.length > 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
export class User {
|
||||
constructor(private readonly name: string) {}
|
||||
|
||||
save(): boolean {
|
||||
return true;
|
||||
return this.name.length > 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
235
gitnexus/test/unit/api-graph-streaming.test.ts
Normal file
235
gitnexus/test/unit/api-graph-streaming.test.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
Loading…
Add table
Reference in a new issue