From 338cb01ee06ae87aad2f959053fa1421e07f22e0 Mon Sep 17 00:00:00 2001 From: JaysonAlbert Date: Fri, 10 Apr 2026 00:40:24 +0800 Subject: [PATCH 01/67] [codex] fix large repository graph loading (#732) * fix(web): stream large graph responses * fix(server): harden graph streaming * fix(ci): stabilize graph loading coverage --------- Co-authored-by: gfwangjie --- gitnexus-web/e2e/server-connect.spec.ts | 50 ++- gitnexus-web/src/components/StatusBar.tsx | 2 +- gitnexus-web/src/services/backend-client.ts | 67 +++- .../test/unit/server-connection.test.ts | 138 +++++++- gitnexus/src/core/lbug/lbug-adapter.ts | 28 ++ gitnexus/src/server/api.ts | 308 +++++++++++++++--- .../test/unit/api-graph-streaming.test.ts | 235 +++++++++++++ 7 files changed, 746 insertions(+), 82 deletions(-) create mode 100644 gitnexus/test/unit/api-graph-streaming.test.ts diff --git a/gitnexus-web/e2e/server-connect.spec.ts b/gitnexus-web/e2e/server-connect.spec.ts index eb241da10..0705b3a27 100644 --- a/gitnexus-web/e2e/server-connect.spec.ts +++ b/gitnexus-web/e2e/server-connect.spec.ts @@ -1,4 +1,4 @@ -import { test, expect, type TestInfo } from '@playwright/test'; +import { test, expect } from '@playwright/test'; /** * E2E tests for the GitNexus web UI — exploring view features. @@ -58,36 +58,41 @@ test.beforeAll(async () => { * For these tests we require at least one indexed repo, so pick the first * landing card when present and then wait for the exploring view. */ -async function waitForGraphLoaded(page: import('@playwright/test').Page, testInfo: TestInfo) { +async function waitForGraphLoaded(page: import('@playwright/test').Page) { await page.goto('/'); - const landingCard = page.locator('[data-testid="landing-repo-card"]').first(); + const landingCards = page.locator('[data-testid="landing-repo-card"]'); + const preferredLandingCard = landingCards + .filter({ hasText: /GitNexus|local-integration/ }) + .first(); try { - await landingCard.waitFor({ state: 'visible', timeout: 15_000 }); + await landingCards.first().waitFor({ state: 'visible', timeout: 15_000 }); + const landingCard = + (await preferredLandingCard.count()) > 0 ? preferredLandingCard : landingCards.first(); await landingCard.click(); } catch { // Landing screen may not appear (e.g. ?server auto-connect) } - await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); - await expect(page.getByText(/\d+ nodes/).first()).toBeVisible(); - await page.screenshot({ path: testInfo.outputPath('graph-loaded.png') }); + const statusBar = page.getByRole('contentinfo'); + await expect(statusBar.getByText('Ready', { exact: true })).toBeVisible({ timeout: 45_000 }); + await expect(statusBar).toContainText(/nodes/, { + timeout: 20_000, + }); } test.describe('Server Connection & Graph Loading', () => { - test('selects a repo from landing and loads graph', async ({ page }, testInfo) => { - await waitForGraphLoaded(page, testInfo); - await page.screenshot({ path: testInfo.outputPath('graph-loaded-full.png'), fullPage: true }); + test('selects a repo from landing and loads graph', async ({ page }) => { + await waitForGraphLoaded(page); }); }); test.describe('Nexus AI', () => { - test('panel opens and agent initializes without error', async ({ page }, testInfo) => { - await waitForGraphLoaded(page, testInfo); + test('panel opens and agent initializes without error', async ({ page }) => { + await waitForGraphLoaded(page); await page.getByRole('button', { name: 'Nexus AI' }).click(); await expect(page.getByText('Ask me anything')).toBeVisible({ timeout: 15_000 }); - await page.screenshot({ path: testInfo.outputPath('nexus-ai-panel.png'), fullPage: true }); const errorBanner = page.getByText('Database not ready'); expect(await errorBanner.isVisible().catch(() => false)).toBe(false); @@ -95,8 +100,8 @@ test.describe('Nexus AI', () => { }); test.describe('Processes Panel', () => { - test('shows process list and View button works', async ({ page }, testInfo) => { - await waitForGraphLoaded(page, testInfo); + test('shows process list and View button works', async ({ page }) => { + await waitForGraphLoaded(page); await page.getByRole('button', { name: 'Nexus AI' }).click(); await page.getByText('Processes').click(); @@ -104,7 +109,6 @@ test.describe('Processes Panel', () => { await expect(page.locator('[data-testid="process-list-loaded"]')).toBeVisible({ timeout: 15_000, }); - await page.screenshot({ path: testInfo.outputPath('processes-panel.png'), fullPage: true }); const processRow = page.locator('[data-testid="process-row"]').first(); await expect(processRow).toBeVisible({ timeout: 10_000 }); @@ -114,14 +118,10 @@ test.describe('Processes Panel', () => { await viewBtn.waitFor({ state: 'visible', timeout: 5_000 }); await viewBtn.click(); await expect(page.locator('[data-testid="process-modal"]')).toBeVisible({ timeout: 5_000 }); - await page.screenshot({ - path: testInfo.outputPath('process-view-clicked.png'), - fullPage: true, - }); }); - test('lightbulb highlights nodes in graph', async ({ page }, testInfo) => { - await waitForGraphLoaded(page, testInfo); + test('lightbulb highlights nodes in graph', async ({ page }) => { + await waitForGraphLoaded(page); await page.getByRole('button', { name: 'Nexus AI' }).click(); await page.getByText('Processes').click(); @@ -137,13 +137,12 @@ test.describe('Processes Panel', () => { await lightbulb.waitFor({ state: 'visible', timeout: 5_000 }); await lightbulb.click(); await expect(processRow).toHaveClass(/bg-amber-950/, { timeout: 5_000 }); - await page.screenshot({ path: testInfo.outputPath('after-highlight.png'), fullPage: true }); }); }); test.describe('Turn Off All Highlights', () => { - test('selecting a node dims others, button clears it', async ({ page }, testInfo) => { - await waitForGraphLoaded(page, testInfo); + test('selecting a node dims others, button clears it', async ({ page }) => { + await waitForGraphLoaded(page); await expect(page.locator('canvas').first()).toBeVisible({ timeout: 10_000 }); @@ -160,6 +159,5 @@ test.describe('Turn Off All Highlights', () => { await expect(highlightToggle).toHaveAttribute('title', 'Turn on AI highlights', { timeout: 5_000, }); - await page.screenshot({ path: testInfo.outputPath('highlights-cleared.png'), fullPage: true }); }); }); diff --git a/gitnexus-web/src/components/StatusBar.tsx b/gitnexus-web/src/components/StatusBar.tsx index a618c3c1c..7468072fa 100644 --- a/gitnexus-web/src/components/StatusBar.tsx +++ b/gitnexus-web/src/components/StatusBar.tsx @@ -64,7 +64,7 @@ export const StatusBar = () => { {/* Right - Stats */} -
+
{graph && ( <> {nodeCount} nodes diff --git a/gitnexus-web/src/services/backend-client.ts b/gitnexus-web/src/services/backend-client.ts index 256219eb6..2c04b66dd 100644 --- a/gitnexus-web/src/services/backend-client.ts +++ b/gitnexus-web/src/services/backend-client.ts @@ -404,13 +404,18 @@ export const fetchGraph = async ( onProgress?: (downloaded: number, total: number | null) => void; }, ): Promise<{ nodes: GraphNode[]; relationships: GraphRelationship[] }> => { - const params = [repoParam(repo), opts?.includeContent ? 'includeContent=true' : ''] + const params = [repoParam(repo), opts?.includeContent ? 'includeContent=true' : '', 'stream=true'] .filter(Boolean) .join('&'); const url = `${_backendUrl}/api/graph${params ? `?${params}` : ''}`; const response = await fetchWithTimeout(url, { signal: opts?.signal }, 60_000); await assertOk(response); + const contentType = response.headers.get('Content-Type') || ''; + if (contentType.includes('application/x-ndjson')) { + return parseNdjsonGraphResponse(response, opts?.onProgress); + } + if (!opts?.onProgress || !response.body) { return response.json() as Promise<{ nodes: GraphNode[]; relationships: GraphRelationship[] }>; } @@ -439,6 +444,66 @@ export const fetchGraph = async ( return JSON.parse(new TextDecoder().decode(combined)); }; +const parseNdjsonGraphResponse = async ( + response: Response, + onProgress?: (downloaded: number, total: number | null) => void, +): Promise<{ nodes: GraphNode[]; relationships: GraphRelationship[] }> => { + if (!response.body) { + throw new BackendError('No response body', response.status, 'server'); + } + + const contentLength = response.headers.get('Content-Length'); + const total = contentLength ? parseInt(contentLength, 10) : null; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + const nodes: GraphNode[] = []; + const relationships: GraphRelationship[] = []; + let buffer = ''; + let downloaded = 0; + + const parseLine = (line: string) => { + const trimmed = line.trim(); + if (!trimmed) return; + + const record = JSON.parse(trimmed) as + | { type: 'node'; data: GraphNode } + | { type: 'relationship'; data: GraphRelationship } + | { type: 'error'; error: string }; + + if (record.type === 'node') { + nodes.push(record.data); + return; + } + if (record.type === 'relationship') { + relationships.push(record.data); + return; + } + if (record.type === 'error') { + throw new BackendError(record.error, response.status || 500, 'server'); + } + }; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + downloaded += value.length; + onProgress?.(downloaded, total); + buffer += decoder.decode(value, { stream: true }); + + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const line of lines) { + parseLine(line); + } + } + + buffer += decoder.decode(); + parseLine(buffer); + + return { nodes, relationships }; +}; + /** Execute a Cypher query. Returns rows. */ export const runQuery = async ( cypher: string, diff --git a/gitnexus-web/test/unit/server-connection.test.ts b/gitnexus-web/test/unit/server-connection.test.ts index a818adb13..f5ee43c53 100644 --- a/gitnexus-web/test/unit/server-connection.test.ts +++ b/gitnexus-web/test/unit/server-connection.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from 'vitest'; -import { normalizeServerUrl } from '../../src/services/backend-client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { fetchGraph, normalizeServerUrl, setBackendUrl } from '../../src/services/backend-client'; describe('normalizeServerUrl', () => { it('adds http:// to localhost', () => { @@ -31,3 +31,137 @@ describe('normalizeServerUrl', () => { expect(normalizeServerUrl('https://gitnexus.example.com')).toBe('https://gitnexus.example.com'); }); }); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('fetchGraph', () => { + it('requests streamed graph responses from the backend', async () => { + setBackendUrl('http://localhost:4747'); + + const fetchMock = vi.fn().mockResolvedValue( + new Response('{"nodes":[],"relationships":[]}', { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + await fetchGraph('big-repo'); + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('/api/graph?repo=big-repo&stream=true'), + expect.any(Object), + ); + }); + + it('parses NDJSON graph streams incrementally', async () => { + setBackendUrl('http://localhost:4747'); + + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + [ + '{"type":"node","data":{"id":"File:src/app.ts","label":"File","properties":{"name":"app.ts","filePath":"src/app.ts"}}}\n', + '{"type":"relationship","data":{"id":"File:src/app.ts_CONTAINS_Function:src/app.ts:main","type":"CONTAINS","sourceId":"File:src/app.ts","targetId":"Function:src/app.ts:main"}}\n', + ].join(''), + ), + ); + controller.close(); + }, + }); + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(stream, { + status: 200, + headers: { + 'Content-Type': 'application/x-ndjson', + }, + }), + ), + ); + + const progress = vi.fn(); + const result = await fetchGraph('big-repo', { onProgress: progress }); + + expect(result.nodes).toHaveLength(1); + expect(result.relationships).toHaveLength(1); + expect(result.nodes[0].id).toBe('File:src/app.ts'); + expect(result.relationships[0].type).toBe('CONTAINS'); + expect(progress).toHaveBeenCalled(); + }); + + it('parses NDJSON graph lines split across chunks', async () => { + setBackendUrl('http://localhost:4747'); + + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + '{"type":"node","data":{"id":"File:src/app.ts","label":"File","properties":{"name":"app.ts"', + ), + ); + controller.enqueue( + encoder.encode( + ',"filePath":"src/app.ts"}}}\n{"type":"relationship","data":{"id":"File:src/app.ts_CONTAINS_Function:src/app.ts:main","type":"CONTAINS","sourceId":"File:src/app.ts","targetId":"Function:src/app.ts:main"}}\n', + ), + ); + controller.close(); + }, + }); + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(stream, { + status: 200, + headers: { + 'Content-Type': 'application/x-ndjson', + }, + }), + ), + ); + + const result = await fetchGraph('big-repo'); + + expect(result.nodes).toHaveLength(1); + expect(result.relationships).toHaveLength(1); + expect(result.nodes[0].properties.filePath).toBe('src/app.ts'); + }); + + it('throws backend errors emitted in the NDJSON stream', async () => { + setBackendUrl('http://localhost:4747'); + + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('{"type":"error","error":"stream failed"}\n')); + controller.close(); + }, + }); + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(stream, { + status: 200, + headers: { + 'Content-Type': 'application/x-ndjson', + }, + }), + ), + ); + + await expect(fetchGraph('big-repo')).rejects.toMatchObject({ + message: 'stream failed', + }); + }); +}); diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index 1363257d5..88a6e9bba 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -637,6 +637,34 @@ export const executeQuery = async (cypher: string): Promise => { return rows; }; +export const streamQuery = async ( + cypher: string, + onRow: (row: any) => void | Promise, +): Promise => { + if (!conn) { + throw new Error('LadybugDB not initialized. Call initLbug first.'); + } + + const queryResult = await conn.query(cypher); + const result = Array.isArray(queryResult) ? queryResult[0] : queryResult; + let rowCount = 0; + + try { + while (await result.hasNext()) { + const row = await result.getNext(); + await onRow(row); + rowCount++; + } + return rowCount; + } finally { + try { + await result.close(); + } catch { + // Best-effort cleanup only. + } + } +}; + /** * Execute a single parameterized query (prepare/execute pattern). * Prevents Cypher injection by binding values as parameters. diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index 8422af6ba..8111c287b 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -18,6 +18,7 @@ import { executeQuery, executePrepared, executeWithReusedStatement, + streamQuery, closeLbug, withLbugDb, } from '../core/lbug/lbug-adapter.js'; @@ -105,75 +106,228 @@ export const isAllowedOrigin = (origin: string | undefined): boolean => { return false; }; +type GraphStreamRecord = + | { type: 'node'; data: GraphNode } + | { type: 'relationship'; data: GraphRelationship } + | { type: 'error'; error: string }; + +export class ClientDisconnectedError extends Error { + constructor() { + super('Client disconnected during graph stream'); + this.name = 'ClientDisconnectedError'; + } +} + +export const isIgnorableGraphQueryError = (err: unknown): boolean => { + const message = err instanceof Error ? err.message : String(err); + return ( + message.includes('does not exist') || + message.includes('not found') || + message.includes('No table named') + ); +}; + +const ensureStreamIsWritable = (res: express.Response, signal?: AbortSignal): void => { + if (signal?.aborted || res.destroyed || res.writableEnded) { + throw new ClientDisconnectedError(); + } +}; + +const waitForDrain = async (res: express.Response, signal?: AbortSignal): Promise => { + ensureStreamIsWritable(res, signal); + + await new Promise((resolve, reject) => { + const cleanup = () => { + res.off('drain', onDrain); + res.off('close', onClose); + signal?.removeEventListener('abort', onAbort); + }; + + const onDrain = () => { + cleanup(); + resolve(); + }; + const onClose = () => { + cleanup(); + reject(new ClientDisconnectedError()); + }; + const onAbort = () => { + cleanup(); + reject(new ClientDisconnectedError()); + }; + + res.once('drain', onDrain); + res.once('close', onClose); + signal?.addEventListener('abort', onAbort, { once: true }); + + if (signal?.aborted || res.destroyed || res.writableEnded) { + onAbort(); + } + }); + + ensureStreamIsWritable(res, signal); +}; + +const isClientDisconnectWriteError = (err: unknown): boolean => { + if (!(err instanceof Error)) return false; + return ( + (err as NodeJS.ErrnoException).code === 'ERR_STREAM_DESTROYED' || + (err as NodeJS.ErrnoException).code === 'EPIPE' || + (err as NodeJS.ErrnoException).code === 'ECONNRESET' || + err.message.includes('write after end') + ); +}; + +export const writeNdjsonRecord = async ( + res: express.Response, + record: GraphStreamRecord, + signal?: AbortSignal, +): Promise => { + ensureStreamIsWritable(res, signal); + + try { + const canContinue = res.write(JSON.stringify(record) + '\n'); + if (!canContinue) { + await waitForDrain(res, signal); + } + } catch (err) { + if (isClientDisconnectWriteError(err)) { + throw new ClientDisconnectedError(); + } + throw err; + } +}; + const buildGraph = async ( includeContent = false, ): Promise<{ nodes: GraphNode[]; relationships: GraphRelationship[] }> => { const nodes: GraphNode[] = []; for (const table of NODE_TABLES) { try { - let query = ''; - if (table === 'File') { - query = includeContent - ? `MATCH (n:File) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.content AS content` - : `MATCH (n:File) RETURN n.id AS id, n.name AS name, n.filePath AS filePath`; - } else if (table === 'Folder') { - query = `MATCH (n:Folder) RETURN n.id AS id, n.name AS name, n.filePath AS filePath`; - } else if (table === 'Community') { - query = `MATCH (n:Community) RETURN n.id AS id, n.label AS label, n.heuristicLabel AS heuristicLabel, n.cohesion AS cohesion, n.symbolCount AS symbolCount`; - } else if (table === 'Process') { - query = `MATCH (n:Process) RETURN n.id AS id, n.label AS label, n.heuristicLabel AS heuristicLabel, n.processType AS processType, n.stepCount AS stepCount, n.communities AS communities, n.entryPointId AS entryPointId, n.terminalId AS terminalId`; - } else { - query = includeContent - ? `MATCH (n:${table}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine, n.content AS content` - : `MATCH (n:${table}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine`; - } - - const rows = await executeQuery(query); + const rows = await executeQuery(getNodeQuery(table, includeContent)); for (const row of rows) { - nodes.push({ - id: row.id ?? row[0], - label: table as GraphNode['label'], - properties: { - name: row.name ?? row.label ?? row[1], - filePath: row.filePath ?? row[2], - startLine: row.startLine, - endLine: row.endLine, - content: includeContent ? row.content : undefined, - heuristicLabel: row.heuristicLabel, - cohesion: row.cohesion, - symbolCount: row.symbolCount, - processType: row.processType, - stepCount: row.stepCount, - communities: row.communities, - entryPointId: row.entryPointId, - terminalId: row.terminalId, - } as GraphNode['properties'], - }); + nodes.push(mapGraphNodeRow(table, row, includeContent)); + } + } catch (err) { + if (!isIgnorableGraphQueryError(err)) { + throw err; } - } catch { - // ignore empty tables } } const relationships: GraphRelationship[] = []; - const relRows = await executeQuery( - `MATCH (a)-[r:CodeRelation]->(b) RETURN a.id AS sourceId, b.id AS targetId, r.type AS type, r.confidence AS confidence, r.reason AS reason, r.step AS step`, - ); + const relRows = await executeQuery(GRAPH_RELATIONSHIP_QUERY); for (const row of relRows) { - relationships.push({ - id: `${row.sourceId}_${row.type}_${row.targetId}`, - type: row.type, - sourceId: row.sourceId, - targetId: row.targetId, - confidence: row.confidence, - reason: row.reason, - step: row.step, - }); + relationships.push(mapGraphRelationshipRow(row)); } return { nodes, relationships }; }; +const GRAPH_RELATIONSHIP_QUERY = + `MATCH (a)-[r:CodeRelation]->(b) RETURN a.id AS sourceId, b.id AS targetId, ` + + `r.type AS type, r.confidence AS confidence, r.reason AS reason, r.step AS step`; + +const quoteNodeTable = (table: string): string => `\`${table.replace(/`/g, '``')}\``; + +const getNodeQuery = (table: string, includeContent: boolean): string => { + const tableLabel = quoteNodeTable(table); + + if (table === 'File') { + return includeContent + ? `MATCH (n:${tableLabel}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.content AS content` + : `MATCH (n:${tableLabel}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath`; + } + if (table === 'Folder') { + return `MATCH (n:${tableLabel}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath`; + } + if (table === 'Community') { + return `MATCH (n:${tableLabel}) RETURN n.id AS id, n.label AS label, n.heuristicLabel AS heuristicLabel, n.cohesion AS cohesion, n.symbolCount AS symbolCount`; + } + if (table === 'Process') { + return `MATCH (n:${tableLabel}) RETURN n.id AS id, n.label AS label, n.heuristicLabel AS heuristicLabel, n.processType AS processType, n.stepCount AS stepCount, n.communities AS communities, n.entryPointId AS entryPointId, n.terminalId AS terminalId`; + } + if (table === 'Route') { + return `MATCH (n:${tableLabel}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.responseKeys AS responseKeys, n.errorKeys AS errorKeys, n.middleware AS middleware`; + } + if (table === 'Tool') { + return `MATCH (n:${tableLabel}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.description AS description`; + } + return includeContent + ? `MATCH (n:${tableLabel}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine, n.content AS content` + : `MATCH (n:${tableLabel}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine`; +}; + +const mapGraphNodeRow = (table: string, row: any, includeContent: boolean): GraphNode => ({ + id: row.id ?? row[0], + label: table as GraphNode['label'], + properties: { + name: row.name ?? row.label ?? row[1], + filePath: row.filePath ?? row[2], + startLine: row.startLine, + endLine: row.endLine, + content: includeContent ? row.content : undefined, + responseKeys: row.responseKeys, + errorKeys: row.errorKeys, + middleware: row.middleware, + heuristicLabel: row.heuristicLabel, + cohesion: row.cohesion, + symbolCount: row.symbolCount, + description: row.description, + processType: row.processType, + stepCount: row.stepCount, + communities: row.communities, + entryPointId: row.entryPointId, + terminalId: row.terminalId, + } as GraphNode['properties'], +}); + +const mapGraphRelationshipRow = (row: any): GraphRelationship => ({ + id: `${row.sourceId}_${row.type}_${row.targetId}`, + type: row.type, + sourceId: row.sourceId, + targetId: row.targetId, + confidence: row.confidence, + reason: row.reason, + step: row.step, +}); + +export const streamGraphNdjson = async ( + res: express.Response, + includeContent = false, + signal?: AbortSignal, +): Promise => { + for (const table of NODE_TABLES) { + try { + await streamQuery(getNodeQuery(table, includeContent), async (row) => { + await writeNdjsonRecord( + res, + { + type: 'node', + data: mapGraphNodeRow(table, row, includeContent), + }, + signal, + ); + }); + } catch (err) { + if (!isIgnorableGraphQueryError(err)) { + throw err; + } + } + } + + await streamQuery(GRAPH_RELATIONSHIP_QUERY, async (row) => { + await writeNdjsonRecord( + res, + { + type: 'relationship', + data: mapGraphRelationshipRow(row), + }, + signal, + ); + }); +}; + /** * Mount an SSE progress endpoint for a JobManager. * Handles: initial state, terminal events, heartbeat, event IDs, client disconnect. @@ -464,10 +618,60 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => } const lbugPath = path.join(entry.storagePath, 'lbug'); const includeContent = req.query.includeContent === 'true'; + const stream = req.query.stream === 'true'; + + if (stream) { + const abortController = new AbortController(); + let responseFinished = false; + const markFinished = () => { + responseFinished = true; + }; + const abortStreaming = () => { + if (!responseFinished) { + abortController.abort(); + } + }; + + res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8'); + res.setHeader('Cache-Control', 'no-cache'); + res.flushHeaders(); + + req.once('aborted', abortStreaming); + res.once('finish', markFinished); + res.once('close', abortStreaming); + + try { + await withLbugDb(lbugPath, async () => + streamGraphNdjson(res, includeContent, abortController.signal), + ); + if (!abortController.signal.aborted && !res.writableEnded) { + res.end(); + } + } finally { + req.off('aborted', abortStreaming); + res.off('finish', markFinished); + res.off('close', abortStreaming); + } + return; + } + const graph = await withLbugDb(lbugPath, async () => buildGraph(includeContent)); res.json(graph); } catch (err: any) { - res.status(500).json({ error: err.message || 'Failed to build graph' }); + if (err instanceof ClientDisconnectedError) { + return; + } + const message = err.message || 'Failed to build graph'; + if (res.headersSent) { + try { + res.write(JSON.stringify({ type: 'error', error: message }) + '\n'); + } catch { + // Best-effort only after streaming has started. + } + res.end(); + return; + } + res.status(500).json({ error: message }); } }); diff --git a/gitnexus/test/unit/api-graph-streaming.test.ts b/gitnexus/test/unit/api-graph-streaming.test.ts new file mode 100644 index 000000000..0cc69833c --- /dev/null +++ b/gitnexus/test/unit/api-graph-streaming.test.ts @@ -0,0 +1,235 @@ +import { EventEmitter } from 'node:events'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +const { lbugMocks } = vi.hoisted(() => ({ + lbugMocks: { + streamQuery: vi.fn(), + }, +})); + +vi.mock('../../src/core/lbug/lbug-adapter.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, ...lbugMocks }; +}); + +import { ClientDisconnectedError, streamGraphNdjson } from '../../src/server/api.js'; + +const createMockResponse = (writeImpl?: (chunk: string) => boolean) => { + const response = new EventEmitter() as any; + response.writableEnded = false; + response.destroyed = false; + response.write = vi.fn((chunk: string) => (writeImpl ? writeImpl(chunk) : true)); + return response; +}; + +describe('streamGraphNdjson', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('waits for drain when writes hit backpressure', async () => { + lbugMocks.streamQuery.mockImplementation( + async (query: string, onRow: (row: any) => Promise) => { + if (query.includes('MATCH (n:`File`)')) { + await onRow({ id: 'File:src/app.ts', name: 'app.ts', filePath: 'src/app.ts' }); + return 1; + } + if (query.includes('CodeRelation')) { + await onRow({ + sourceId: 'File:src/app.ts', + targetId: 'Function:src/app.ts:main', + type: 'CONTAINS', + }); + return 1; + } + return 0; + }, + ); + + const writes: string[] = []; + let firstWrite = true; + const response = createMockResponse((chunk) => { + writes.push(chunk); + if (firstWrite) { + firstWrite = false; + return false; + } + return true; + }); + + let settled = false; + const pending = streamGraphNdjson(response, false).then(() => { + settled = true; + }); + + await Promise.resolve(); + expect(writes).toHaveLength(1); + expect(settled).toBe(false); + + response.emit('drain'); + await pending; + + expect(writes).toHaveLength(2); + }); + + it('stops streaming when the client disconnects', async () => { + const controller = new AbortController(); + lbugMocks.streamQuery.mockImplementation( + async (query: string, onRow: (row: any) => Promise) => { + if (!query.includes('MATCH (n:`File`)')) { + return 0; + } + await onRow({ id: 'File:src/app.ts', name: 'app.ts', filePath: 'src/app.ts' }); + controller.abort(); + await onRow({ id: 'File:src/other.ts', name: 'other.ts', filePath: 'src/other.ts' }); + return 2; + }, + ); + + const response = createMockResponse(); + + await expect(streamGraphNdjson(response, false, controller.signal)).rejects.toBeInstanceOf( + ClientDisconnectedError, + ); + expect(response.write).toHaveBeenCalledTimes(1); + }); + + it('rethrows non-missing table errors', async () => { + lbugMocks.streamQuery.mockImplementation(async (query: string) => { + if (query.includes('MATCH (n:`File`)')) { + throw new Error('database unavailable'); + } + return 0; + }); + + const response = createMockResponse(); + await expect(streamGraphNdjson(response, false)).rejects.toThrow('database unavailable'); + }); + + it('ignores missing-table errors while continuing the stream', async () => { + lbugMocks.streamQuery.mockImplementation( + async (query: string, onRow: (row: any) => Promise) => { + if (query.includes('MATCH (n:`File`)')) { + throw new Error('Table File does not exist'); + } + if (query.includes('CodeRelation')) { + await onRow({ + sourceId: 'File:src/app.ts', + targetId: 'Function:src/app.ts:main', + type: 'CONTAINS', + }); + return 1; + } + return 0; + }, + ); + + const response = createMockResponse(); + await expect(streamGraphNdjson(response, false)).resolves.toBeUndefined(); + expect(response.write).toHaveBeenCalledTimes(1); + }); + + it('quotes node table names in generated Cypher queries', async () => { + lbugMocks.streamQuery.mockImplementation(async () => 0); + + const response = createMockResponse(); + await expect(streamGraphNdjson(response, false)).resolves.toBeUndefined(); + + expect(lbugMocks.streamQuery).toHaveBeenCalledWith( + expect.stringContaining('MATCH (n:`Macro`)'), + expect.any(Function), + ); + }); + + it('streams Route and Tool nodes without requiring startLine fields', async () => { + lbugMocks.streamQuery.mockImplementation( + async (query: string, onRow: (row: any) => Promise) => { + if (query.includes('MATCH (n:`Route`)')) { + expect(query).not.toContain('startLine'); + await onRow({ + id: 'Route:/api/graph:GET', + name: 'GET /api/graph', + filePath: 'src/server/api.ts', + responseKeys: ['nodes', 'relationships'], + errorKeys: ['error'], + middleware: ['withAuth'], + }); + return 1; + } + if (query.includes('MATCH (n:`Tool`)')) { + expect(query).not.toContain('startLine'); + await onRow({ + id: 'Tool:gitnexus_query', + name: 'gitnexus_query', + filePath: 'src/mcp/resources.ts', + description: 'Query the code graph', + }); + return 1; + } + return 0; + }, + ); + + const writes: string[] = []; + const response = createMockResponse((chunk) => { + writes.push(chunk); + return true; + }); + + await expect(streamGraphNdjson(response, false)).resolves.toBeUndefined(); + + const records = writes.map((chunk) => JSON.parse(chunk)); + expect(records).toContainEqual({ + type: 'node', + data: { + id: 'Route:/api/graph:GET', + label: 'Route', + properties: { + name: 'GET /api/graph', + filePath: 'src/server/api.ts', + startLine: undefined, + endLine: undefined, + content: undefined, + responseKeys: ['nodes', 'relationships'], + errorKeys: ['error'], + middleware: ['withAuth'], + heuristicLabel: undefined, + cohesion: undefined, + symbolCount: undefined, + description: undefined, + processType: undefined, + stepCount: undefined, + communities: undefined, + entryPointId: undefined, + terminalId: undefined, + }, + }, + }); + expect(records).toContainEqual({ + type: 'node', + data: { + id: 'Tool:gitnexus_query', + label: 'Tool', + properties: { + name: 'gitnexus_query', + filePath: 'src/mcp/resources.ts', + startLine: undefined, + endLine: undefined, + content: undefined, + responseKeys: undefined, + errorKeys: undefined, + middleware: undefined, + heuristicLabel: undefined, + cohesion: undefined, + symbolCount: undefined, + description: 'Query the code graph', + processType: undefined, + stepCount: undefined, + communities: undefined, + entryPointId: undefined, + terminalId: undefined, + }, + }, + }); + }); +}); From d09078925ee8a243da8f133710a45d5ba4f88837 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:41:28 +0100 Subject: [PATCH 02/67] Extract `resolveFreeCall` from `resolveCallTarget` (SM-13) (#756) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * feat(SM-13): extract resolveFreeCall from resolveCallTarget Extract the free-function call resolution path into a dedicated `resolveFreeCall(calledName, filePath, ctx)` function that uses `lookupExact` + import-scoped resolution via `ctx.resolve()`. - Free function calls (foo()) now route through `resolveFreeCall` - Swift/Kotlin implicit constructors (User()) delegate to `resolveStaticCall` within `resolveFreeCall` - `resolveCallTarget` dispatches `callForm === 'free'` early, removing the inline freeFormHasClassTarget logic - S0 block simplified to only handle `callForm === 'constructor'` - Global (Tier 3) fallthrough preserved via ctx.resolve() until Phase 5 - 9 new unit tests for resolveFreeCall - All 163 unit tests pass, all 1199 integration resolver tests pass Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c5f2e73a-259a-438c-b5c8-286b82e3c215 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * chore: revert unrelated package-lock.json change Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c5f2e73a-259a-438c-b5c8-286b82e3c215 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix(SM-13): address PR #756 review findings on resolveFreeCall Addresses all 7 findings from the PR #756 review comment. Code (R1, finding #1) - Replace the literal `'Class' | 'Struct' | 'Record'` check in `hasClassTarget` with `INSTANTIABLE_CLASS_TYPES.has(c.type)`. Converts an invariant that was previously comment-enforced ("keep this list aligned with INSTANTIABLE_CLASS_TYPES") into one enforced structurally. Any future extension of the set propagates here automatically. The narrower Swift extension dedup block below still uses literal `'Class' | 'Struct'` by design — Swift extensions only produce Class duplicates in practice, Record is deliberately excluded there, and the inline comment now documents that asymmetry. Tests (+12 regression scenarios) Finding #2 — language coverage - Go free function (doStuff()) - Python free function (def helper(): ... helper()) - Rust free function outside any impl block - Java statically-imported function - JavaScript module-level function Each exercises `_resolveCallTargetForTesting` with `callForm='free'` and the language-specific file extension. `resolveFreeCall` has no file-extension branching, so these guard the dispatch chain per language without assuming extractor-specific symbol shapes. Finding #3 — argCount threading - 2-arg overload selected when argCount=2 - 0-arg overload selected when argCount=0 Finding #5 — Tier 3 (global) resolution - Function globally visible but not imported. Asserts exact `TIER_CONFIDENCE.global === 0.5` and `reason === 'global'` to catch silent drift if the tier table is ever refactored. Finding #6 — preComputedArgTypes worker path - String overload matched via preComputedArgTypes=['String'] - Int overload matched via preComputedArgTypes=['int'] (lowercase, mirroring the parse-worker's inferred-literal shape; stored 'Int' is normalized via normalizeJvmTypeName at comparison time) Finding #7 — Enum null-route documentation - Enum-only free call asserts `toBeNull()` with an explanatory comment linking to the INSTANTIABLE_CLASS_TYPES rationale. NOT marked skipped — current behavior is intentional, not broken. Finding #4 — Swift extension dedup guard - Two same-name Class entries at different path lengths; exercises the full dispatch chain: 1. filterCallableCandidates with 'free' strips Class → length 0 2. hasClassTarget triggers resolveStaticCall 3. Homonym ambiguity null-routes per SM-12 round-1 contract 4. Constructor-form retry repopulates with both Classes 5. Dedup block sorts by filePath.length → shortest path wins Verification - `tsc --noEmit` clean - 3064 unit tests pass (+12) - 1766 integration tests pass - Zero regressions Plan: docs/plans/2026-04-09-003-fix-sm13-resolve-free-call-review-findings-plan.md Review: https://github.com/abhigyanpatwari/GitNexus/pull/756#issuecomment-4213879002 * refactor(SM-13): extract dedupSwiftExtensionCandidates shared helper Follow-up to the PR #756 review fix. SM-13 duplicated the Swift extension same-name collision dedup block between `resolveCallTarget` and `resolveFreeCall` — two copies of identical 15-line logic with the same heuristic (`filePath.length` sort, Class/Struct-only, `length > 1` guard). Extract a single shared helper so the two sites cannot drift. Changes - New `dedupSwiftExtensionCandidates(candidates, tier)` helper defined alongside `tryOverloadDisambiguation`, with JSDoc documenting: - The Swift extension scenario it addresses - Why it is intentionally narrower than INSTANTIABLE_CLASS_TYPES (Class/Struct only, not Record — C#/Kotlin records don't exhibit the multi-file definition pattern, widening risks accidental dedup of legitimately distinct record types) - The return-null-on-no-match contract so callers can fall through - `resolveCallTarget` tail dedup (was lines 1593-1610): replaced with a single `dedupSwiftExtensionCandidates` call - `resolveFreeCall` tail dedup (was lines 1994-2012): same replacement - Net line count: -32 insertions, -9 deletions in the consumer sites, +36 for the shared helper + JSDoc Verification - `tsc --noEmit` clean - 3064 unit tests pass (including the R7 Swift dedup guard test added in the previous commit that exercises the full free-form retry chain through this helper) - 1766 integration tests pass - Zero regressions Follows-up on: https://github.com/abhigyanpatwari/GitNexus/pull/756 * docs(SM-13): address PR #756 final review — comment cleanup only Three documentation-only findings from the approval review. No behavior change, no new tests, no code path modifications. Finding #1 — stale line-number comment - The comment inside `resolveFreeCall` at the `hasClassTarget` site referenced "lines ~1994-2008" for the Swift extension dedup block. Those lines were the inlined pre-SM-13 version; the block has since been extracted to `dedupSwiftExtensionCandidates`. Replaced the line reference with the helper name so future readers don't chase dead line numbers. Finding #2 — fuzzy-widening asymmetry undocumented - `resolveFreeCall` intentionally has no `widenCache` parameter and no D2 fuzzy-widening pass (unlike `resolveCallTarget`'s member-call path). Added an explicit "Asymmetry vs `resolveCallTarget`" paragraph to the JSDoc so a caller comparing the two signatures knows the skipped pass is deliberate and tied to Phase 5. Finding #3 — constructor-form retry reasons undocumented - `resolveStaticCall` can return null for three distinct reasons (empty instantiable pool, homonym ambiguity, ownerless Constructor nodes). The retry below it unconditionally re-filters with `'constructor'` form, which is correct for all three but not obvious. Added a structured three-case comment enumerating each reason and linking (a) to the SM-12 null-route contract, (b) to the R7 dedup test, and (c) to the currently-uncovered ownerless- Constructor path (noted as a future test candidate). Verification - `tsc --noEmit` clean - 175 `resolveFreeCall` + `resolveStaticCall` + sibling tests pass (sanity check — no behavior change expected) - No regressions Follows-up on: https://github.com/abhigyanpatwari/GitNexus/pull/756#issuecomment-4215739052 * test(SM-13): cover ownerless-Constructor retry + PHP free function Two low-severity test gaps from PR #756 review comment 4215739052 — previously addressed doc-only, now have concrete test coverage. Finding #3 low — ownerless-Constructor retry path (previously comment-only) - The retry after resolveStaticCall returns null handles three distinct null-return reasons. Cases (a) and (b) were already tested (Interface/ Trait null-route from SM-12, Swift shadowing dedup from R7). Case (c) — resolveStaticCall step-4 bailout when the tiered pool contains ownerless Constructor nodes — was only covered by a comment. - New test: Class + ownerless Constructor in tiered pool, callForm='free'. Exercises the full chain: 1. resolveStaticCall step 3 walks classCandidates via lookupMethodByOwner — ownerless Constructor not in methodByOwner, nothing found. 2. Step 4 detects Constructor in tiered pool, bails with null. 3. resolveFreeCall retry re-runs filterCallableCandidates with 'constructor' form, which prefers Constructor over Class per CONSTRUCTOR_TARGET_TYPES ordering. 4. Single survivor returned. - Asserts the Constructor node (not the Class) is the resolved target. Low — PHP free function coverage gap - The language coverage table in the same review flagged PHP free functions (top-level `function helper()` outside any class) as uncovered. Added a test mirroring the existing Go/Python/Rust/Java/ JS language tests — exercises the `.php` dispatch path for free calls. Ruby and C/C++ remain uncovered; deferred to a future round since those languages also have other gaps in the broader test file. Verification - `tsc --noEmit` clean - 3066 unit tests pass (+2 new regression tests) - 1766 integration tests pass - Zero regressions Follows-up on: https://github.com/abhigyanpatwari/GitNexus/pull/756#issuecomment-4215739052 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> Co-authored-by: Gergo Magyar --- gitnexus/src/core/ingestion/call-processor.ts | 245 +++++++--- gitnexus/test/unit/symbol-table.test.ts | 441 ++++++++++++++++++ 2 files changed, 632 insertions(+), 54 deletions(-) diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 2a47d5f39..1dadcaf54 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -1317,6 +1317,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() @@ -1370,6 +1408,20 @@ const resolveCallTarget = ( 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, @@ -1377,13 +1429,10 @@ const resolveCallTarget = ( ); // S0. Constructor/static fast path (SM-12): O(1) class + constructor lookup - // via lookupClassByName + lookupMethodByOwner before falling back to the - // existing filtering + fuzzy-widening path. Falls back to the class node - // itself when no Constructor symbol is indexed for the type. - // - // Handles: - // (a) callForm === 'constructor' — explicit `new User()` in Java/TS/C#/etc. - // (b) callForm === 'free' with class target — implicit `User()` in Swift/Kotlin + // 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): @@ -1392,21 +1441,7 @@ const resolveCallTarget = ( // S0 to cover them would require threading receiver-type resolution // through the module-alias logic; revisit if it shows up as a hot // spot. - // - // The `.some()` trigger below must stay aligned with - // `INSTANTIABLE_CLASS_TYPES` — any type admitted here that is not in - // that set will cause S0 → `resolveStaticCall` to run and return null, - // wasting two lookup passes per call. `Enum` is deliberately excluded - // (same rationale as `INSTANTIABLE_CLASS_TYPES`); `Record` is included - // so C# records and Kotlin data classes reach the fast path. - const freeFormHasClassTarget = - call.callForm === 'free' && - filteredCandidates.length === 0 && - tiered.candidates.some((c) => c.type === 'Class' || c.type === 'Struct' || c.type === 'Record'); - if (call.callForm === 'constructor' || freeFormHasClassTarget) { - // Reuse the pre-computed `tiered` result — resolveStaticCall's class name - // is identical to `call.calledName` here, so re-running ctx.resolve would - // duplicate the tiered-lookup work performed at the top of this function. + if (call.callForm === 'constructor') { const staticResult = resolveStaticCall( call.calledName, currentFile, @@ -1417,22 +1452,6 @@ const resolveCallTarget = ( if (staticResult) return staticResult; } - // 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') { - // `freeFormHasClassTarget` was already computed for the S0 fast path - // above under the same `callForm === 'free' && filteredCandidates.length === 0` - // precondition. Reuse it to avoid a second `.some()` scan on the same pool. - if (freeFormHasClassTarget) { - filteredCandidates = filterCallableCandidates( - tiered.candidates, - call.argCount, - 'constructor', - ); - } - } - // Module-qualified constructor pattern: e.g. Python `import models; models.User()`. // The attribute access gives callForm='member', but the callee may be a Class — a valid // constructor target. Re-try with constructor-form filtering so that `module.ClassName()` @@ -1610,22 +1629,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; } @@ -1929,6 +1937,135 @@ export const resolveMemberCall = ( 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) // --------------------------------------------------------------------------- diff --git a/gitnexus/test/unit/symbol-table.test.ts b/gitnexus/test/unit/symbol-table.test.ts index b861c31e7..9378a6008 100644 --- a/gitnexus/test/unit/symbol-table.test.ts +++ b/gitnexus/test/unit/symbol-table.test.ts @@ -1417,6 +1417,7 @@ describe('lookupMethodByOwnerWithMRO', () => { import { _resolveCallTargetForTesting, resolveMemberCall, + resolveFreeCall, type OverloadHints, } from '../../src/core/ingestion/call-processor.js'; @@ -2329,3 +2330,443 @@ describe('resolveStaticCall', () => { expect(result!.nodeId).toBe('ctor:User:2'); }); }); + +// --------------------------------------------------------------------------- +// resolveFreeCall — SM-13: free-function call resolution +// --------------------------------------------------------------------------- + +describe('resolveFreeCall', () => { + let ctx: ResolutionContext; + + beforeEach(() => { + ctx = createResolutionContext(); + }); + + it('resolves a free function call via import-scoped resolution', () => { + ctx.symbols.add('src/utils.ts', 'doStuff', 'func:doStuff', 'Function'); + ctx.importMap.set('src/app.ts', new Set(['src/utils.ts'])); + + const result = resolveFreeCall('doStuff', 'src/app.ts', ctx); + + expect(result).not.toBeNull(); + expect(result!.nodeId).toBe('func:doStuff'); + expect(result!.confidence).toBe(0.9); // import-scoped tier + expect(result!.reason).toBe('import-resolved'); + }); + + it('resolves a free function call via same-file resolution', () => { + ctx.symbols.add('src/app.ts', 'helper', 'func:helper', 'Function'); + + const result = resolveFreeCall('helper', 'src/app.ts', ctx); + + expect(result).not.toBeNull(); + expect(result!.nodeId).toBe('func:helper'); + expect(result!.confidence).toBe(0.95); // same-file tier + expect(result!.reason).toBe('same-file'); + }); + + it('returns null when no candidates exist', () => { + const result = resolveFreeCall('nonexistent', 'src/app.ts', ctx); + expect(result).toBeNull(); + }); + + it('returns null for ambiguous free function calls (multiple candidates)', () => { + ctx.symbols.add('src/a.ts', 'doStuff', 'func:a:doStuff', 'Function'); + ctx.symbols.add('src/b.ts', 'doStuff', 'func:b:doStuff', 'Function'); + ctx.importMap.set('src/app.ts', new Set(['src/a.ts', 'src/b.ts'])); + + const result = resolveFreeCall('doStuff', 'src/app.ts', ctx); + + expect(result).toBeNull(); + }); + + it('delegates to resolveStaticCall for free-form class targets (Swift/Kotlin)', () => { + ctx.symbols.add('src/user.swift', 'User', 'class:User', 'Class'); + ctx.importMap.set('src/app.swift', new Set(['src/user.swift'])); + + const result = resolveFreeCall('User', 'src/app.swift', ctx); + + expect(result).not.toBeNull(); + expect(result!.nodeId).toBe('class:User'); + }); + + it('delegates to resolveStaticCall for Record free-form targets (C#/Kotlin)', () => { + ctx.symbols.add('src/User.cs', 'User', 'record:cs:User', 'Record'); + ctx.importMap.set('src/App.cs', new Set(['src/User.cs'])); + + const result = resolveFreeCall('User', 'src/App.cs', ctx); + + expect(result).not.toBeNull(); + expect(result!.nodeId).toBe('record:cs:User'); + }); + + it('null-routes Trait free-form calls via resolveStaticCall', () => { + ctx.symbols.add('src/timestamps.php', 'HasTimestamps', 'trait:HasTimestamps', 'Trait'); + ctx.importMap.set('src/model.php', new Set(['src/timestamps.php'])); + + const result = resolveFreeCall('HasTimestamps', 'src/model.php', ctx); + + expect(result).toBeNull(); + }); + + it('uses tieredOverride when provided', () => { + ctx.symbols.add('src/utils.ts', 'doStuff', 'func:doStuff', 'Function'); + ctx.importMap.set('src/app.ts', new Set(['src/utils.ts'])); + + const tiered = ctx.resolve('doStuff', 'src/app.ts'); + expect(tiered).not.toBeNull(); + + // Spy on ctx.resolve to verify it is NOT called again + const originalResolve = ctx.resolve.bind(ctx); + let resolveCallCount = 0; + ctx.resolve = ((name: string, fromFile: string) => { + resolveCallCount++; + return originalResolve(name, fromFile); + }) as typeof ctx.resolve; + + const result = resolveFreeCall('doStuff', 'src/app.ts', ctx, undefined, tiered!); + + expect(result).not.toBeNull(); + expect(result!.nodeId).toBe('func:doStuff'); + expect(resolveCallCount).toBe(0); + }); + + it('routes through resolveCallTarget for free-form calls', () => { + ctx.symbols.add('src/utils.ts', 'doStuff', 'func:doStuff', 'Function'); + ctx.importMap.set('src/app.ts', new Set(['src/utils.ts'])); + + const result = _resolveCallTargetForTesting( + { + calledName: 'doStuff', + callForm: 'free', + }, + 'src/app.ts', + ctx, + ); + + expect(result).not.toBeNull(); + expect(result!.nodeId).toBe('func:doStuff'); + }); + + // ------------------------------------------------------------------------- + // PR #756 review follow-up (plan 2026-04-09-003): language coverage, + // arity threading, Tier 3 resolution, preComputedArgTypes worker path, + // Enum null-route, and Swift extension dedup guard. + // ------------------------------------------------------------------------- + + // R2 — Language coverage: Go, Python, Rust, Java, JavaScript free-function + // dispatch through _resolveCallTargetForTesting. resolveFreeCall has no + // file-extension branching; these guard the dispatch chain per language. + + it('resolves a Go free function (doStuff())', () => { + ctx.symbols.add('src/helper.go', 'doStuff', 'func:go:doStuff', 'Function'); + ctx.importMap.set('src/main.go', new Set(['src/helper.go'])); + + const result = _resolveCallTargetForTesting( + { calledName: 'doStuff', callForm: 'free' }, + 'src/main.go', + ctx, + ); + + expect(result).not.toBeNull(); + expect(result!.nodeId).toBe('func:go:doStuff'); + }); + + it('resolves a Python free function (def helper(): ... helper())', () => { + ctx.symbols.add('helpers.py', 'helper', 'func:py:helper', 'Function'); + ctx.importMap.set('app.py', new Set(['helpers.py'])); + + const result = _resolveCallTargetForTesting( + { calledName: 'helper', callForm: 'free' }, + 'app.py', + ctx, + ); + + expect(result).not.toBeNull(); + expect(result!.nodeId).toBe('func:py:helper'); + }); + + it('resolves a Rust free function outside any impl block (free_fn())', () => { + ctx.symbols.add('src/helpers.rs', 'free_fn', 'func:rs:free_fn', 'Function'); + ctx.importMap.set('src/main.rs', new Set(['src/helpers.rs'])); + + const result = _resolveCallTargetForTesting( + { calledName: 'free_fn', callForm: 'free' }, + 'src/main.rs', + ctx, + ); + + expect(result).not.toBeNull(); + expect(result!.nodeId).toBe('func:rs:free_fn'); + }); + + it('resolves a Java statically-imported function (doStuff() after import static Utils.doStuff)', () => { + // Note: this simulates the extractor output post static import by + // indexing the function directly in its declaring file. The test guards + // the dispatch chain for .java files, not the extractor's handling of + // static imports specifically. + ctx.symbols.add('src/Utils.java', 'doStuff', 'func:java:doStuff', 'Function'); + ctx.importMap.set('src/App.java', new Set(['src/Utils.java'])); + + const result = _resolveCallTargetForTesting( + { calledName: 'doStuff', callForm: 'free' }, + 'src/App.java', + ctx, + ); + + expect(result).not.toBeNull(); + expect(result!.nodeId).toBe('func:java:doStuff'); + }); + + it('resolves a JavaScript module-level function (moduleFn())', () => { + ctx.symbols.add('src/helpers.js', 'moduleFn', 'func:js:moduleFn', 'Function'); + ctx.importMap.set('src/app.js', new Set(['src/helpers.js'])); + + const result = _resolveCallTargetForTesting( + { calledName: 'moduleFn', callForm: 'free' }, + 'src/app.js', + ctx, + ); + + expect(result).not.toBeNull(); + expect(result!.nodeId).toBe('func:js:moduleFn'); + }); + + // R3 — Arity filtering: call.argCount must narrow overloaded free functions + // differing only in parameter count. + + it('narrows overloaded free functions by argCount (2-arg overload selected)', () => { + ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:0', 'Function', { + parameterCount: 0, + }); + ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:2', 'Function', { + parameterCount: 2, + }); + ctx.importMap.set('src/app.ts', new Set(['src/utils.ts'])); + + const result = _resolveCallTargetForTesting( + { calledName: 'helper', callForm: 'free', argCount: 2 }, + 'src/app.ts', + ctx, + ); + + expect(result).not.toBeNull(); + expect(result!.nodeId).toBe('func:helper:2'); + }); + + it('narrows overloaded free functions by argCount (0-arg overload selected)', () => { + ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:0', 'Function', { + parameterCount: 0, + }); + ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:2', 'Function', { + parameterCount: 2, + }); + ctx.importMap.set('src/app.ts', new Set(['src/utils.ts'])); + + const result = _resolveCallTargetForTesting( + { calledName: 'helper', callForm: 'free', argCount: 0 }, + 'src/app.ts', + ctx, + ); + + expect(result).not.toBeNull(); + expect(result!.nodeId).toBe('func:helper:0'); + }); + + // R4 — Tier 3 (global) resolution: function globally visible but not + // imported. Locks in TIER_CONFIDENCE.global === 0.5 and reason === 'global' + // so a silent tier-table refactor surfaces here. + + it('resolves a globally-visible free function via Tier 3 with global confidence', () => { + ctx.symbols.add('lib/global.ts', 'helper', 'func:global:helper', 'Function'); + // No importMap entry — must fall through to Tier 3 (global). + + const result = _resolveCallTargetForTesting( + { calledName: 'helper', callForm: 'free' }, + 'src/app.ts', + ctx, + ); + + expect(result).not.toBeNull(); + expect(result!.nodeId).toBe('func:global:helper'); + expect(result!.confidence).toBe(0.5); // TIER_CONFIDENCE.global + expect(result!.reason).toBe('global'); + }); + + // R5 — preComputedArgTypes worker path: when parse-worker pre-computes + // argument types, the disambiguation routes through matchCandidatesByArgTypes. + // Preconditions (verified at feasibility review): + // 1. filteredCandidates.length > 1 — both overloads must survive arity + // filtering, so argCount left unset here. + // 2. overloadHints must be undefined — it takes precedence over + // preComputedArgTypes at the disambiguation site. + + it('disambiguates overloads via preComputedArgTypes (String overload matched)', () => { + ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:str', 'Function', { + parameterCount: 1, + parameterTypes: ['String'], + }); + ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:int', 'Function', { + parameterCount: 1, + parameterTypes: ['Int'], + }); + ctx.importMap.set('src/app.ts', new Set(['src/utils.ts'])); + + const result = _resolveCallTargetForTesting( + { calledName: 'helper', callForm: 'free', argCount: 1 }, + 'src/app.ts', + ctx, + { preComputedArgTypes: ['String'] }, + ); + + expect(result).not.toBeNull(); + expect(result!.nodeId).toBe('func:helper:str'); + }); + + it('disambiguates overloads via preComputedArgTypes (Int overload matched)', () => { + ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:str', 'Function', { + parameterCount: 1, + parameterTypes: ['String'], + }); + ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:int', 'Function', { + parameterCount: 1, + parameterTypes: ['Int'], + }); + ctx.importMap.set('src/app.ts', new Set(['src/utils.ts'])); + + const result = _resolveCallTargetForTesting( + { calledName: 'helper', callForm: 'free', argCount: 1 }, + 'src/app.ts', + ctx, + // `Int` is normalized to `int` on the stored side via normalizeJvmTypeName + // (matchCandidatesByArgTypes:1287). Real parse-worker-emitted argTypes are + // already lowercase primitive names inferred from literals, so this + // mirrors production call-site shape. + { preComputedArgTypes: ['int'] }, + ); + + expect(result).not.toBeNull(); + expect(result!.nodeId).toBe('func:helper:int'); + }); + + // R6 — Enum free-form null-route: locks in the current behavior that + // `Color()`-style calls on Enum types return null because Enum is + // deliberately excluded from INSTANTIABLE_CLASS_TYPES. This is intentional + // per PR #754 round 1 (see `call-processor.ts` INSTANTIABLE_CLASS_TYPES + // JSDoc — "Enum excluded pending language-specific support with motivating + // test fixtures"). If a future extension adds Enum to the set, this test + // will need to be updated alongside that work — that is the correct signal. + + it('null-routes Enum free-form calls (Color() — no instantiable fallback)', () => { + ctx.symbols.add('src/color.ts', 'Color', 'enum:Color', 'Enum'); + ctx.importMap.set('src/app.ts', new Set(['src/color.ts'])); + + const result = _resolveCallTargetForTesting( + { calledName: 'Color', callForm: 'free' }, + 'src/app.ts', + ctx, + ); + + // Enum not in INSTANTIABLE_CLASS_TYPES → hasClassTarget is false → + // resolveStaticCall is not called → tail dedup also doesn't fire → + // falls through to the final null return. + expect(result).toBeNull(); + }); + + // R7 — Swift extension dedup `filePath.length` heuristic guard: + // Two same-name Class entries at different path lengths. The free-form + // dispatch chain goes: + // 1. filterCallableCandidates(tiered, argCount, 'free') strips Class → + // filteredCandidates.length === 0 + // 2. hasClassTarget is true (both are Class) + // 3. resolveStaticCall runs, has 2 homonym Class candidates → + // instantiableCandidates.length > 1 → returns null (SM-12 round-1 + // null-route contract) + // 4. Constructor-form retry: filterCallableCandidates(tiered, argCount, + // 'constructor') keeps Class entries → filteredCandidates.length === 2 + // 5. Falls through to the Swift extension dedup block → sorts by + // filePath.length → returns the shortest path. + + it('dedupes Swift extension candidates by shortest file path (free-form retry path)', () => { + // Two same-name Class entries, different path lengths. + ctx.symbols.add('src/User.swift', 'User', 'class:User:primary', 'Class'); + ctx.symbols.add('src/Extensions/UserExtensions.swift', 'User', 'class:User:extension', 'Class'); + ctx.importMap.set( + 'src/App.swift', + new Set(['src/User.swift', 'src/Extensions/UserExtensions.swift']), + ); + + const result = _resolveCallTargetForTesting( + { calledName: 'User', callForm: 'free' }, + 'src/App.swift', + ctx, + ); + + // The shortest file path wins per the existing heuristic. This is a + // behavior guard for finding #4 in the PR #756 review — if the dedup + // heuristic changes, this test surfaces that intent. + expect(result).not.toBeNull(); + expect(result!.nodeId).toBe('class:User:primary'); + }); + + // ------------------------------------------------------------------------- + // PR #756 final review follow-up (comment 4215739052): + // - Finding #3 low: ownerless-Constructor retry path (previously covered + // by comment only) — adds the concrete test the reviewer asked for. + // - Low-severity coverage gap: PHP free function (from the language + // coverage table in the same review). + // ------------------------------------------------------------------------- + + it('routes through resolveStaticCall retry when tiered pool contains an ownerless Constructor (free-form)', () => { + // This exercises the third null-return reason documented in the retry + // comment inside resolveFreeCall: resolveStaticCall's step-4 bailout when + // the tiered pool contains Constructor nodes that lack ownerId (common in + // some extractors). In that case: + // 1. resolveStaticCall step 3 walks classCandidates via lookupMethodByOwner + // — the ownerless Constructor is NOT in methodByOwner, so nothing found. + // 2. Step 4 detects the Constructor in the tiered pool and bails out + // with null so filterCallableCandidates can handle Constructor-vs- + // Class preference correctly. + // 3. resolveFreeCall's retry re-runs filterCallableCandidates with + // 'constructor' form, which — per CONSTRUCTOR_TARGET_TYPES — prefers + // the Constructor node over the Class node. + // 4. Single survivor → returned as the call target. + ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.symbols.add('src/user.ts', 'User', 'ctor:User:ownerless', 'Constructor', { + parameterCount: 0, + // No ownerId — this is the pathological extractor output the retry path + // exists to handle. + }); + ctx.importMap.set('src/app.ts', new Set(['src/user.ts'])); + + const result = _resolveCallTargetForTesting( + { calledName: 'User', callForm: 'free' }, + 'src/app.ts', + ctx, + ); + + // The Constructor survives filterCallableCandidates's 'constructor' form + // filter and is preferred over the Class (CONSTRUCTOR_TARGET_TYPES puts + // Constructor first). Guards the (c) case in the retry-reasons comment. + expect(result).not.toBeNull(); + expect(result!.nodeId).toBe('ctor:User:ownerless'); + }); + + it('resolves a PHP free function (top-level helper())', () => { + // PHP allows top-level function definitions outside any class. The + // language coverage table in PR #756 review flagged this as uncovered; + // this test exercises the `.php` dispatch path for free calls. Matches + // the shape of the existing Go/Python/Rust/Java/JS language tests above. + ctx.symbols.add('src/helpers.php', 'helper', 'func:php:helper', 'Function'); + ctx.importMap.set('src/app.php', new Set(['src/helpers.php'])); + + const result = _resolveCallTargetForTesting( + { calledName: 'helper', callForm: 'free' }, + 'src/app.php', + ctx, + ); + + expect(result).not.toBeNull(); + expect(result!.nodeId).toBe('func:php:helper'); + }); +}); From 4a1f912aee7d040713405ffa5b65865231385608 Mon Sep 17 00:00:00 2001 From: Abhigyan Patwari <126312502+abhigyanpatwari@users.noreply.github.com> Date: Fri, 10 Apr 2026 01:33:25 +0530 Subject: [PATCH 03/67] =?UTF-8?q?feat(sm-14):=20add=20BindingAccumulator?= =?UTF-8?q?=20=E2=80=94=20collect=20TypeEnv=20outputs=20across=20files=20(?= =?UTF-8?q?#743)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/core/ingestion/binding-accumulator.ts | 368 ++++++++++ gitnexus/src/core/ingestion/call-processor.ts | 20 +- .../src/core/ingestion/parsing-processor.ts | 22 +- gitnexus/src/core/ingestion/pipeline.ts | 154 ++++- gitnexus/src/core/ingestion/type-env.ts | 62 +- .../core/ingestion/workers/parse-worker.ts | 62 +- .../test/unit/binding-accumulator.test.ts | 653 ++++++++++++++++++ gitnexus/test/unit/type-env.test.ts | 212 ++++++ 8 files changed, 1492 insertions(+), 61 deletions(-) create mode 100644 gitnexus/src/core/ingestion/binding-accumulator.ts create mode 100644 gitnexus/test/unit/binding-accumulator.test.ts diff --git a/gitnexus/src/core/ingestion/binding-accumulator.ts b/gitnexus/src/core/ingestion/binding-accumulator.ts new file mode 100644 index 000000000..3e7563623 --- /dev/null +++ b/gitnexus/src/core/ingestion/binding-accumulator.ts @@ -0,0 +1,368 @@ +/** + * BindingAccumulator — read-append-only accumulator that collects TypeEnv + * bindings across files in the GitNexus analyzer pipeline. + * + * **Current behavior (both execution paths):** The accumulator carries only + * file-scope (`scope = ''`) entries. Function-scope bindings are stripped + * at both write sites: + * + * - **Worker path**: `parse-worker.ts` serializes only + * `typeEnv.fileScope()` entries across the IPC boundary. + * - **Sequential path**: `type-env.ts::flush()` iterates only the FILE_SCOPE + * entry of the env map and writes `BindingEntry` records with + * `scope: ''` hardcoded. + * + * The narrowing exists because function-scope bindings have zero downstream + * consumers today and were previously costing ~4.9 MB of heap + IPC on + * every pipeline run. See `type-env.ts::flush()` and the `FileScopeBindings` + * JSDoc in `parse-worker.ts` for the paired Phase 9 reversion checklist. + * + * **Historical quality asymmetry (Phase 9 consideration):** Even though + * both paths now carry only file-scope data, the two paths were built + * under different resolution capabilities, and a future Phase 9 reverter + * that widens them back to all scopes will inherit that asymmetry: + * + * - **Sequential path** had (and would regain) access to the full + * `SymbolTable` and `importedBindings`, so its bindings benefit from + * Tier 2 cross-file propagation. + * - **Worker path** runs without `SymbolTable` / `importedBindings` and + * can only produce Tier 0 (annotation-declared) and local Tier 1 + * (same-file constructor inference) bindings. + * + * Phase 9 consumers that trust every entry equally will silently produce + * worse results for large repos (worker-dominant) than small ones + * (sequential-dominant). If Phase 9 needs homogeneous quality, either + * (a) tag entries with their tier at insert time so consumers can filter, + * or (b) post-process worker-path entries through a follow-up resolution + * pass after the main-thread `SymbolTable` is complete. + * + * **Lifecycle contract**: `append → finalize → consume → dispose`. See + * `finalize()` and `dispose()` for the state machine. Disposal is + * orthogonal to finalization: either order is legal. + */ + +export interface BindingEntry { + readonly scope: string; // '' for file-level, 'funcName@startIndex' for function-local + readonly varName: string; + readonly typeName: string; +} + +/** + * Minimal graph-node shape required by `enrichExportedTypeMap()`. Intentionally + * narrower than the full `GraphNode` type in `graph/types.ts` so tests can + * construct a minimal mock without depending on the full graph module, and + * so the enrichment logic is a pure function over this contract. + * + * Matches the shape of the real `KnowledgeGraph` node's `properties.isExported` + * access path — tests that use a different shape silently pass while + * production fails. + */ +export interface EnrichmentGraphNode { + readonly id: string; + readonly properties?: { readonly isExported?: boolean } | undefined; +} + +/** + * Minimal graph lookup interface used by `enrichExportedTypeMap()`. + * Consumes only the method the enrichment loop actually calls. + */ +export interface EnrichmentGraphLookup { + getNode(id: string): EnrichmentGraphNode | undefined; +} + +/** + * Merge file-scope bindings from a (finalized) `BindingAccumulator` into an + * `exportedTypeMap` for symbols whose graph nodes are marked as exported. + * + * This is the single source of truth for the worker-path ExportedTypeMap + * enrichment loop. Previously the logic lived inline in `pipeline.ts` and + * the test suite reimplemented it as a `runEnrichmentLoop` helper — a + * drift-prone pattern that meant tests could pass while production regressed. + * Extracting it here makes the production code call the same function the + * tests call. + * + * **Node ID candidate order**: `Function:{filePath}:{name}` → + * `Variable:{filePath}:{name}` → `Const:{filePath}:{name}`. First match wins. + * + * **Tier 0 priority**: if `exportedTypeMap` already has an entry for a + * `(filePath, name)` pair, the accumulator entry does NOT overwrite it — + * the SymbolTable tier-0 pass is authoritative. Without this guard, a + * worker-path binding could clobber a higher-quality type from SymbolTable. + * + * **Finalize precondition**: the accumulator should be finalized before + * calling this function. The lifecycle contract is + * `append → finalize → enrich → dispose`. Finalization is not asserted + * here (the test suite and pipeline both honor it separately), but any + * append happening concurrently with this enrichment would be a lifecycle + * bug at the caller level. + * + * @returns The number of new entries written into `exportedTypeMap` + * (0 on empty accumulator or when every candidate was filtered + * out by the export check or the Tier 0 guard). + */ +export function enrichExportedTypeMap( + bindingAccumulator: BindingAccumulator, + graph: EnrichmentGraphLookup, + exportedTypeMap: Map>, +): number { + if (bindingAccumulator.fileCount === 0) return 0; + let enriched = 0; + for (const filePath of bindingAccumulator.files()) { + for (const [name, type] of bindingAccumulator.fileScopeEntries(filePath)) { + // Three-candidate-ID lookup mirrors the sequential-path export check + // in `collectExportedBindings()` (call-processor.ts). + const functionNodeId = `Function:${filePath}:${name}`; + const variableNodeId = `Variable:${filePath}:${name}`; + const constNodeId = `Const:${filePath}:${name}`; + const node = + graph.getNode(functionNodeId) ?? + graph.getNode(variableNodeId) ?? + graph.getNode(constNodeId); + if (!node?.properties?.isExported) continue; + + let fileExports = exportedTypeMap.get(filePath); + if (!fileExports) { + fileExports = new Map(); + exportedTypeMap.set(filePath, fileExports); + } + // Tier 0 priority: SymbolTable-populated entries are authoritative. + if (!fileExports.has(name)) { + fileExports.set(name, type); + enriched++; + } + } + } + return enriched; +} + +const ENTRY_OVERHEAD = 64; // bytes per entry (object overhead + property refs) +const MAP_ENTRY_OVERHEAD = 80; // bytes per file entry in the map + +export class BindingAccumulator { + // Storage is split into two parallel maps so fileScopeEntries() is + // O(n_file_scope) instead of O(n_total). + // - _allByFile holds every BindingEntry (used by getFile, memory estimate). + // - _fileScopeByFile caches the flat [varName, typeName] view of the + // `scope === ''` subset, populated at insert time so reads are O(1) map + // lookup + O(n_file_scope) array return. Both maps carry the same key + // set modulo the `scope === ''` precondition: _allByFile has a key as + // soon as any entry is appended; _fileScopeByFile only has a key once a + // file-scope entry arrives. Code that iterates via files() uses + // _allByFile so files with only function-scope entries remain visible. + private readonly _allByFile = new Map(); + private readonly _fileScopeByFile = new Map(); + private _totalBindings = 0; + private _finalized = false; + private _disposed = false; + + /** + * Append bindings for a file. Safe to call multiple times for the same file. + * Throws if the accumulator has been finalized. Skips if entries is empty. + * + * The `entries` parameter is `readonly` — this method never mutates the + * caller's array. Internally, the first `appendFile` call per filePath + * makes a defensive copy (`slice()`), and subsequent calls push into the + * accumulator's own storage. + */ + appendFile(filePath: string, entries: readonly BindingEntry[]): void { + if (this._finalized) { + throw new Error( + '[BindingAccumulator] appendFile after finalize — no further appends allowed', + ); + } + if (entries.length === 0) { + return; + } + // Contract consistency: if this accumulator was previously disposed + // without being finalized, `dispose()` is documented to leave it + // "behaving like a fresh one" for subsequent appends. Clear the + // `_disposed` flag here so the `disposed` getter tracks the actual + // live state, not a stale signal from the prior lifecycle cycle. + if (this._disposed) { + this._disposed = false; + } + // Note on the file-scope-only invariant: + // The accumulator does NOT reject function-scope entries at this + // boundary. The narrowing contract is enforced by the two production + // write sites — `parse-worker.ts` (which uses `typeEnv.fileScope()` + // and hardcodes `scope: ''` in the pipeline adapter) and + // `type-env.ts::flush()` (which iterates only `env.get(FILE_SCOPE)`). + // The class JSDoc documents the invariant and the Phase 9 reversion + // path. Making `appendFile` runtime-reject non-file-scope entries + // would break the accumulator's own storage-split tests which + // legitimately exercise mixed-scope entries. If a future write path + // violates the invariant, tests should fail via missing exports in + // the enrichment loop, not via an assertion here. + // All-scope store. + const existingAll = this._allByFile.get(filePath); + if (existingAll !== undefined) { + for (const e of entries) { + existingAll.push(e); + } + } else { + this._allByFile.set(filePath, entries.slice()); + } + // File-scope fast-path store. Populated lazily on first file-scope entry. + let existingFileScope = this._fileScopeByFile.get(filePath); + for (const e of entries) { + if (e.scope === '') { + if (existingFileScope === undefined) { + existingFileScope = []; + this._fileScopeByFile.set(filePath, existingFileScope); + } + existingFileScope.push([e.varName, e.typeName]); + } + } + this._totalBindings += entries.length; + } + + /** Lock the accumulator — no further appends. Idempotent. */ + finalize(): void { + // Dev-mode invariant: verify the parallel storage split is consistent. + // `_fileScopeByFile` must be a proper projection of `_allByFile` + // where the outer key is a subset and the inner entries are exactly + // the `scope === ''` subset of `_allByFile[key]`. A drift would + // indicate a bug in `appendFile()` where one map was updated but + // not the other. + if (process.env.NODE_ENV !== 'production' && !this._finalized) { + for (const [filePath, fileScopeTuples] of this._fileScopeByFile) { + const allEntries = this._allByFile.get(filePath); + if (allEntries === undefined) { + throw new Error( + `[BindingAccumulator] storage split drift: file ${filePath} has file-scope entries ` + + `but no _allByFile entry`, + ); + } + const projectedCount = allEntries.filter((e) => e.scope === '').length; + if (projectedCount !== fileScopeTuples.length) { + throw new Error( + `[BindingAccumulator] storage split drift: file ${filePath} has ` + + `${fileScopeTuples.length} file-scope tuples but ${projectedCount} file-scope ` + + `entries in _allByFile`, + ); + } + } + } + this._finalized = true; + } + + /** + * Release the accumulator's heap footprint. Clears both internal storage + * maps and resets `_totalBindings` to zero. Idempotent and orthogonal to + * `finalize()` — calling `dispose()` does not change the finalized state. + * + * Post-dispose contract: all read methods return empty/undefined state + * matching a never-appended-to accumulator. Specifically: + * - `fileCount === 0` + * - `totalBindings === 0` + * - `files()` yields an empty iterator + * - `getFile(x)` returns `undefined` for all `x` + * - `fileScopeEntries(x)` returns `[]` for all `x` + * - `estimateMemoryBytes()` returns `0` + * + * If `dispose()` is called **before** `finalize()`, subsequent `appendFile` + * calls succeed — the accumulator behaves like a fresh one. If called + * **after** `finalize()`, subsequent `appendFile` calls throw the existing + * "finalized" error. + * + * Lifecycle note: the pipeline disposes the accumulator after the + * ExportedTypeMap enrichment loop consumes its file-scope entries, so + * the heap is released before Phase 14 (`runCrossFileBindingPropagation`) + * and `runGraphAnalysisPhases` begin their long-running work. When Phase 9 + * wires a consumer into that stage, the dispose call should move later in + * the pipeline or be removed entirely. + */ + dispose(): void { + this._allByFile.clear(); + this._fileScopeByFile.clear(); + this._totalBindings = 0; + this._disposed = true; + } + + /** Get all bindings for a file, or undefined if the file is unknown. */ + getFile(filePath: string): readonly BindingEntry[] | undefined { + return this._allByFile.get(filePath); + } + + /** + * Get only scope='' (file-level) entries as [varName, typeName] tuples. + * Backward-compatible with the old workerTypeEnvBindings pattern. + * Returns an empty array for an unknown file. + * + * O(1) map lookup + O(n_file_scope) defensive-copy construction — does + * NOT walk function-scope entries. See the `_fileScopeByFile` field + * comment for the storage split rationale. + * + * The return value is a shallow copy; mutating it does not affect + * subsequent reads or internal state. This encapsulation guard prevents + * a Phase 9 consumer from accidentally corrupting the accumulator via + * `acc.fileScopeEntries(p).push(...)` or similar. + */ + fileScopeEntries(filePath: string): readonly (readonly [string, string])[] { + const cached = this._fileScopeByFile.get(filePath); + return cached ? cached.slice() : []; + } + + /** Iterate over all file paths in insertion order. */ + files(): IterableIterator { + return this._allByFile.keys(); + } + + /** Number of distinct files with at least one binding. */ + get fileCount(): number { + return this._allByFile.size; + } + + /** Total number of binding entries across all files. */ + get totalBindings(): number { + return this._totalBindings; + } + + /** Whether the accumulator has been finalized. */ + get finalized(): boolean { + return this._finalized; + } + + /** + * Whether the accumulator has been disposed. Exposed for symmetry with + * `finalized` so debug tooling and future Phase 9 consumers can detect a + * disposed accumulator without inspecting empty state heuristically. + * + * Disposal and finalization are orthogonal: a disposed accumulator may or + * may not be finalized, and vice versa. See `dispose()` for the full + * lifecycle contract. + */ + get disposed(): boolean { + return this._disposed; + } + + /** + * Rough memory estimate in bytes (intentionally pessimistic). + * Formula: sum of (ENTRY_OVERHEAD + char bytes of scope+varName+typeName) per entry + * + MAP_ENTRY_OVERHEAD + char bytes of filePath per file. + * + * Note: V8 stores all-ASCII strings as Latin-1 (1 byte/char) and only upgrades + * to UCS-2 (2 bytes/char) for non-Latin-1 code points. Source paths and type names + * are typically all-ASCII, so actual heap cost is roughly half what this returns. + * The pessimistic factor is intentional — better to over-budget than under-budget. + * + * **⚠ Cost profile**: O(totalBindings) — iterates every entry in + * `_allByFile` and reads three string `.length` properties per entry. + * At a typical repo scale (10k files × ~20 file-scope bindings) this is + * ~200k property reads per call. Call at most once per pipeline run, + * NOT per file, per chunk, or per progress tick. The current single + * call site is the dev-mode telemetry log at the pipeline finalize + * seam. Adding a per-file-progress caller would silently make it + * quadratic in repo size. + */ + estimateMemoryBytes(): number { + let total = 0; + for (const [filePath, entries] of this._allByFile) { + total += MAP_ENTRY_OVERHEAD + filePath.length * 2; + for (const e of entries) { + total += ENTRY_OVERHEAD + (e.scope.length + e.varName.length + e.typeName.length) * 2; + } + } + return total; + } +} diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 1dadcaf54..bd49f4d09 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -34,6 +34,7 @@ import { buildTypeEnv, isSubclassOf } from './type-env.js'; import type { ConstructorBinding, TypeEnvironment } from './type-env.js'; import type { HeritageMap } from './heritage-map.js'; import { c3Linearize } from './mro-processor.js'; +import type { BindingAccumulator } from './binding-accumulator.js'; import { getTreeSitterBufferSize } from './constants.js'; import type { ExtractedCall, @@ -155,7 +156,15 @@ export function buildImportedRawReturnTypes( } /** Collect resolved type bindings for exported file-scope symbols. - * Uses graph node isExported flag — does NOT require isExported on SymbolDefinition. */ + * Uses graph node isExported flag — does NOT require isExported on SymbolDefinition. + * + * **Counterpart**: the worker path populates `exportedTypeMap` via the + * accumulator enrichment loop in `pipeline.ts` (search for "Worker path + * quality enrichment"). Both sites populate the same map with subtly + * different export-check semantics — this site uses SymbolTable + + * graph lookup, the worker loop uses three-candidate-ID graph lookup. + * They must stay in sync until Phase 9 unifies them. If you edit one, + * check the other. */ function collectExportedBindings( typeEnv: { fileScope(): ReadonlyMap }, filePath: string, @@ -612,6 +621,7 @@ export const processCalls = async ( /** Phase 14 E3: cross-file RAW return types for for-loop element extraction. Keyed by filePath → Map. */ importedRawReturnTypesMap?: ReadonlyMap>, heritageMap?: HeritageMap, + bindingAccumulator?: BindingAccumulator, ): Promise => { const parser = await loadParser(); const collectedHeritage: ExtractedHeritage[] = []; @@ -733,6 +743,14 @@ export const processCalls = async ( const fileExports = collectExportedBindings(typeEnv, file.path, ctx.symbols, graph); if (fileExports) exportedTypeMap.set(file.path, fileExports); } + // Flush file-scope bindings into the accumulator. `flush()` is narrowed + // to iterate only FILE_SCOPE entries (type-env.ts) — function-scope + // bindings are dropped at the flush boundary until a Phase 9 consumer + // lands. See type-env.ts::flush() JSDoc for the dual-site reversion + // checklist (this sequential path + the worker path in parse-worker.ts). + if (bindingAccumulator) { + typeEnv.flush(file.path, bindingAccumulator); + } const callRouter = provider.callRouter; const verifiedReceivers = diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index b55b70747..5feddd29f 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -42,7 +42,7 @@ import type { ExtractedDecoratorRoute, ExtractedToolDef, FileConstructorBindings, - FileTypeEnvBindings, + FileScopeBindings, ExtractedORMQuery, } from './workers/parse-worker.js'; import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from './constants.js'; @@ -60,7 +60,7 @@ export interface WorkerExtractedData { toolDefs: ExtractedToolDef[]; ormQueries: ExtractedORMQuery[]; constructorBindings: FileConstructorBindings[]; - typeEnvBindings: FileTypeEnvBindings[]; + fileScopeBindings: FileScopeBindings[]; } // ============================================================================ @@ -94,7 +94,7 @@ const processParsingWithWorkers = async ( toolDefs: [], ormQueries: [], constructorBindings: [], - typeEnvBindings: [], + fileScopeBindings: [], }; const total = files.length; @@ -118,7 +118,7 @@ const processParsingWithWorkers = async ( const allToolDefs: ExtractedToolDef[] = []; const allORMQueries: ExtractedORMQuery[] = []; const allConstructorBindings: FileConstructorBindings[] = []; - const allTypeEnvBindings: FileTypeEnvBindings[] = []; + const fileScopeBindingsByFile: FileScopeBindings[] = []; for (const result of chunkResults) { for (const node of result.nodes) { graph.addNode({ @@ -154,7 +154,8 @@ const processParsingWithWorkers = async ( for (const _item of result.toolDefs) allToolDefs.push(_item); if (result.ormQueries) for (const _item of result.ormQueries) allORMQueries.push(_item); for (const _item of result.constructorBindings) allConstructorBindings.push(_item); - for (const _item of result.typeEnvBindings) allTypeEnvBindings.push(_item); + if (result.fileScopeBindings) + for (const _item of result.fileScopeBindings) fileScopeBindingsByFile.push(_item); } // Merge and log skipped languages from workers @@ -184,7 +185,7 @@ const processParsingWithWorkers = async ( toolDefs: allToolDefs, ormQueries: allORMQueries, constructorBindings: allConstructorBindings, - typeEnvBindings: allTypeEnvBindings, + fileScopeBindings: fileScopeBindingsByFile, }; }; @@ -354,7 +355,14 @@ const processParsingSequential = async ( continue; } - // Build per-file type environment for FieldExtractor context (lightweight — skipped if no fieldExtractor) + // Build per-file type environment for FieldExtractor context (lightweight — skipped if no fieldExtractor). + // + // Note: this TypeEnv is intentionally NOT flushed into the BindingAccumulator. + // The accumulator feed happens later in `call-processor.ts` via its own + // `typeEnv.flush(accumulator)` call. Flushing here would double-count + // file-scope bindings and break the single-use invariant of `flush()`. + // See the BindingAccumulator class JSDoc for the full accumulator + // lifecycle and flush-site ownership rules. const typeEnv = provider.fieldExtractor ? buildTypeEnv(tree, language, { enclosingFunctionFinder: provider.enclosingFunctionFinder, diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index e73efd86e..9a521e7c5 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -1,4 +1,9 @@ import { createKnowledgeGraph } from '../graph/graph.js'; +import { + BindingAccumulator, + enrichExportedTypeMap, + type BindingEntry, +} from './binding-accumulator.js'; import { processStructure } from './structure-processor.js'; import { processMarkdown } from './markdown-processor.js'; import { processCobol, isCobolFile, isJclFile } from './cobol-processor.js'; @@ -650,6 +655,7 @@ async function runChunkedParseAndResolve( allDecoratorRoutes: ExtractedDecoratorRoute[]; allToolDefs: ExtractedToolDef[]; allORMQueries: ExtractedORMQuery[]; + bindingAccumulator: BindingAccumulator; }> { const symbolTable = ctx.symbols; @@ -782,7 +788,7 @@ async function runChunkedParseAndResolve( // Phase 14: Collect exported type bindings for cross-file propagation const exportedTypeMap: ExportedTypeMap = new Map(); // Accumulate file-scope TypeEnv bindings from workers (closes worker/sequential quality gap) - const workerTypeEnvBindings: { filePath: string; bindings: [string, string][] }[] = []; + const bindingAccumulator = new BindingAccumulator(); // Accumulate fetch() calls from workers for Next.js route matching const allFetchCalls: ExtractedFetchCall[] = []; // Accumulate framework-extracted routes (Laravel, etc.) for Route node creation @@ -916,9 +922,31 @@ async function runChunkedParseAndResolve( }); }), ]); - // Collect TypeEnv file-scope bindings for exported type enrichment - if (chunkWorkerData.typeEnvBindings?.length) { - for (const _item of chunkWorkerData.typeEnvBindings) workerTypeEnvBindings.push(_item); + // Collect file-scope bindings into BindingAccumulator. The worker + // IPC payload carries only file-scope entries (`scope = ''` + // hardcoded here). See the FileScopeBindings JSDoc in + // parse-worker.ts for the rationale and Phase 9 reversion path. + // + // Defensive validation at the IPC boundary: silently skip entries + // with non-string varName/typeName. If a future worker regression + // (or a Phase 9 reversion mistake that emits 3-tuples into the + // 2-tuple consumer) produces malformed data, logging is better + // than silently writing `undefined` into the enrichment map. + if (chunkWorkerData.fileScopeBindings?.length) { + for (const { filePath, bindings } of chunkWorkerData.fileScopeBindings) { + if (typeof filePath !== 'string' || filePath.length === 0) continue; + if (!Array.isArray(bindings)) continue; + const entries: BindingEntry[] = []; + for (const tuple of bindings) { + if (!Array.isArray(tuple) || tuple.length !== 2) continue; + const [varName, typeName] = tuple; + if (typeof varName !== 'string' || typeof typeName !== 'string') continue; + entries.push({ scope: '', varName, typeName }); + } + if (entries.length > 0) { + bindingAccumulator.appendFile(filePath, entries); + } + } } // Collect fetch() calls for Next.js route matching if (chunkWorkerData.fetchCalls?.length) { @@ -1034,6 +1062,7 @@ async function runChunkedParseAndResolve( undefined, undefined, sequentialHeritageMap, + bindingAccumulator, ); await processHeritage(graph, chunkFiles, astCache, ctx); if (rubyHeritage.length > 0) { @@ -1068,39 +1097,31 @@ async function runChunkedParseAndResolve( ); } - // ── Worker path quality enrichment: merge TypeEnv file-scope bindings into ExportedTypeMap ── - // Workers return file-scope bindings from their TypeEnv fixpoint (includes inferred types - // like `const config = getConfig()` → Config). Filter by graph isExported to match - // the sequential path's collectExportedBindings behavior. - if (workerTypeEnvBindings.length > 0) { - let enriched = 0; - for (const { filePath, bindings } of workerTypeEnvBindings) { - for (const [name, type] of bindings) { - // Verify the symbol is exported via graph node - const nodeId = `Function:${filePath}:${name}`; - const varNodeId = `Variable:${filePath}:${name}`; - const constNodeId = `Const:${filePath}:${name}`; - const node = - graph.getNode(nodeId) ?? graph.getNode(varNodeId) ?? graph.getNode(constNodeId); - if (!node?.properties?.isExported) continue; + // ── Finalize the accumulator before the read phase begins. All worker-path + // appends (line ~934) and sequential-path flushes (via `processCalls` → + // `typeEnv.flush()` earlier in this function) have completed by here, + // so the finalize-write-lock is correct at this seam. Making the + // lifecycle contract explicit — `append → finalize → consume → dispose`. + // Previously `finalize()` was called much later in `runPipelineFromRepo` + // after the enrichment loop had already read the mutable accumulator. + bindingAccumulator.finalize(); - let fileExports = exportedTypeMap.get(filePath); - if (!fileExports) { - fileExports = new Map(); - exportedTypeMap.set(filePath, fileExports); - } - // Don't overwrite existing entries (Tier 0 from SymbolTable is authoritative) - if (!fileExports.has(name)) { - fileExports.set(name, type); - enriched++; - } - } - } - if (isDev && enriched > 0) { - console.log( - `🔗 Worker TypeEnv enrichment: ${enriched} fixpoint-inferred exports added to ExportedTypeMap`, - ); - } + // ── Worker path quality enrichment: merge file-scope bindings into ExportedTypeMap ── + // Counterpart to `collectExportedBindings()` in call-processor.ts which + // handles the sequential path (main thread, full SymbolTable access). + // This call handles the worker path via the accumulator. Both sites + // populate the same `exportedTypeMap` with subtly different export-check + // semantics — sequential uses SymbolTable + graph lookup, `enrichExportedTypeMap` + // uses a three-candidate-ID graph lookup. They must stay in sync until + // Phase 9 unifies them. If you edit one, check the other. + // + // The enrichment loop itself lives in `binding-accumulator.ts` so tests + // can exercise the real production code instead of reimplementing it. + const enriched = enrichExportedTypeMap(bindingAccumulator, graph, exportedTypeMap); + if (isDev && enriched > 0) { + console.log( + `🔗 Worker TypeEnv enrichment: ${enriched} fixpoint-inferred exports added to ExportedTypeMap`, + ); } // ── Final synthesis pass for whole-module-import languages ── @@ -1128,6 +1149,7 @@ async function runChunkedParseAndResolve( allDecoratorRoutes, allToolDefs, allORMQueries, + bindingAccumulator, }; } @@ -1359,6 +1381,15 @@ export const runPipelineFromRepo = async ( const ctx = createResolutionContext(); const pipelineStart = Date.now(); + // Hoisted reference for error-path cleanup. The accumulator is normally + // disposed at the happy-path seam after the dev telemetry log, but if any + // step between the runChunkedParseAndResolve return and that seam throws + // (ORM processing, tool node creation, Phase 14, graph analysis), the + // catch handler disposes it here so the heap footprint does not leak + // through the rethrow. See binding-accumulator.ts dispose() JSDoc for the + // lifecycle contract. + let bindingAccumulatorForCleanup: BindingAccumulator | undefined; + try { // Phase 1+2: Scan paths, build structure, process markdown const { scannedFiles, allPaths, totalFiles } = await runScanAndStructure( @@ -1375,6 +1406,7 @@ export const runPipelineFromRepo = async ( allDecoratorRoutes, allToolDefs, allORMQueries, + bindingAccumulator, } = await runChunkedParseAndResolve( graph, ctx, @@ -1386,6 +1418,11 @@ export const runPipelineFromRepo = async ( onProgress, options, ); + // Track the accumulator for error-path cleanup — the happy-path dispose + // is still at the post-telemetry seam below, this reference is only + // consulted by the catch handler if any step between here and there + // throws. + bindingAccumulatorForCleanup = bindingAccumulator; // ── Phase 3.5: Route Registry (Next.js + PHP + Laravel + decorators) ── type RouteEntry = { filePath: string; source: string }; @@ -1697,6 +1734,45 @@ export const runPipelineFromRepo = async ( processORMQueries(graph, allORMQueries, isDev); } + // `bindingAccumulator.finalize()` was moved inside `runChunkedParseAndResolve` + // to immediately precede the enrichment loop — see the comment there for + // the ordering rationale. By the time execution + // reaches this point, the accumulator has already been finalized, consumed + // by the enrichment loop, and is ready for dispose() below after the dev + // telemetry log captures peak state. + + if (isDev) { + if (bindingAccumulator.totalBindings > 0) { + const memKB = Math.round(bindingAccumulator.estimateMemoryBytes() / 1024); + console.log( + `📦 BindingAccumulator: ${bindingAccumulator.totalBindings} bindings across ${bindingAccumulator.fileCount} files (~${memKB} KB)`, + ); + } else if (totalFiles > 0) { + // Zero-binding signal: if the pipeline parsed files but the + // accumulator is empty, something upstream dropped all bindings. + // Flag it so operators can spot a regression (e.g. a worker path + // that accidentally emits empty fileScopeBindings arrays for every + // file, or a TypeEnv build failure). Dev-mode only. + console.log( + `📦 BindingAccumulator: EMPTY — 0 bindings across 0 files despite ${totalFiles} parsed files. If the codebase has typed bindings, this indicates an upstream regression.`, + ); + } + } + + // Release the accumulator's heap footprint now. The ExportedTypeMap + // enrichment loop above is the only current consumer, and the dev + // telemetry log just captured peak state. Phase 14 and + // runGraphAnalysisPhases do not read the accumulator today — keeping + // it alive through those long-running phases pins heap for no reason. + // When Phase 9 wires a consumer into runCrossFileBindingPropagation, + // move this dispose() call to after that consumer completes or delete + // it entirely if the consumer takes lifecycle ownership. + bindingAccumulator.dispose(); + // Happy-path dispose completed — clear the cleanup ref so the catch + // handler doesn't attempt a second (harmless but noisy) dispose if a + // later phase throws. + bindingAccumulatorForCleanup = undefined; + // ── Phase 14: Cross-file binding propagation (topological level sort) ── await runCrossFileBindingPropagation( graph, @@ -1741,6 +1817,12 @@ export const runPipelineFromRepo = async ( return { graph, repoPath, totalFileCount: totalFiles, communityResult, processResult }; } catch (error) { + // Error-path cleanup: dispose the accumulator if a step after the + // destructure from runChunkedParseAndResolve but before the happy-path + // dispose threw. The reference is cleared on the happy path, so this + // is a no-op when the pipeline completed successfully and then threw + // from an unrelated post-dispose step (e.g., future cleanup code). + bindingAccumulatorForCleanup?.dispose(); ctx.clear(); throw error; } diff --git a/gitnexus/src/core/ingestion/type-env.ts b/gitnexus/src/core/ingestion/type-env.ts index e5fbb8cd2..2b093fb14 100644 --- a/gitnexus/src/core/ingestion/type-env.ts +++ b/gitnexus/src/core/ingestion/type-env.ts @@ -8,6 +8,7 @@ import { CALL_EXPRESSION_TYPES } from './utils/call-analysis.js'; import { SupportedLanguages } from 'gitnexus-shared'; import { TYPED_PARAMETER_TYPES } from './type-extractors/shared.js'; import { getProvider } from './languages/index.js'; +import type { BindingAccumulator, BindingEntry } from './binding-accumulator.js'; import type { ClassNameLookup, ReturnTypeLookup, @@ -42,7 +43,21 @@ type TypeEnv = Map>; const FILE_SCOPE = ''; /** Shared empty map for files with no file-scope bindings. */ -const EMPTY_FILE_SCOPE: ReadonlyMap = new Map(); +/** + * Create a fresh empty Map for the "no file-scope bindings" fallback. + * + * **Why not a shared sentinel**: we previously used a module-level + * `const EMPTY_FILE_SCOPE = new Map()` typed as `ReadonlyMap` and shared + * across every TypeEnv instance. That was a latent singleton-poisoning + * footgun: any caller that did `(fileScope() as Map).set(...)` — or any + * future refactor that widened the return type — would silently corrupt + * every subsequent "empty" return for the process lifetime. A Proxy + * wrapper was considered but broke Map's internal-slot methods (`.size`, + * iteration protocol). Allocating a fresh empty Map per call is a few + * bytes per file — immediately GC'd, no measurable cost even at 10k files + * — and eliminates the shared-mutation hazard entirely. + */ +const emptyFileScope = (): ReadonlyMap => new Map(); /** Fallback for languages where class names aren't in a 'name' field (e.g. Kotlin uses type_identifier). */ const findTypeIdentifierChild = (node: SyntaxNode): SyntaxNode | null => { @@ -73,6 +88,10 @@ export interface TypeEnvironment { * Populated when a variable has BOTH a declared base type AND a more specific * constructor type (e.g., `Animal a = new Dog()` → key maps to 'Dog'). */ readonly constructorTypeMap: ReadonlyMap; + /** Copy all scoped bindings into a BindingAccumulator. + * Must be called at most once per TypeEnv instance — throws on second call. + * The source `env` is not cleared (TypeEnv is per-file and discarded immediately after). */ + flush(filePath: string, accumulator: BindingAccumulator): void; } /** @@ -822,6 +841,7 @@ export const buildTypeEnv = ( const parentMap = options?.parentMap; const extractFuncNameHook = options?.extractFunctionName; const env: TypeEnv = new Map(); + let flushed = false; const patternOverrides: PatternOverrides = new Map(); // Phase P: maps `scope\0varName` → constructor type when a declaration has BOTH // a base type annotation AND a more specific constructor initializer. @@ -1242,9 +1262,47 @@ export const buildTypeEnv = ( extractFuncNameHook, ), constructorBindings: bindings, - fileScope: () => env.get(FILE_SCOPE) ?? EMPTY_FILE_SCOPE, + fileScope: () => env.get(FILE_SCOPE) ?? emptyFileScope(), allScopes: () => env as ReadonlyMap>, constructorTypeMap, + flush(filePath: string, accumulator: BindingAccumulator): void { + if (flushed) { + throw new Error( + `[TypeEnvironment] flush called twice for ${filePath} — flush is single-use`, + ); + } + // Narrow flush() to iterate only the FILE_SCOPE entry, mirroring the + // worker-path narrowing in parse-worker.ts (commit 803631fe). Before + // this change, both execution paths had the same asymmetry bug: the + // worker path was fixed but the sequential path (this code) still + // wrote function-scope entries into long-lived accumulator storage + // that no consumer reads until Phase 9 lands. + // + // Phase 9 reversion: when a downstream consumer of function-scope + // bindings exists, restore the nested iteration: + // + // for (const [scope, scopeMap] of env) { + // for (const [varName, typeName] of scopeMap) { + // entries.push({ scope, varName, typeName }); + // } + // } + // + // See BindingAccumulator class JSDoc and FileScopeBindings JSDoc in + // parse-worker.ts for the full reversion checklist. + const fileScope = env.get(FILE_SCOPE) ?? emptyFileScope(); + const entries: BindingEntry[] = []; + for (const [varName, typeName] of fileScope) { + entries.push({ scope: '', varName, typeName }); + } + if (entries.length > 0) { + accumulator.appendFile(filePath, entries); + } + // Mark the env as flushed AFTER the successful append. If appendFile + // throws (e.g., accumulator is already finalized due to a lifecycle + // ordering bug), the caller can catch and retry — the single-use + // guard now tracks "data was written", not "flush was attempted". + flushed = true; + }, }; }; diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 59e6cc03b..ef01e4c63 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -235,10 +235,33 @@ export interface FileConstructorBindings { bindings: ConstructorBinding[]; } -/** File-scope type bindings from TypeEnv fixpoint — used for cross-file ExportedTypeMap. */ -export interface FileTypeEnvBindings { +/** All-scope type bindings from TypeEnv — includes function-local scopes. + * Used by BindingAccumulator for cross-file type propagation (Phase 9+). + * + * Carries only file-scope entries (`scope = ''`). Serializing function-scope + * bindings over IPC cost ~4.9 MB with zero downstream consumers. + * `parse-worker.ts` now iterates only `typeEnv.fileScope()` and the + * sequential path's `type-env.ts::flush()` is also narrowed to file + * scope — see the `BindingAccumulator` class JSDoc for the unified + * narrowing contract across both execution paths. + * + * **Phase 9 reversion checklist** (when a downstream consumer of + * function-scope bindings lands): + * 1. Change the loop in `runParseJob` below from `typeEnv.fileScope()` + * back to `typeEnv.allScopes()`. + * 2. Emit three-element tuples `[scope, varName, typeName]`. + * 3. Widen the `bindings` field on this interface back to + * `[string, string, string][]`. + * 4. Update the pipeline adapter in `pipeline.ts` to unpack three + * elements and populate `BindingEntry.scope` from the first tuple + * element instead of hardcoding `''`. + * 5. Also revert `type-env.ts::flush()` to iterate `env` instead of + * just `FILE_SCOPE` if the sequential path needs function-scope data too. + * 6. Consider renaming this interface back to `FileAllScopeBindings` + * along with widening. */ +export interface FileScopeBindings { filePath: string; - /** [varName, typeName] pairs from file scope (scope = '') */ + /** [varName, typeName] pairs from the file scope only. */ bindings: [string, string][]; } @@ -256,8 +279,8 @@ export interface ParseWorkerResult { toolDefs: ExtractedToolDef[]; ormQueries: ExtractedORMQuery[]; constructorBindings: FileConstructorBindings[]; - /** File-scope type bindings from TypeEnv fixpoint for exported symbol collection. */ - typeEnvBindings: FileTypeEnvBindings[]; + /** All-scope type bindings from TypeEnv for BindingAccumulator (includes function-local). */ + fileScopeBindings: FileScopeBindings[]; skippedLanguages: Record; fileCount: number; } @@ -690,7 +713,7 @@ const processBatch = ( toolDefs: [], ormQueries: [], constructorBindings: [], - typeEnvBindings: [], + fileScopeBindings: [], skippedLanguages: {}, fileCount: 0, }; @@ -1386,14 +1409,23 @@ const processFileGroup = ( }); } - // Extract file-scope bindings for ExportedTypeMap (closes worker/sequential quality gap). - // Sequential path uses collectExportedBindings(typeEnv) directly; worker path serializes - // these bindings so the main thread can merge them into ExportedTypeMap. + // Serialize file-scope bindings for BindingAccumulator. These feed the + // ExportedTypeMap enrichment loop in pipeline.ts — the only current + // consumer of worker-path binding data. + // + // Historical note: we previously serialized all scopes + // (`typeEnv.allScopes()`), which pushed ~4.9 MB of function-scope + // bindings across the IPC boundary on every worker batch with zero + // downstream readers. Narrowing to `fileScope()` recovers that cost. + // See the `FileScopeBindings` JSDoc above for the Phase 9 reversion + // path when a function-scope consumer lands. const fileScope = typeEnv.fileScope(); if (fileScope.size > 0) { - const bindings: [string, string][] = []; - for (const [name, type] of fileScope) bindings.push([name, type]); - result.typeEnvBindings.push({ filePath: file.path, bindings }); + const scopeBindings: [string, string][] = []; + for (const [varName, typeName] of fileScope) { + scopeBindings.push([varName, typeName]); + } + result.fileScopeBindings.push({ filePath: file.path, bindings: scopeBindings }); } // Per-file map: decorator end-line → decorator info, for associating with definitions @@ -2113,7 +2145,7 @@ let accumulated: ParseWorkerResult = { toolDefs: [], ormQueries: [], constructorBindings: [], - typeEnvBindings: [], + fileScopeBindings: [], skippedLanguages: {}, fileCount: 0, }; @@ -2133,7 +2165,7 @@ const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult) => { target.toolDefs.push(...src.toolDefs); target.ormQueries.push(...src.ormQueries); target.constructorBindings.push(...src.constructorBindings); - target.typeEnvBindings.push(...src.typeEnvBindings); + target.fileScopeBindings.push(...src.fileScopeBindings); for (const [lang, count] of Object.entries(src.skippedLanguages)) { target.skippedLanguages[lang] = (target.skippedLanguages[lang] || 0) + count; } @@ -2184,7 +2216,7 @@ parentPort!.on('message', (msg: WorkerIncomingMessage) => { toolDefs: [], ormQueries: [], constructorBindings: [], - typeEnvBindings: [], + fileScopeBindings: [], skippedLanguages: {}, fileCount: 0, }; diff --git a/gitnexus/test/unit/binding-accumulator.test.ts b/gitnexus/test/unit/binding-accumulator.test.ts new file mode 100644 index 000000000..be394d987 --- /dev/null +++ b/gitnexus/test/unit/binding-accumulator.test.ts @@ -0,0 +1,653 @@ +import { describe, it, expect } from 'vitest'; +import { + BindingAccumulator, + enrichExportedTypeMap, + type BindingEntry, + type EnrichmentGraphLookup, + type EnrichmentGraphNode, +} from '../../src/core/ingestion/binding-accumulator.js'; + +describe('BindingAccumulator', () => { + describe('append + read', () => { + it('returns entries for a single file', () => { + const acc = new BindingAccumulator(); + const entries: BindingEntry[] = [ + { scope: '', varName: 'x', typeName: 'number' }, + { scope: 'foo@10', varName: 'y', typeName: 'string' }, + ]; + acc.appendFile('src/a.ts', entries); + expect(acc.getFile('src/a.ts')).toEqual(entries); + }); + + it('returns entries for multiple files', () => { + const acc = new BindingAccumulator(); + acc.appendFile('src/a.ts', [{ scope: '', varName: 'a', typeName: 'number' }]); + acc.appendFile('src/b.ts', [{ scope: '', varName: 'b', typeName: 'string' }]); + expect(acc.getFile('src/a.ts')).toHaveLength(1); + expect(acc.getFile('src/b.ts')).toHaveLength(1); + expect(acc.fileCount).toBe(2); + }); + + it('returns undefined for unknown file', () => { + const acc = new BindingAccumulator(); + expect(acc.getFile('nonexistent.ts')).toBeUndefined(); + }); + + it('accumulates entries across multiple calls for the same file', () => { + const acc = new BindingAccumulator(); + acc.appendFile('src/a.ts', [{ scope: '', varName: 'x', typeName: 'number' }]); + acc.appendFile('src/a.ts', [{ scope: 'fn@5', varName: 'y', typeName: 'boolean' }]); + const entries = acc.getFile('src/a.ts'); + expect(entries).toHaveLength(2); + expect(entries![0].varName).toBe('x'); + expect(entries![1].varName).toBe('y'); + }); + + it('skips append when entries is empty', () => { + const acc = new BindingAccumulator(); + acc.appendFile('src/a.ts', []); + expect(acc.getFile('src/a.ts')).toBeUndefined(); + expect(acc.fileCount).toBe(0); + }); + + it('tracks totalBindings correctly', () => { + const acc = new BindingAccumulator(); + acc.appendFile('src/a.ts', [ + { scope: '', varName: 'x', typeName: 'number' }, + { scope: '', varName: 'y', typeName: 'string' }, + ]); + acc.appendFile('src/b.ts', [{ scope: '', varName: 'z', typeName: 'boolean' }]); + expect(acc.totalBindings).toBe(3); + }); + }); + + describe('finalize + immutability', () => { + it('finalize prevents further appends', () => { + const acc = new BindingAccumulator(); + acc.appendFile('src/a.ts', [{ scope: '', varName: 'x', typeName: 'number' }]); + acc.finalize(); + expect(() => + acc.appendFile('src/b.ts', [{ scope: '', varName: 'y', typeName: 'string' }]), + ).toThrow(/finalize/); + }); + + it('finalized getter returns true after finalize', () => { + const acc = new BindingAccumulator(); + expect(acc.finalized).toBe(false); + acc.finalize(); + expect(acc.finalized).toBe(true); + }); + + it('getFile works after finalize', () => { + const acc = new BindingAccumulator(); + acc.appendFile('src/a.ts', [{ scope: '', varName: 'x', typeName: 'number' }]); + acc.finalize(); + expect(acc.getFile('src/a.ts')).toHaveLength(1); + }); + + it('finalize is idempotent', () => { + const acc = new BindingAccumulator(); + acc.finalize(); + expect(() => acc.finalize()).not.toThrow(); + }); + }); + + describe('fileScopeEntries', () => { + it('returns only scope="" entries as [varName, typeName] tuples', () => { + const acc = new BindingAccumulator(); + acc.appendFile('src/a.ts', [ + { scope: '', varName: 'x', typeName: 'number' }, + { scope: 'foo@10', varName: 'y', typeName: 'string' }, + { scope: '', varName: 'z', typeName: 'boolean' }, + ]); + const tuples = acc.fileScopeEntries('src/a.ts'); + expect(tuples).toEqual([ + ['x', 'number'], + ['z', 'boolean'], + ]); + }); + + it('returns empty array for unknown file', () => { + const acc = new BindingAccumulator(); + expect(acc.fileScopeEntries('nonexistent.ts')).toEqual([]); + }); + + it('returns empty array when file has no file-scope entries', () => { + const acc = new BindingAccumulator(); + acc.appendFile('src/a.ts', [{ scope: 'fn@1', varName: 'x', typeName: 'number' }]); + expect(acc.fileScopeEntries('src/a.ts')).toEqual([]); + }); + }); + + describe('iteration', () => { + it('files() yields all file paths', () => { + const acc = new BindingAccumulator(); + acc.appendFile('src/a.ts', [{ scope: '', varName: 'x', typeName: 'number' }]); + acc.appendFile('src/b.ts', [{ scope: '', varName: 'y', typeName: 'string' }]); + acc.appendFile('src/c.ts', [{ scope: '', varName: 'z', typeName: 'boolean' }]); + const paths = [...acc.files()]; + expect(paths.sort()).toEqual(['src/a.ts', 'src/b.ts', 'src/c.ts']); + }); + + it('files() returns empty iterator when no files added', () => { + const acc = new BindingAccumulator(); + expect([...acc.files()]).toEqual([]); + }); + }); + + describe('memory estimate', () => { + it('returns a reasonable estimate for 1000 files x 2 entries', () => { + const acc = new BindingAccumulator(); + for (let i = 0; i < 1000; i++) { + acc.appendFile(`src/file${i}.ts`, [ + { scope: '', varName: `var${i}a`, typeName: 'string' }, + { scope: `fn${i}@0`, varName: `var${i}b`, typeName: 'number' }, + ]); + } + const bytes = acc.estimateMemoryBytes(); + // Should be between 50KB and 2MB + expect(bytes).toBeGreaterThan(50 * 1024); + expect(bytes).toBeLessThan(2 * 1024 * 1024); + }); + }); + + describe('pipeline integration (simulated)', () => { + it('deserializes allScopeBindings from worker into accumulator', () => { + const acc = new BindingAccumulator(); + + // Simulated worker output: + // After narrowing the worker IPC payload to file-scope only, the + // emitted tuple shape is [varName, typeName]. Function-scope entries + // are stripped at the parse-worker boundary; the sequential path's + // flush() still writes all scopes via its own code path. + const workerBindings = [ + { + filePath: 'src/service.ts', + bindings: [['config', 'Config'] as [string, string]], + }, + { + filePath: 'src/utils.ts', + bindings: [['logger', 'Logger'] as [string, string]], + }, + ]; + + // Pipeline deserialization logic (mirrors pipeline.ts adapter): + // two-element tuples → BindingEntry with hard-coded scope: ''. + for (const { filePath, bindings } of workerBindings) { + const entries: BindingEntry[] = bindings.map(([varName, typeName]) => ({ + scope: '', + varName, + typeName, + })); + acc.appendFile(filePath, entries); + } + acc.finalize(); + + expect(acc.fileCount).toBe(2); + expect(acc.totalBindings).toBe(2); + + // fileScopeEntries — what the ExportedTypeMap enrichment loop uses. + expect(acc.fileScopeEntries('src/service.ts')).toEqual([['config', 'Config']]); + expect(acc.fileScopeEntries('src/utils.ts')).toEqual([['logger', 'Logger']]); + + // Every entry produced by the worker path has scope === '' after the + // IPC narrowing — locks the contract in place. + const serviceEntries = acc.getFile('src/service.ts'); + expect(serviceEntries).toHaveLength(1); + expect(serviceEntries![0]).toEqual({ + scope: '', + varName: 'config', + typeName: 'Config', + }); + }); + + it('worker IPC payload contains ONLY file-scope entries (narrowing guard)', () => { + // Function-scope bindings were being + // serialized over worker IPC with no consumer, costing ~4.9 MB. The + // worker now uses typeEnv.fileScope() instead of typeEnv.allScopes(), + // so `handleRequest@15 → db: Database` never crosses the IPC boundary. + // + // This test simulates a TypeEnvironment that HAD both file-scope and + // function-scope bindings (as would be produced by a realistic file), + // then asserts the worker IPC payload contains only the file-scope + // ones. If a future change accidentally re-broadens the worker loop + // to `allScopes()`, this assertion fires. + const simulatedFileScope = new Map([ + ['config', 'Config'], + ['db', 'Database'], + ]); + // Function-scope entries that must NOT appear in the worker payload. + const simulatedFunctionScope = new Map([ + ['localRequest', 'Request'], + ['localUser', 'User'], + ]); + + // Mirror the parse-worker loop (post-narrowing shape): + // const fileScope = typeEnv.fileScope(); + // for (const [varName, typeName] of fileScope) { + // scopeBindings.push([varName, typeName]); + // } + const workerPayload: [string, string][] = []; + for (const [varName, typeName] of simulatedFileScope) { + workerPayload.push([varName, typeName]); + } + + // Verify: the simulated function-scope variables are never pushed. + const allVarNames = workerPayload.map(([v]) => v); + expect(allVarNames).toEqual(['config', 'db']); + expect(allVarNames).not.toContain('localRequest'); + expect(allVarNames).not.toContain('localUser'); + + // Sanity: simulatedFunctionScope exists so the test is not trivially + // vacuous — it documents what the old allScopes() path would have + // emitted and what the new fileScope() path deliberately excludes. + expect(simulatedFunctionScope.size).toBe(2); + + // Round-trip through the accumulator with the pipeline adapter shape. + const acc = new BindingAccumulator(); + const entries: BindingEntry[] = workerPayload.map(([varName, typeName]) => ({ + scope: '', + varName, + typeName, + })); + acc.appendFile('src/service.ts', entries); + acc.finalize(); + + const stored = acc.getFile('src/service.ts'); + expect(stored).toHaveLength(2); + // All accumulator entries from the worker path have scope === ''. + for (const entry of stored!) { + expect(entry.scope).toBe(''); + } + }); + }); + + // ------------------------------------------------------------------------- + // fileScopeEntries() must be O(n_file_scope), + // not O(n_total). Storage is split into _allByFile + _fileScopeByFile so + // reads skip function-scope entries entirely. + // ------------------------------------------------------------------------- + + describe('storage split (fast-path fileScopeEntries)', () => { + it('mixed file-scope and function-scope input: fileScopeEntries ignores function-scope', () => { + const acc = new BindingAccumulator(); + acc.appendFile('src/a.ts', [ + { scope: '', varName: 'file1', typeName: 'T1' }, + { scope: 'fn@10', varName: 'local1', typeName: 'L1' }, + { scope: '', varName: 'file2', typeName: 'T2' }, + { scope: 'fn@20', varName: 'local2', typeName: 'L2' }, + { scope: 'fn@30', varName: 'local3', typeName: 'L3' }, + ]); + + // fileScopeEntries returns exactly the two file-scope entries, + // preserving insertion order. + expect(acc.fileScopeEntries('src/a.ts')).toEqual([ + ['file1', 'T1'], + ['file2', 'T2'], + ]); + + // getFile still returns all 5 entries (mixed scopes preserved). + expect(acc.getFile('src/a.ts')).toHaveLength(5); + }); + + it('only-function-scope file: fileScopeEntries returns [] but files() still lists it', () => { + const acc = new BindingAccumulator(); + acc.appendFile('src/only-fn.ts', [ + { scope: 'fn@5', varName: 'x', typeName: 'X' }, + { scope: 'fn@10', varName: 'y', typeName: 'Y' }, + ]); + + expect(acc.fileScopeEntries('src/only-fn.ts')).toEqual([]); + expect(acc.getFile('src/only-fn.ts')).toHaveLength(2); + expect([...acc.files()]).toContain('src/only-fn.ts'); + expect(acc.fileCount).toBe(1); + }); + + it('multiple appends accumulate in both maps consistently', () => { + const acc = new BindingAccumulator(); + acc.appendFile('src/a.ts', [ + { scope: '', varName: 'x', typeName: 'X' }, + { scope: 'fn@1', varName: 'y', typeName: 'Y' }, + ]); + acc.appendFile('src/a.ts', [ + { scope: '', varName: 'z', typeName: 'Z' }, + { scope: 'fn@2', varName: 'w', typeName: 'W' }, + ]); + + expect(acc.fileScopeEntries('src/a.ts')).toEqual([ + ['x', 'X'], + ['z', 'Z'], + ]); + expect(acc.getFile('src/a.ts')).toHaveLength(4); + expect(acc.totalBindings).toBe(4); + }); + + it('performance guard: fileScopeEntries does not walk function-scope entries', () => { + const acc = new BindingAccumulator(); + // 1 file-scope entry + 1000 function-scope entries. + const entries: BindingEntry[] = [{ scope: '', varName: 'shared', typeName: 'Shared' }]; + for (let i = 0; i < 1000; i++) { + entries.push({ + scope: `fn${i}@${i * 10}`, + varName: `local${i}`, + typeName: 'Local', + }); + } + acc.appendFile('src/big.ts', entries); + + // fileScopeEntries returns the single file-scope pair without + // iterating the 1000 function-scope entries — this is the O(1) cache + // lookup behavior guaranteed by the storage split. + const result = acc.fileScopeEntries('src/big.ts'); + expect(result).toHaveLength(1); + expect(result[0]).toEqual(['shared', 'Shared']); + // Sanity: getFile still sees everything. + expect(acc.getFile('src/big.ts')).toHaveLength(1001); + }); + }); + + // ------------------------------------------------------------------------- + // Integration coverage for the sequential + // path → accumulator → ExportedTypeMap enrichment loop at pipeline.ts + // lines 1082-1110. This test mirrors that loop inline with a minimal + // KnowledgeGraph-shaped mock, locking in the node-ID format contract + // (Function:{filePath}:{name}, Variable:..., Const:...). If the ID format + // drifts for any language, this test fires. + // ------------------------------------------------------------------------- + + describe('ExportedTypeMap enrichment (integration)', () => { + /** + * Minimal graph backing for `enrichExportedTypeMap`. Matches the + * `EnrichmentGraphNode` shape from binding-accumulator.ts — which in + * turn matches the real `GraphNode.properties.isExported` access path + * used by the production `KnowledgeGraph`. Using this shape (rather + * than a flat `isExported` field) means a refactor of the graph's + * `properties` layout will fail this test, not silently pass. + */ + function makeGraphLookup( + nodes: Array<{ id: string; isExported: boolean }>, + ): EnrichmentGraphLookup { + const byId = new Map(); + for (const n of nodes) { + byId.set(n.id, { id: n.id, properties: { isExported: n.isExported } }); + } + return { getNode: (id) => byId.get(id) }; + } + + it('enriches exportedTypeMap with an exported Function node', () => { + const acc = new BindingAccumulator(); + acc.appendFile('src/utils.ts', [ + { scope: '', varName: 'helper', typeName: '(arg: string) => User' }, + ]); + acc.finalize(); + + const graph = makeGraphLookup([{ id: 'Function:src/utils.ts:helper', isExported: true }]); + const exportedTypeMap = new Map>(); + + const enriched = enrichExportedTypeMap(acc, graph, exportedTypeMap); + + expect(enriched).toBe(1); + expect(exportedTypeMap.get('src/utils.ts')?.get('helper')).toBe('(arg: string) => User'); + }); + + it('skips non-exported Variable nodes', () => { + const acc = new BindingAccumulator(); + acc.appendFile('src/app.ts', [{ scope: '', varName: 'dbClient', typeName: 'Database' }]); + acc.finalize(); + + const graph = makeGraphLookup([{ id: 'Variable:src/app.ts:dbClient', isExported: false }]); + const exportedTypeMap = new Map>(); + + const enriched = enrichExportedTypeMap(acc, graph, exportedTypeMap); + + expect(enriched).toBe(0); + expect(exportedTypeMap.has('src/app.ts')).toBe(false); + }); + + it('enriches exportedTypeMap with an exported Const node', () => { + const acc = new BindingAccumulator(); + acc.appendFile('src/config.ts', [{ scope: '', varName: 'API_URL', typeName: 'string' }]); + acc.finalize(); + + const graph = makeGraphLookup([{ id: 'Const:src/config.ts:API_URL', isExported: true }]); + const exportedTypeMap = new Map>(); + + const enriched = enrichExportedTypeMap(acc, graph, exportedTypeMap); + + expect(enriched).toBe(1); + expect(exportedTypeMap.get('src/config.ts')?.get('API_URL')).toBe('string'); + }); + + it('silently skips accumulator entries with no matching graph node', () => { + const acc = new BindingAccumulator(); + acc.appendFile('src/missing.ts', [{ scope: '', varName: 'ghost', typeName: 'Ghost' }]); + acc.finalize(); + + // Empty graph — no nodes at any of the candidate IDs. + const graph = makeGraphLookup([]); + const exportedTypeMap = new Map>(); + + // Must not throw; enrichment's `continue` path fires for every + // unmatched entry. + let enriched = -1; + expect(() => { + enriched = enrichExportedTypeMap(acc, graph, exportedTypeMap); + }).not.toThrow(); + expect(enriched).toBe(0); + expect(exportedTypeMap.has('src/missing.ts')).toBe(false); + }); + + it('does not overwrite existing SymbolTable entry (Tier 0 priority)', () => { + // When the SymbolTable's tier-0 extraction pass has already populated + // an entry for a name, the accumulator enrichment must NOT overwrite + // it with a (lower-quality) worker-path binding. + const acc = new BindingAccumulator(); + acc.appendFile('src/utils.ts', [ + { scope: '', varName: 'helper', typeName: 'WorkerInferredType' }, + ]); + acc.finalize(); + + // Pre-populate exportedTypeMap to simulate what SymbolTable would + // have written in the tier-0 pass. + const exportedTypeMap = new Map>([ + ['src/utils.ts', new Map([['helper', 'SymbolTableAuthoritativeType']])], + ]); + + const graph = makeGraphLookup([{ id: 'Function:src/utils.ts:helper', isExported: true }]); + + const enriched = enrichExportedTypeMap(acc, graph, exportedTypeMap); + + // Tier 0 wins — the authoritative SymbolTable type survives. + expect(enriched).toBe(0); + expect(exportedTypeMap.get('src/utils.ts')?.get('helper')).toBe( + 'SymbolTableAuthoritativeType', + ); + }); + + it('handles nodes whose properties object is undefined (production shape)', () => { + // Regression guard: the real KnowledgeGraph stores isExported under + // `node.properties.isExported` and properties may be undefined for + // some node kinds. The enrichment guard `!node?.properties?.isExported` + // must treat an undefined properties object as non-exported. + const acc = new BindingAccumulator(); + acc.appendFile('src/edge.ts', [{ scope: '', varName: 'helper', typeName: 'Helper' }]); + acc.finalize(); + + const graph: EnrichmentGraphLookup = { + getNode: (id) => + id === 'Function:src/edge.ts:helper' + ? ({ id, properties: undefined } satisfies EnrichmentGraphNode) + : undefined, + }; + const exportedTypeMap = new Map>(); + + const enriched = enrichExportedTypeMap(acc, graph, exportedTypeMap); + + expect(enriched).toBe(0); + expect(exportedTypeMap.has('src/edge.ts')).toBe(false); + }); + + it('returns 0 and leaves exportedTypeMap untouched when accumulator is empty', () => { + const acc = new BindingAccumulator(); + acc.finalize(); + + const graph = makeGraphLookup([{ id: 'Function:src/utils.ts:helper', isExported: true }]); + const existingMap = new Map>([ + ['src/existing.ts', new Map([['keep', 'Type']])], + ]); + + const enriched = enrichExportedTypeMap(acc, graph, existingMap); + + expect(enriched).toBe(0); + expect(existingMap.size).toBe(1); + expect(existingMap.get('src/existing.ts')?.get('keep')).toBe('Type'); + }); + }); + + // ------------------------------------------------------------------------- + // BindingAccumulator.dispose() releases the accumulator's heap footprint + // after the enrichment loop has consumed everything it needs. Post-dispose + // reads return empty/undefined without throwing, matching "never-appended" + // state. Idempotent and orthogonal to finalize(). + // ------------------------------------------------------------------------- + + describe('dispose', () => { + it('empties all read methods after dispose', () => { + const acc = new BindingAccumulator(); + acc.appendFile('src/a.ts', [ + { scope: '', varName: 'x', typeName: 'X' }, + { scope: 'fn@10', varName: 'y', typeName: 'Y' }, + ]); + acc.appendFile('src/b.ts', [{ scope: '', varName: 'z', typeName: 'Z' }]); + + // Sanity: pre-dispose state is populated. + expect(acc.fileCount).toBe(2); + expect(acc.totalBindings).toBe(3); + + acc.dispose(); + + // Post-dispose state: all read methods return empty/undefined. + expect(acc.fileCount).toBe(0); + expect(acc.totalBindings).toBe(0); + expect([...acc.files()]).toEqual([]); + expect(acc.getFile('src/a.ts')).toBeUndefined(); + expect(acc.getFile('src/b.ts')).toBeUndefined(); + expect(acc.fileScopeEntries('src/a.ts')).toEqual([]); + expect(acc.fileScopeEntries('src/b.ts')).toEqual([]); + }); + + it('is idempotent — calling twice is a no-op', () => { + const acc = new BindingAccumulator(); + acc.appendFile('src/a.ts', [{ scope: '', varName: 'x', typeName: 'X' }]); + acc.dispose(); + expect(() => acc.dispose()).not.toThrow(); + expect(acc.fileCount).toBe(0); + expect(acc.totalBindings).toBe(0); + }); + + it('works before finalize() — accumulator behaves like a fresh one after dispose', () => { + const acc = new BindingAccumulator(); + acc.appendFile('src/a.ts', [{ scope: '', varName: 'x', typeName: 'X' }]); + acc.dispose(); + // Not finalized, so appends still work post-dispose. + expect(() => + acc.appendFile('src/b.ts', [{ scope: '', varName: 'y', typeName: 'Y' }]), + ).not.toThrow(); + expect(acc.fileCount).toBe(1); + expect(acc.totalBindings).toBe(1); + expect(acc.getFile('src/b.ts')).toHaveLength(1); + expect(acc.getFile('src/a.ts')).toBeUndefined(); + }); + + it('works after finalize() — append still throws, reads return empty', () => { + const acc = new BindingAccumulator(); + acc.appendFile('src/a.ts', [{ scope: '', varName: 'x', typeName: 'X' }]); + acc.finalize(); + acc.dispose(); + // Finalized, so appends throw even post-dispose. + expect(() => + acc.appendFile('src/b.ts', [{ scope: '', varName: 'y', typeName: 'Y' }]), + ).toThrow(/finalize/); + // But reads return empty. + expect(acc.fileCount).toBe(0); + expect(acc.totalBindings).toBe(0); + expect(acc.getFile('src/a.ts')).toBeUndefined(); + }); + + it('estimateMemoryBytes drops to zero after dispose', () => { + const acc = new BindingAccumulator(); + // Populate a large batch to give the estimate a non-trivial baseline. + for (let i = 0; i < 100; i++) { + acc.appendFile(`src/file${i}.ts`, [ + { scope: '', varName: `var${i}a`, typeName: 'string' }, + { scope: '', varName: `var${i}b`, typeName: 'number' }, + ]); + } + const preDisposeBytes = acc.estimateMemoryBytes(); + expect(preDisposeBytes).toBeGreaterThan(0); + + acc.dispose(); + + // After dispose, the iteration over `_allByFile` in estimateMemoryBytes + // has zero files to walk, so the returned value is exactly 0. + expect(acc.estimateMemoryBytes()).toBe(0); + }); + + it('disposed getter reflects dispose state', () => { + // Locks in the `get disposed()` contract for API symmetry with + // `get finalized()`. Without this test, a trivial wrong impl like + // `get disposed() { return this._finalized; }` passes everything. + const acc = new BindingAccumulator(); + expect(acc.disposed).toBe(false); + acc.appendFile('src/a.ts', [{ scope: '', varName: 'x', typeName: 'X' }]); + expect(acc.disposed).toBe(false); + acc.dispose(); + expect(acc.disposed).toBe(true); + acc.dispose(); // idempotent + expect(acc.disposed).toBe(true); + }); + + it('dispose then finalize: appends throw, state is consistent', () => { + // Orthogonality check: dispose() and finalize() are independent + // lifecycle dimensions. dispose → finalize → appendFile should throw + // the finalized error (because finalize was called), and the + // accumulator should report both flags as true. + const acc = new BindingAccumulator(); + acc.appendFile('src/a.ts', [{ scope: '', varName: 'x', typeName: 'X' }]); + acc.dispose(); + acc.finalize(); + expect(acc.disposed).toBe(true); + expect(acc.finalized).toBe(true); + expect(() => + acc.appendFile('src/b.ts', [{ scope: '', varName: 'y', typeName: 'Y' }]), + ).toThrow(/finalize/); + }); + + it('fileScopeEntries returns a defensive copy — mutation does not corrupt state', () => { + // Encapsulation guard: the cached internal array must not be exposed + // by reference. Mutating the returned array should not affect + // subsequent reads. + const acc = new BindingAccumulator(); + acc.appendFile('src/a.ts', [ + { scope: '', varName: 'x', typeName: 'X' }, + { scope: '', varName: 'y', typeName: 'Y' }, + ]); + + const firstRead = acc.fileScopeEntries('src/a.ts'); + expect(firstRead).toHaveLength(2); + + // Try to corrupt internal state via the returned array. The + // `readonly` return type is compile-time only; cast to mutable at + // runtime to simulate a consumer that bypasses TypeScript. + const mutableView = firstRead as unknown as [string, string][]; + mutableView.push(['corrupted', 'Corrupt']); + mutableView.length = 0; + + // Subsequent reads are unaffected by the mutation attempt. + const secondRead = acc.fileScopeEntries('src/a.ts'); + expect(secondRead).toHaveLength(2); + expect(secondRead[0][0]).toBe('x'); + expect(secondRead[1][0]).toBe('y'); + }); + }); +}); diff --git a/gitnexus/test/unit/type-env.test.ts b/gitnexus/test/unit/type-env.test.ts index db46bb31b..97818819c 100644 --- a/gitnexus/test/unit/type-env.test.ts +++ b/gitnexus/test/unit/type-env.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { buildTypeEnv, type TypeEnvironment } from '../../src/core/ingestion/type-env.js'; +import { BindingAccumulator } from '../../src/core/ingestion/binding-accumulator.js'; import { createSymbolTable, type SymbolDefinition, @@ -5859,4 +5860,215 @@ function process() { expect(typeEnv.lookup('c', validateCall)).toBe('Config'); }); }); + + describe('flush', () => { + it('flushes file-scope bindings into accumulator', () => { + const code = `const user: User = getUser();\nconst count: number = 0;`; + const tree = parse(code, TypeScript.typescript); + const typeEnv = buildTypeEnv(tree, 'typescript'); + const acc = new BindingAccumulator(); + + typeEnv.flush('/src/test.ts', acc); + + const entries = acc.getFile('/src/test.ts'); + expect(entries).toBeDefined(); + const userEntry = entries!.find((e) => e.varName === 'user'); + expect(userEntry).toBeDefined(); + expect(userEntry!.typeName).toBe('User'); + expect(userEntry!.scope).toBe(''); + }); + + // flush() is narrowed to file-scope-only, matching the worker-path + // narrowing. Function-scope entries are dropped at + // the flush seam and never reach the accumulator until a Phase 9 + // consumer lands. This test was previously the positive assertion that + // function-scope entries DID land in the accumulator; it is now a + // negative assertion guarding the narrowing. + it('does NOT flush function-scoped bindings into accumulator (file-scope narrowing)', () => { + const code = `function process() {\n const result: Response = fetch();\n}`; + const tree = parse(code, TypeScript.typescript); + const typeEnv = buildTypeEnv(tree, 'typescript'); + const acc = new BindingAccumulator(); + + typeEnv.flush('/src/test.ts', acc); + + // With only a function-scope binding (`result` inside `process()`) and + // no file-scope bindings, the accumulator should have nothing for this + // file — the function-scope entry is dropped at the flush boundary. + const entries = acc.getFile('/src/test.ts'); + expect(entries).toBeUndefined(); + expect(acc.fileCount).toBe(0); + expect(acc.totalBindings).toBe(0); + }); + + it('narrows mixed file-scope and function-scope env to file-scope only', () => { + // Core narrowing assertion: a realistic file with BOTH file-scope and + // function-scope bindings flushes only the file-scope subset. This is + // the primary narrowing-contract assertion for the sequential path. + const code = `const dbClient: Database = connectDb();\nfunction handleRequest() {\n const localRequest: Request = parseRequest();\n const localUser: User = loadUser();\n}`; + const tree = parse(code, TypeScript.typescript); + const typeEnv = buildTypeEnv(tree, 'typescript'); + const acc = new BindingAccumulator(); + + typeEnv.flush('/src/service.ts', acc); + + const entries = acc.getFile('/src/service.ts'); + expect(entries).toBeDefined(); + // Exactly one entry: the file-scope `dbClient`. The two function-scope + // entries (`localRequest`, `localUser`) are dropped. + expect(entries).toHaveLength(1); + expect(entries![0].scope).toBe(''); + expect(entries![0].varName).toBe('dbClient'); + expect(entries![0].typeName).toBe('Database'); + // Function-scope entries are absent from the accumulator. + expect(entries!.find((e) => e.varName === 'localRequest')).toBeUndefined(); + expect(entries!.find((e) => e.varName === 'localUser')).toBeUndefined(); + }); + + it('flushes nothing for an empty TypeEnv', () => { + const code = `// empty file`; + const tree = parse(code, TypeScript.typescript); + const typeEnv = buildTypeEnv(tree, 'typescript'); + const acc = new BindingAccumulator(); + + typeEnv.flush('/src/empty.ts', acc); + + expect(acc.getFile('/src/empty.ts')).toBeUndefined(); + }); + + it('multiple files flush into same accumulator', () => { + const code1 = `const a: A = makeA();`; + const code2 = `const b: B = makeB();`; + const tree1 = parse(code1, TypeScript.typescript); + const tree2 = parse(code2, TypeScript.typescript); + const typeEnv1 = buildTypeEnv(tree1, 'typescript'); + const typeEnv2 = buildTypeEnv(tree2, 'typescript'); + const acc = new BindingAccumulator(); + + typeEnv1.flush('/src/a.ts', acc); + typeEnv2.flush('/src/b.ts', acc); + + expect(acc.fileCount).toBe(2); + expect(acc.getFile('/src/a.ts')).toBeDefined(); + expect(acc.getFile('/src/b.ts')).toBeDefined(); + }); + + it('throws on second flush of the same TypeEnv (single-use)', () => { + const code = `const x: X = makeX();`; + const tree = parse(code, TypeScript.typescript); + const typeEnv = buildTypeEnv(tree, 'typescript'); + const acc = new BindingAccumulator(); + + typeEnv.flush('/src/a.ts', acc); + expect(() => typeEnv.flush('/src/a.ts', acc)).toThrow(/single-use/); + }); + }); + + // --------------------------------------------------------------------- + // End-to-end integration: drive real TypeEnv → real flush → real + // BindingAccumulator → real enrichExportedTypeMap with a realistic + // graph-node shape. Every other accumulator test is unit-level with + // mocks; this exercises the full wiring between layers that the + // accumulator's bug history has all been in. If the wiring breaks + // (e.g. a future refactor changes TypeEnv's flush output, or the + // enrichment helper's node-ID format drifts), this test fires. + // --------------------------------------------------------------------- + describe('end-to-end: real TypeEnv → flush → accumulator → enrichment', () => { + it('enriches exportedTypeMap with bindings from a real TypeScript file', async () => { + // Lazy import to keep the test co-located without hoisting binding + // accumulator imports to the top of the type-env test file. + const { enrichExportedTypeMap, type: _ignore } = + (await import('../../src/core/ingestion/binding-accumulator.js')) as typeof import('../../src/core/ingestion/binding-accumulator.js') & { + type: unknown; + }; + + const code = ` +export const dbClient: Database = connectDb(); +export const API_URL: string = 'https://api.example.com'; +function internal() { + const localVar: LocalType = makeLocal(); +} +`; + const tree = parse(code, TypeScript.typescript); + const typeEnv = buildTypeEnv(tree, 'typescript'); + const acc = new BindingAccumulator(); + + // Real flush — exercises the narrowed FILE_SCOPE-only iteration. + typeEnv.flush('src/service.ts', acc); + acc.finalize(); + + // Verify the flush wrote only file-scope entries (no `localVar`). + const entries = acc.getFile('src/service.ts'); + expect(entries).toBeDefined(); + const varNames = (entries ?? []).map((e) => e.varName).sort(); + expect(varNames).toEqual(['API_URL', 'dbClient']); + for (const entry of entries ?? []) { + expect(entry.scope).toBe(''); + } + + // Build a minimal realistic graph matching the production node-ID + // candidate order (Function → Variable → Const). The dbClient is + // exported as a Variable, API_URL is exported as a Const. + const graph = { + getNode: (id: string) => { + if (id === 'Variable:src/service.ts:dbClient') { + return { id, properties: { isExported: true } }; + } + if (id === 'Const:src/service.ts:API_URL') { + return { id, properties: { isExported: true } }; + } + return undefined; + }, + }; + const exportedTypeMap = new Map>(); + + // Real enrichment — not a reimplementation. + const enrichedCount = enrichExportedTypeMap(acc, graph, exportedTypeMap); + + expect(enrichedCount).toBe(2); + expect(exportedTypeMap.get('src/service.ts')?.get('dbClient')).toBe('Database'); + expect(exportedTypeMap.get('src/service.ts')?.get('API_URL')).toBe('string'); + // The function-scope `localVar` is absent because flush() narrowed + // it out before it could reach the accumulator. + expect(exportedTypeMap.get('src/service.ts')?.has('localVar')).toBe(false); + + // Lifecycle: dispose releases heap. + acc.dispose(); + expect(acc.disposed).toBe(true); + expect(acc.fileCount).toBe(0); + }); + + it('respects Tier 0 priority when the SymbolTable pre-populated the export', async () => { + const { enrichExportedTypeMap } = + await import('../../src/core/ingestion/binding-accumulator.js'); + + const code = `export const helper: WorkerInferredType = makeHelper();`; + const tree = parse(code, TypeScript.typescript); + const typeEnv = buildTypeEnv(tree, 'typescript'); + const acc = new BindingAccumulator(); + typeEnv.flush('src/utils.ts', acc); + acc.finalize(); + + // Simulate SymbolTable pre-populating the exportedTypeMap with an + // authoritative Tier 0 type. The real enrichment loop must NOT + // overwrite it with the WorkerInferredType from the accumulator. + const exportedTypeMap = new Map>([ + ['src/utils.ts', new Map([['helper', 'SymbolTableAuthoritativeType']])], + ]); + + const graph = { + getNode: (id: string) => + id === 'Const:src/utils.ts:helper' || id === 'Variable:src/utils.ts:helper' + ? { id, properties: { isExported: true } } + : undefined, + }; + + const enriched = enrichExportedTypeMap(acc, graph, exportedTypeMap); + + expect(enriched).toBe(0); + expect(exportedTypeMap.get('src/utils.ts')?.get('helper')).toBe( + 'SymbolTableAuthoritativeType', + ); + }); + }); }); From 6147579e54bcb0c144ab4659ed251c19fd7476c4 Mon Sep 17 00:00:00 2001 From: "Mr. WorldwideBrown" Date: Fri, 10 Apr 2026 09:50:29 +0530 Subject: [PATCH 04/67] Fix security issues and critical bugs found in code review (#709) --- gitnexus-web/src/services/backend-client.ts | 5 +- .../src/core/ingestion/parsing-processor.ts | 7 +- .../core/ingestion/workers/parse-worker.ts | 129 ++++++++++++------ gitnexus/src/server/api.ts | 16 +++ gitnexus/src/server/git-clone.ts | 120 +++++++++++++++- gitnexus/test/helpers/test-indexed-db.ts | 6 +- gitnexus/test/unit/git-clone.test.ts | 92 ++++++++++++- 7 files changed, 323 insertions(+), 52 deletions(-) diff --git a/gitnexus-web/src/services/backend-client.ts b/gitnexus-web/src/services/backend-client.ts index 2c04b66dd..49a521949 100644 --- a/gitnexus-web/src/services/backend-client.ts +++ b/gitnexus-web/src/services/backend-client.ts @@ -222,7 +222,7 @@ export function normalizeServerUrl(input: string): string { // ── Internal Helpers ─────────────────────────────────────────────────────── -const DEFAULT_TIMEOUT_MS = 10_000; +const DEFAULT_TIMEOUT_MS = 30_000; const PROBE_TIMEOUT_MS = 2_000; const fetchWithTimeout = async ( @@ -408,7 +408,8 @@ export const fetchGraph = async ( .filter(Boolean) .join('&'); const url = `${_backendUrl}/api/graph${params ? `?${params}` : ''}`; - const response = await fetchWithTimeout(url, { signal: opts?.signal }, 60_000); + // Large repos can take a while to serialize the graph — use an elevated timeout + const response = await fetchWithTimeout(url, { signal: opts?.signal }, 120_000); await assertOk(response); const contentType = response.headers.get('Content-Type') || ''; diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 5feddd29f..f37f1a52f 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -239,7 +239,12 @@ const seqMethodMapCache = new Map< function seqFindEnclosingClassNode(node: SyntaxNode): SyntaxNode | null { let current = node.parent; while (current) { - if (CLASS_CONTAINER_TYPES.has(current.type)) return current; + if (CLASS_CONTAINER_TYPES.has(current.type)) { + // Return singleton_class directly so the method extractor sees it as + // the owner node and correctly marks methods as static. Name resolution + // for qualified names is handled separately by findEnclosingClassInfo. + return current; + } current = current.parent; } return null; diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index ef01e4c63..7abff42b4 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -378,12 +378,9 @@ function findEnclosingClassNode(node: SyntaxNode): SyntaxNode | null { let current = node.parent; while (current) { if (CLASS_CONTAINER_TYPES.has(current.type)) { - // Ruby singleton_class (class << self) has no name field — walk up to - // the enclosing class/module so the caller gets a node with a findable name. - if (current.type === 'singleton_class') { - current = current.parent; - continue; - } + // Return singleton_class directly so the method extractor sees it as + // the owner node and correctly marks methods as static. Name resolution + // for qualified names is handled separately by findEnclosingClassInfo. return current; } current = current.parent; @@ -841,6 +838,23 @@ const EXPRESS_ROUTE_METHODS = new Set([ // function is captured separately by the route.fetch query. const HTTP_CLIENT_ONLY_METHODS = new Set(['head', 'options', 'request', 'ajax']); +// Known HTTP client receivers u2014 skip these, they're API consumers not routes +const HTTP_CLIENT_RECEIVERS = new Set([ + 'axios', + 'request', + 'fetch', + 'http', + 'https', + 'got', + 'ky', + 'superagent', + 'needle', + 'undici', + 'apiclient', + 'client', + 'httpclient', +]); + // Decorator names that indicate HTTP route handlers (NestJS, Flask, FastAPI, Spring) const ROUTE_DECORATOR_NAMES = new Set([ 'Get', @@ -1178,24 +1192,38 @@ function extractLaravelRoutes(tree: Parser.Tree, filePath: string): ExtractedRou } } - function walk(node: SyntaxNode, groupStack: RouteGroupContext[]) { + // Iterative traversal using an explicit stack to avoid V8 call stack overflow + // on deeply nested ASTs (e.g. Go stdlib, large Grafana components). + // Each frame tracks the node and a snapshot of the group stack at that depth. + interface WalkFrame { + node: SyntaxNode; + groupSnapshot: RouteGroupContext[]; + } + + const walkStack: WalkFrame[] = [{ node: tree.rootNode, groupSnapshot: [] }]; + + while (walkStack.length > 0) { + const { node, groupSnapshot } = walkStack.pop()!; + // Case 1: Simple Route::get(...), Route::post(...), etc. if (isRouteStaticCall(node)) { const method = getCallMethodName(node); if (method && (ROUTE_HTTP_METHODS.has(method) || ROUTE_RESOURCE_METHODS.has(method))) { - emitRoute(method, getArguments(node), node.startPosition.row, groupStack, []); - return; + emitRoute(method, getArguments(node), node.startPosition.row, groupSnapshot, []); + continue; } if (method === 'group') { const argsNode = getArguments(node); const groupCtx = parseArrayGroupArgs(argsNode); const body = findClosureBody(argsNode); if (body) { - groupStack.push(groupCtx); - walkChildren(body, groupStack); - groupStack.pop(); + const childSnapshot = [...groupSnapshot, groupCtx]; + const children = body.children ?? []; + for (let i = children.length - 1; i >= 0; i--) { + walkStack.push({ node: children[i], groupSnapshot: childSnapshot }); + } } - return; + continue; } } @@ -1212,11 +1240,13 @@ function extractLaravelRoutes(tree: Parser.Tree, filePath: string): ExtractedRou } const body = findClosureBody(chain.terminalArgs); if (body) { - groupStack.push(groupCtx); - walkChildren(body, groupStack); - groupStack.pop(); + const childSnapshot = [...groupSnapshot, groupCtx]; + const children = body.children ?? []; + for (let i = children.length - 1; i >= 0; i--) { + walkStack.push({ node: children[i], groupSnapshot: childSnapshot }); + } } - return; + continue; } if ( ROUTE_HTTP_METHODS.has(chain.terminalMethod) || @@ -1226,24 +1256,19 @@ function extractLaravelRoutes(tree: Parser.Tree, filePath: string): ExtractedRou chain.terminalMethod, chain.terminalArgs, node.startPosition.row, - groupStack, + groupSnapshot, chain.attributes, ); - return; + continue; } } - // Default: recurse into children - walkChildren(node, groupStack); - } - - function walkChildren(node: SyntaxNode, groupStack: RouteGroupContext[]) { - for (const child of node.children ?? []) { - walk(child, groupStack); + // Default: push children in reverse so leftmost is processed first + const children = node.children ?? []; + for (let i = children.length - 1; i >= 0; i--) { + walkStack.push({ node: children[i], groupSnapshot }); } } - - walk(tree.rootNode, []); return routes; } @@ -1558,6 +1583,19 @@ const processFileGroup = ( const method = captureMap['express_route.method'].text; const routePath = captureMap['express_route.path'].text; if (EXPRESS_ROUTE_METHODS.has(method) && routePath.startsWith('/')) { + // Extract the receiver (the object the method is called on) to filter out + // HTTP client calls like axios.get('/api/users') that match the same pattern + // as Express route registrations. + const callNode = captureMap['express_route']; + const funcNode = callNode.childForFieldName?.('function') ?? callNode.children?.[0]; + const receiverNode = funcNode?.childForFieldName?.('object') ?? funcNode?.children?.[0]; + const receiverText = receiverNode?.text?.toLowerCase() ?? ''; + + if (HTTP_CLIENT_RECEIVERS.has(receiverText)) { + // This is an HTTP client call, not a route definition u2014 skip it + continue; + } + const httpMethod = method === 'all' || method === 'use' || method === 'route' ? 'GET' @@ -2151,21 +2189,28 @@ let accumulated: ParseWorkerResult = { }; let cumulativeProcessed = 0; +// Use a loop instead of push(...spread) to avoid hitting V8's argument limit +// when merging large result sets (push(...arr) calls apply() under the hood +// and blows the stack when arr has >~65k elements). +const appendAll = (target: T[], src: T[]) => { + for (let i = 0; i < src.length; i++) target.push(src[i]); +}; + const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult) => { - target.nodes.push(...src.nodes); - target.relationships.push(...src.relationships); - target.symbols.push(...src.symbols); - target.imports.push(...src.imports); - target.calls.push(...src.calls); - target.assignments.push(...src.assignments); - target.heritage.push(...src.heritage); - target.routes.push(...src.routes); - target.fetchCalls.push(...src.fetchCalls); - target.decoratorRoutes.push(...src.decoratorRoutes); - target.toolDefs.push(...src.toolDefs); - target.ormQueries.push(...src.ormQueries); - target.constructorBindings.push(...src.constructorBindings); - target.fileScopeBindings.push(...src.fileScopeBindings); + appendAll(target.nodes, src.nodes); + appendAll(target.relationships, src.relationships); + appendAll(target.symbols, src.symbols); + appendAll(target.imports, src.imports); + appendAll(target.calls, src.calls); + appendAll(target.assignments, src.assignments); + appendAll(target.heritage, src.heritage); + appendAll(target.routes, src.routes); + appendAll(target.fetchCalls, src.fetchCalls); + appendAll(target.decoratorRoutes, src.decoratorRoutes); + appendAll(target.toolDefs, src.toolDefs); + appendAll(target.ormQueries, src.ormQueries); + appendAll(target.constructorBindings, src.constructorBindings); + appendAll(target.fileScopeBindings, src.fileScopeBindings); for (const [lang, count] of Object.entries(src.skippedLanguages)) { target.skippedLanguages[lang] = (target.skippedLanguages[lang] || 0) + count; } diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index 8111c287b..bb223696a 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -442,6 +442,22 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => ); app.use(express.json({ limit: '10mb' })); + // Support Chromium Private Network Access (required since Chrome 130+). + // Without this header, Chrome/Edge/Brave/Arc block public->loopback requests + // which breaks bridge mode entirely. + app.use((_req, res, next) => { + res.setHeader('Access-Control-Allow-Private-Network', 'true'); + next(); + }); + + // Handle PNA preflight: Chromium sends Access-Control-Request-Private-Network + // on OPTIONS requests and expects the allow header in the response. + // Note: the actual Allow-Private-Network header is already set by the global + // middleware above, so we just need to call next() here. + app.options('*', (_req, res, next) => { + next(); + }); + // Initialize MCP backend (multi-repo, shared across all MCP sessions) const backend = new LocalBackend(); await backend.init(); diff --git a/gitnexus/src/server/git-clone.ts b/gitnexus/src/server/git-clone.ts index 71985a884..0f7bc2653 100644 --- a/gitnexus/src/server/git-clone.ts +++ b/gitnexus/src/server/git-clone.ts @@ -9,6 +9,7 @@ import { spawn } from 'child_process'; import path from 'path'; import os from 'os'; import fs from 'fs/promises'; +import { isIP } from 'net'; /** Extract the repository name from a git URL (HTTPS or SSH). */ export function extractRepoName(url: string): string { @@ -22,9 +23,18 @@ export function getCloneDir(repoName: string): string { return path.join(os.homedir(), '.gitnexus', 'repos', repoName); } +// Cloud metadata hostnames that must never be reachable via user-supplied URLs +const BLOCKED_HOSTNAMES = new Set([ + 'localhost', + 'metadata.google.internal', + 'metadata.azure.com', + 'metadata.internal', +]); + /** * Validate a git URL to prevent SSRF attacks. - * Only allows https:// and http:// schemes. Blocks private/internal addresses. + * Only allows https:// and http:// schemes. Blocks private/internal addresses, + * IPv6 private ranges, cloud metadata hostnames, and numeric IP encodings. */ export function validateGitUrl(url: string): void { let parsed: URL; @@ -39,15 +49,110 @@ export function validateGitUrl(url: string): void { } const host = parsed.hostname.toLowerCase(); + + // Block known dangerous hostnames (cloud metadata services) + if (BLOCKED_HOSTNAMES.has(host)) { + throw new Error('Cloning from private/internal addresses is not allowed'); + } + + // Strip IPv6 brackets if present (URL parser behavior varies across Node versions) + let normalizedHost = host; + if (host.startsWith('[') && host.endsWith(']')) { + normalizedHost = host.slice(1, -1); + } + + // Check if this is an IPv6 address + // Use manual colon detection as fallback since isIP may return 0 for some + // normalized IPv6 forms (e.g. ::ffff:7f00:1) + const isIPv6 = isIP(normalizedHost) === 6 || normalizedHost.includes(':'); + if (isIPv6) { + assertNotPrivateIPv6(normalizedHost); + return; + } + + // Check if this is an IPv4 address (including numeric encodings) + if (isIP(normalizedHost) === 4) { + assertNotPrivateIPv4(normalizedHost); + return; + } + + // For non-IP hostnames, check for numeric IP tricks + // Decimal encoding: 2130706433 = 127.0.0.1 + // Hex encoding: 0x7f000001 = 127.0.0.1 + if (/^\d+$/.test(host) || /^0x[0-9a-f]+$/i.test(host)) { + throw new Error('Cloning from private/internal addresses is not allowed'); + } + + // Standard IPv4 regex checks for dotted notation if ( - host === 'localhost' || - host === '[::1]' || /^127\./.test(host) || /^10\./.test(host) || /^172\.(1[6-9]|2\d|3[01])\./.test(host) || /^192\.168\./.test(host) || /^169\.254\./.test(host) || - /^0\./.test(host) + /^0\./.test(host) || + host === '0.0.0.0' || + /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(host) || + /^198\.1[89]\./.test(host) + ) { + throw new Error('Cloning from private/internal addresses is not allowed'); + } +} + +function assertNotPrivateIPv6(ip: string): void { + // Expand common compressed forms for comparison + const lower = ip.toLowerCase(); + + // IPv6 loopback + if (lower === '::1' || lower === '0:0:0:0:0:0:0:1') { + throw new Error('Cloning from private/internal addresses is not allowed'); + } + + // Unspecified address + if (lower === '::' || lower === '0:0:0:0:0:0:0:0') { + throw new Error('Cloning from private/internal addresses is not allowed'); + } + + // IPv6 Unique Local Address (fc00::/7 = fc and fd prefixes) + if (lower.startsWith('fc') || lower.startsWith('fd')) { + throw new Error('Cloning from private/internal addresses is not allowed'); + } + + // IPv6 link-local (fe80::/10) + if ( + lower.startsWith('fe80') || + lower.startsWith('fe8') || + lower.startsWith('fe9') || + lower.startsWith('fea') || + lower.startsWith('feb') + ) { + throw new Error('Cloning from private/internal addresses is not allowed'); + } + + // IPv4-mapped IPv6 (::ffff:x.x.x.x or ::ffff:hex:hex) + // Node may normalize ::ffff:127.0.0.1 to ::ffff:7f00:1 + if (lower.startsWith('::ffff:')) { + throw new Error('Cloning from private/internal addresses is not allowed'); + } + + // Also catch the expanded form: 0:0:0:0:0:ffff: + if (lower.includes(':ffff:')) { + throw new Error('Cloning from private/internal addresses is not allowed'); + } +} + +function assertNotPrivateIPv4(ip: string): void { + const parts = ip.split('.').map(Number); + const [a, b] = parts; + if ( + a === 127 || + a === 10 || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + (a === 169 && b === 254) || + a === 0 || + (a === 100 && b >= 64 && b <= 127) || + (a === 198 && (b === 18 || b === 19)) ) { throw new Error('Cloning from private/internal addresses is not allowed'); } @@ -91,6 +196,13 @@ function runGit(args: string[], cwd?: string): Promise { const proc = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + // Prevent git from prompting for credentials (hangs the process) + GIT_TERMINAL_PROMPT: '0', + // Ensure no credential helper tries to open a GUI prompt + GIT_ASKPASS: process.platform === 'win32' ? 'echo' : '/bin/true', + }, }); let stderr = ''; diff --git a/gitnexus/test/helpers/test-indexed-db.ts b/gitnexus/test/helpers/test-indexed-db.ts index a67dd4edc..cd6ffe213 100644 --- a/gitnexus/test/helpers/test-indexed-db.ts +++ b/gitnexus/test/helpers/test-indexed-db.ts @@ -51,7 +51,7 @@ export interface WithTestLbugDBOptions { poolAdapter?: boolean; /** Run after all lifecycle phases complete (mocks, dynamic imports, etc). */ afterSetup?: (handle: IndexedDBHandle) => Promise; - /** Timeout for beforeAll in ms (default: 30000). */ + /** Timeout for beforeAll in ms (default: 120000). */ timeout?: number; } @@ -72,7 +72,9 @@ export function withTestLbugDB( options?: WithTestLbugDBOptions, ): void { const ref: { handle: IndexedDBHandle | undefined } = { handle: undefined }; - const timeout = options?.timeout ?? 30000; + // Default must match vitest.config hookTimeout (120s). KuzuDB pool-adapter + // init on Windows CI regularly exceeds 30s due to native resource setup. + const timeout = options?.timeout ?? 120_000; const setup = async () => { // Get shared DB path from globalSetup (created once with full schema) diff --git a/gitnexus/test/unit/git-clone.test.ts b/gitnexus/test/unit/git-clone.test.ts index 637ff5932..832f95641 100644 --- a/gitnexus/test/unit/git-clone.test.ts +++ b/gitnexus/test/unit/git-clone.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { extractRepoName, getCloneDir } from '../../src/server/git-clone.js'; +import { extractRepoName, getCloneDir, validateGitUrl } from '../../src/server/git-clone.js'; describe('git-clone', () => { describe('extractRepoName', () => { @@ -32,4 +32,94 @@ describe('git-clone', () => { expect(dir).toContain('my-repo'); }); }); + + describe('validateGitUrl', () => { + it('allows valid HTTPS GitHub URLs', () => { + expect(() => validateGitUrl('https://github.com/user/repo.git')).not.toThrow(); + expect(() => validateGitUrl('https://github.com/user/repo')).not.toThrow(); + }); + + it('allows valid HTTP URLs', () => { + expect(() => validateGitUrl('http://gitlab.com/user/repo.git')).not.toThrow(); + }); + + it('blocks SSH protocol', () => { + expect(() => validateGitUrl('ssh://git@github.com/user/repo.git')).toThrow( + 'Only https:// and http://', + ); + }); + + it('blocks file:// protocol', () => { + expect(() => validateGitUrl('file:///etc/passwd')).toThrow('Only https:// and http://'); + }); + + it('blocks IPv4 loopback', () => { + expect(() => validateGitUrl('http://127.0.0.1/repo.git')).toThrow('private/internal'); + expect(() => validateGitUrl('http://127.255.0.1/repo.git')).toThrow('private/internal'); + }); + + it('blocks IPv6 loopback ::1', () => { + // Node URL parser strips brackets: hostname is "::1" not "[::1]" + expect(() => validateGitUrl('http://[::1]/repo.git')).toThrow('private/internal'); + }); + + it('blocks IPv4 private ranges (10.x, 172.16-31.x, 192.168.x)', () => { + expect(() => validateGitUrl('http://10.0.0.1/repo.git')).toThrow('private/internal'); + expect(() => validateGitUrl('http://172.16.0.1/repo.git')).toThrow('private/internal'); + expect(() => validateGitUrl('http://172.31.255.255/repo.git')).toThrow('private/internal'); + expect(() => validateGitUrl('http://192.168.1.1/repo.git')).toThrow('private/internal'); + }); + + it('blocks link-local addresses', () => { + expect(() => validateGitUrl('http://169.254.1.1/repo.git')).toThrow('private/internal'); + }); + + it('blocks cloud metadata hostname', () => { + expect(() => validateGitUrl('http://metadata.google.internal/repo')).toThrow( + 'private/internal', + ); + expect(() => validateGitUrl('http://metadata.azure.com/repo')).toThrow('private/internal'); + }); + + it('blocks IPv6 ULA (fc/fd)', () => { + expect(() => validateGitUrl('http://[fc00::1]/repo.git')).toThrow('private/internal'); + expect(() => validateGitUrl('http://[fd12::1]/repo.git')).toThrow('private/internal'); + }); + + it('blocks IPv6 link-local (fe80)', () => { + expect(() => validateGitUrl('http://[fe80::1]/repo.git')).toThrow('private/internal'); + }); + + it('blocks IPv4-mapped IPv6', () => { + expect(() => validateGitUrl('http://[::ffff:127.0.0.1]/repo.git')).toThrow( + 'private/internal', + ); + }); + + it('does not block valid public IPs', () => { + expect(() => validateGitUrl('https://140.82.121.4/repo.git')).not.toThrow(); + }); + + it('blocks CGN range (100.64.0.0/10)', () => { + expect(() => validateGitUrl('http://100.64.0.1/repo.git')).toThrow('private/internal'); + expect(() => validateGitUrl('http://100.127.255.255/repo.git')).toThrow('private/internal'); + }); + + it('blocks benchmarking range (198.18.0.0/15)', () => { + expect(() => validateGitUrl('http://198.18.0.1/repo.git')).toThrow('private/internal'); + expect(() => validateGitUrl('http://198.19.255.255/repo.git')).toThrow('private/internal'); + }); + + it('blocks numeric decimal IP encoding', () => { + expect(() => validateGitUrl('http://2130706433/repo.git')).toThrow('private/internal'); + }); + + it('blocks hex IP encoding', () => { + expect(() => validateGitUrl('http://0x7f000001/repo.git')).toThrow('private/internal'); + }); + + it('blocks 0.0.0.0', () => { + expect(() => validateGitUrl('http://0.0.0.0/repo.git')).toThrow('private/internal'); + }); + }); }); From ad2a397137069487633bf142d9244f9fdd2f7991 Mon Sep 17 00:00:00 2001 From: Louis Chu Date: Thu, 9 Apr 2026 23:46:25 -0700 Subject: [PATCH 05/67] feat(web): add smart chat scroll --- gitnexus-web/src/components/RightPanel.tsx | 35 ++- gitnexus-web/src/hooks/useAutoScroll.ts | 94 +++++++++ gitnexus-web/src/lib/lucide-icons.tsx | 1 + .../test/unit/use-auto-scroll.test.tsx | 199 ++++++++++++++++++ 4 files changed, 318 insertions(+), 11 deletions(-) create mode 100644 gitnexus-web/src/hooks/useAutoScroll.ts create mode 100644 gitnexus-web/test/unit/use-auto-scroll.test.tsx diff --git a/gitnexus-web/src/components/RightPanel.tsx b/gitnexus-web/src/components/RightPanel.tsx index 44c7ed7ab..35b3a2833 100644 --- a/gitnexus-web/src/components/RightPanel.tsx +++ b/gitnexus-web/src/components/RightPanel.tsx @@ -8,8 +8,10 @@ import { Loader2, AlertTriangle, GitBranch, + ArrowDown, } from '@/lib/lucide-icons'; import { useAppState } from '../hooks/useAppState'; +import { useAutoScroll } from '../hooks/useAutoScroll'; import { ToolCallCard } from './ToolCallCard'; import { isProviderConfigured } from '../core/llm/settings-service'; import { MarkdownRenderer } from './MarkdownRenderer'; @@ -35,14 +37,11 @@ export const RightPanel = () => { const [chatInput, setChatInput] = useState(''); const [activeTab, setActiveTab] = useState<'chat' | 'processes'>('chat'); const textareaRef = useRef(null); - const messagesEndRef = useRef(null); - - // Auto-scroll to bottom when messages update or while streaming - useEffect(() => { - if (messagesEndRef.current) { - messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }); - } - }, [chatMessages, isChatLoading]); + // Keep streamed replies pinned unless the user intentionally scrolls away from the bottom. + const { scrollContainerRef, messagesEndRef, isAtBottom, scrollToBottom } = useAutoScroll( + chatMessages, + isChatLoading, + ); const resolveFilePathForUI = useCallback((_requestedPath: string): string | null => { return null; @@ -265,7 +264,7 @@ export const RightPanel = () => { {/* Chat Content - only show when chat tab is active */} {activeTab === 'chat' && ( -
+
{/* Status bar */}
@@ -291,7 +290,7 @@ export const RightPanel = () => { )} {/* Messages */} -
+
{chatMessages.length === 0 ? (
@@ -391,10 +390,24 @@ export const RightPanel = () => { ))}
)} - {/* Scroll anchor for auto-scroll */} + {/* Scroll anchor */}
+ {/* Scroll to bottom */} + + {/* Input */}
diff --git a/gitnexus-web/src/hooks/useAutoScroll.ts b/gitnexus-web/src/hooks/useAutoScroll.ts new file mode 100644 index 000000000..075152b9e --- /dev/null +++ b/gitnexus-web/src/hooks/useAutoScroll.ts @@ -0,0 +1,94 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; + +const BOTTOM_THRESHOLD = 100; + +export interface UseAutoScrollResult { + scrollContainerRef: React.RefObject; + messagesEndRef: React.RefObject; + isAtBottom: boolean; + scrollToBottom: () => void; +} + +function isNearBottom(element: HTMLDivElement): boolean { + return element.scrollHeight - element.scrollTop - element.clientHeight <= BOTTOM_THRESHOLD; +} + +export function useAutoScroll( + chatMessages: unknown[], + isChatLoading: boolean, +): UseAutoScrollResult { + const scrollContainerRef = useRef(null); + const messagesEndRef = useRef(null); + + const [isAtBottom, setIsAtBottom] = useState(true); + const shouldStickToBottomRef = useRef(true); + const lastScrollTopRef = useRef(0); + const frameIdRef = useRef(null); + + const syncScrollState = useCallback(() => { + const element = scrollContainerRef.current; + if (!element) return; + + const nearBottom = isNearBottom(element); + + if (nearBottom) { + shouldStickToBottomRef.current = true; + } else if (element.scrollTop < lastScrollTopRef.current) { + shouldStickToBottomRef.current = false; + } + + lastScrollTopRef.current = element.scrollTop; + setIsAtBottom(nearBottom); + }, []); + + useEffect(() => { + const element = scrollContainerRef.current; + if (!element) return; + + lastScrollTopRef.current = element.scrollTop; + + const handleScroll = () => { + if (frameIdRef.current !== null) { + cancelAnimationFrame(frameIdRef.current); + } + + frameIdRef.current = requestAnimationFrame(() => { + frameIdRef.current = null; + syncScrollState(); + }); + }; + + element.addEventListener('scroll', handleScroll, { passive: true }); + syncScrollState(); + + return () => { + element.removeEventListener('scroll', handleScroll); + if (frameIdRef.current !== null) { + cancelAnimationFrame(frameIdRef.current); + frameIdRef.current = null; + } + }; + }, [syncScrollState]); + + const jumpToBottom = useCallback(() => { + const element = scrollContainerRef.current; + if (!element) return; + + element.scrollTop = element.scrollHeight; + lastScrollTopRef.current = element.scrollTop; + }, []); + + useLayoutEffect(() => { + if (!shouldStickToBottomRef.current) return; + jumpToBottom(); + setIsAtBottom(true); + }, [chatMessages, isChatLoading, jumpToBottom]); + + const scrollToBottom = useCallback(() => { + shouldStickToBottomRef.current = true; + setIsAtBottom(true); + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }); + }, []); + + return { scrollContainerRef, messagesEndRef, isAtBottom, scrollToBottom }; +} diff --git a/gitnexus-web/src/lib/lucide-icons.tsx b/gitnexus-web/src/lib/lucide-icons.tsx index 7ec9d4122..c6a4b565b 100644 --- a/gitnexus-web/src/lib/lucide-icons.tsx +++ b/gitnexus-web/src/lib/lucide-icons.tsx @@ -9,6 +9,7 @@ export { AlertCircle, AlertTriangle, + ArrowDown, ArrowRight, AtSign, Brain, diff --git a/gitnexus-web/test/unit/use-auto-scroll.test.tsx b/gitnexus-web/test/unit/use-auto-scroll.test.tsx new file mode 100644 index 000000000..6d68dd823 --- /dev/null +++ b/gitnexus-web/test/unit/use-auto-scroll.test.tsx @@ -0,0 +1,199 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAutoScroll } from '../../src/hooks/useAutoScroll'; + +interface HarnessProps { + messages: unknown[]; + isChatLoading: boolean; +} + +function AutoScrollHarness({ messages, isChatLoading }: HarnessProps) { + const { scrollContainerRef, messagesEndRef, isAtBottom, scrollToBottom } = useAutoScroll( + messages, + isChatLoading, + ); + + return ( + <> +
{String(isAtBottom)}
+
+
+
+ + + ); +} + +function setScrollMetrics( + element: HTMLDivElement, + metrics: { scrollTop?: number; scrollHeight?: number; clientHeight?: number }, +) { + if (metrics.scrollTop !== undefined) { + Object.defineProperty(element, 'scrollTop', { + configurable: true, + writable: true, + value: metrics.scrollTop, + }); + } + + if (metrics.scrollHeight !== undefined) { + Object.defineProperty(element, 'scrollHeight', { + configurable: true, + value: metrics.scrollHeight, + }); + } + + if (metrics.clientHeight !== undefined) { + Object.defineProperty(element, 'clientHeight', { + configurable: true, + value: metrics.clientHeight, + }); + } +} + +async function flushAnimationFrame() { + await act(async () => { + vi.runAllTimers(); + }); +} + +async function scrollContainer(element: HTMLDivElement, scrollTop: number) { + setScrollMetrics(element, { scrollTop }); + fireEvent.scroll(element); + await flushAnimationFrame(); +} + +describe('useAutoScroll', () => { + const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; + + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + return window.setTimeout(() => callback(performance.now()), 0); + }), + ); + vi.stubGlobal( + 'cancelAnimationFrame', + vi.fn((frameId: number) => { + clearTimeout(frameId); + }), + ); + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value: vi.fn(), + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value: originalScrollIntoView, + }); + }); + + it('follows streaming updates while the view stays pinned to the bottom', () => { + const { rerender } = render(); + const container = screen.getByTestId('container') as HTMLDivElement; + + setScrollMetrics(container, { + scrollTop: 700, + scrollHeight: 1000, + clientHeight: 200, + }); + + rerender(); + + expect(container.scrollTop).toBe(1000); + expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('true'); + }); + + it('stops auto-scroll after the user scrolls up', async () => { + const { rerender } = render(); + const container = screen.getByTestId('container') as HTMLDivElement; + + setScrollMetrics(container, { + scrollTop: 700, + scrollHeight: 1000, + clientHeight: 200, + }); + await scrollContainer(container, 700); + + await scrollContainer(container, 250); + + expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('false'); + + setScrollMetrics(container, { + scrollTop: 250, + scrollHeight: 1400, + clientHeight: 200, + }); + rerender(); + + expect(container.scrollTop).toBe(250); + }); + + it('re-enables auto-scroll once the user returns near the bottom', async () => { + const { rerender } = render(); + const container = screen.getByTestId('container') as HTMLDivElement; + + setScrollMetrics(container, { + scrollTop: 700, + scrollHeight: 1000, + clientHeight: 200, + }); + await scrollContainer(container, 700); + await scrollContainer(container, 250); + + setScrollMetrics(container, { + scrollTop: 1120, + scrollHeight: 1400, + clientHeight: 200, + }); + await scrollContainer(container, 1120); + + expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('true'); + + setScrollMetrics(container, { + scrollTop: 1120, + scrollHeight: 1800, + clientHeight: 200, + }); + rerender(); + + expect(container.scrollTop).toBe(1800); + }); + + it('scrollToBottom re-engages auto-scroll and uses the sentinel element', async () => { + const { rerender } = render(); + const container = screen.getByTestId('container') as HTMLDivElement; + const scrollIntoView = vi.mocked(HTMLElement.prototype.scrollIntoView); + + setScrollMetrics(container, { + scrollTop: 700, + scrollHeight: 1000, + clientHeight: 200, + }); + await scrollContainer(container, 700); + await scrollContainer(container, 250); + + fireEvent.click(screen.getByRole('button', { name: 'Scroll to bottom' })); + + expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'end' }); + expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('true'); + + setScrollMetrics(container, { + scrollTop: 250, + scrollHeight: 1600, + clientHeight: 200, + }); + rerender(); + + expect(container.scrollTop).toBe(1600); + }); +}); From ab956f113c034c2a130d888d31233d1ecaa9603a Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:29:31 +0100 Subject: [PATCH 06/67] feat(SM-15): Wire BindingAccumulator into processCallsFromExtracted for cross-file return type propagation (#763) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * Initial setup - Phase 9 BindingAccumulator cross-file return type wiring Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/7cee6490-090d-4714-8cb5-a704168ff47a * feat(SM-15): wire BindingAccumulator into processCallsFromExtracted for Phase 9 cross-file return type propagation Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/7cee6490-090d-4714-8cb5-a704168ff47a * fix(SM-15): address all PR #763 review findings Performance (R1) - Changed _fileScopeByFile from Map to Map>. fileScopeGet(filePath, name) is now O(1) — replaces the O(n) linear scan + defensive-copy alloc that ran once per ConstructorBinding entry. fileScopeEntries() reconstructs tuples from Map.entries() for backward compat. - Updated finalize() dev-mode invariant to compare deduplicated Map size rather than raw array length (Map.set deduplicates same-name). Lifecycle (R2) - Documented that Phase 9 intentionally reads pre-finalize because finalize() cannot move before both the worker consumer (line 984) AND the sequential-path writer (line 1061). Pre-finalize reads are safe because finalize() is write-lock-only with no side effects. Replaced the ambiguous "populated but not yet finalized" comment with the full lifecycle ordering explanation. Sequential-path parity (R3) - Wired bindingAccumulator into processCalls at line 797 (sequential path) so verifyConstructorBindings gets the Phase 9 fallback. - Added bindingAccumulator parameter to processAssignmentsFromExtracted signature and wired it at the pipeline.ts call site (line 1026). - Both paths now produce identical Phase 9 behavior for the same code. Tracking comments (R4) - Added "Overlapping mechanism (N of 3)" cross-references at: 1. buildImportedReturnTypes (~line 109) 2. collectExportedBindings (~line 168) 3. Phase 9 fallback in verifyConstructorBindings (~line 563) Each links to the other two and notes future unification. Language coverage (R5) - Added 5 new Phase 9 integration test suites in cross-file-binding.test.ts: JavaScript, C++, C#, PHP, Ruby. Each uses the existing fixture directories and asserts getUser() → User → user.save() resolves. Total cross-file binding tests: 52 (was 37). Quality asymmetry (R6) - Added inline comment at the Phase 9 fallback noting worker-path entries are Tier 0/1 only and that binding accuracy is structurally lower for large repos where the worker path dominates. Tests (+21 new) - 6 fileScopeGet unit tests (happy path, unknown file/name, mixed scopes, post-dispose, duplicate varName last-write-wins) - 15 integration tests across 5 new language suites Verification - tsc --noEmit clean - 3147 unit tests pass (+6 new) - 52 cross-file binding integration tests pass (+15 new) - 1766 resolver integration tests pass - Zero regressions Plan: docs/plans/2026-04-10-001-fix-sm15-review-findings-plan.md Review: https://github.com/abhigyanpatwari/GitNexus/pull/763#issuecomment-4220354242 * fix(SM-15): gate accumulator fallback on resolution tier and fix sequential file-order dependency Two Codex adversarial reviews identified medium-severity bugs in the Phase 9 BindingAccumulator fallback: 1. Local-first violation: the fallback fired regardless of whether ctx.resolve() found same-file candidates, letting an imported callee shadow a local one and produce false CALLS edges. Fixed by gating on tiered.tier !== 'same-file' and callableDefs.length <= 1. 2. Sequential file-order dependency: processCalls flushed and verified per-file, so consumer files processed before their providers missed accumulator bindings. Fixed by splitting into a flush pre-pass (all files) then a resolution loop, mirroring the worker path's "all appends before any reads" pattern. Also adds 11 consumer-before-provider integration test fixtures (one per supported language) and 4 unit tests for tier gating edge cases. * refactor(SM-15): eliminate duplicated prepare logic in processCalls two-pass split Replace the duplicated pre-pass + legacy-path code (parse → query → heritage → TypeEnv → exports) with a single preparation loop followed by a resolution loop. Both paths now share the same preparation code — the only conditional is the accumulator flush. Side benefit: globalParentMap is now fully populated before any resolution runs, improving cross-file isSubclassOf accuracy regardless of file order. Net -118 lines (226 removed, 108 added). * fix(SM-15): address PR #763 third-pass review findings 1. Update stale dispose() JSDoc — remove forward-reference to Phase 9 wiring that is now complete; document actual consumers. 2. Add processAssignmentsFromExtracted Phase 9 unit test — verifies the accumulator fallback produces ACCESSES write edges when the SymbolTable has no returnType for the callee. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Gergo Magyar --- AGENTS.md | 2 +- CLAUDE.md | 102 ++- gitnexus/package-lock.json | 1 + .../src/core/ingestion/binding-accumulator.ts | 93 +-- gitnexus/src/core/ingestion/call-processor.ts | 155 ++++- gitnexus/src/core/ingestion/pipeline.ts | 31 +- .../a_consumer/main.cpp | 6 + .../b_provider/provider.cpp | 7 + .../b_provider/provider.h | 8 + .../AConsumer/Program.cs | 13 + .../BProvider/User.cs | 7 + .../BProvider/UserFactory.cs | 7 + .../ConsumerBeforeProvider.csproj | 5 + .../go-consumer-before-provider/app/main.go | 8 + .../go-consumer-before-provider/go.mod | 3 + .../models/user.go | 9 + .../app/AConsumer.java | 10 + .../models/BProvider.java | 7 + .../models/User.java | 5 + .../js-consumer-before-provider/a-consumer.js | 10 + .../js-consumer-before-provider/b-provider.js | 7 + .../app/AConsumer.kt | 10 + .../models/BProvider.kt | 7 + .../app/AConsumer.php | 12 + .../app/BProvider.php | 11 + .../composer.json | 7 + .../src/a_consumer.py | 5 + .../src/b_provider.py | 6 + .../rb-consumer-before-provider/a_consumer.rb | 6 + .../models/b_user.rb | 4 + .../models/b_user_factory.rb | 7 + .../src/a_consumer.rs | 6 + .../src/b_provider.rs | 9 + .../rs-consumer-before-provider/src/main.rs | 2 + .../src/a-consumer.ts | 10 + .../src/b-provider.ts | 7 + .../integration/cross-file-binding.test.ts | 578 +++++++++++++++++ .../test/unit/binding-accumulator.test.ts | 48 ++ gitnexus/test/unit/call-processor.test.ts | 596 ++++++++++++++++++ 39 files changed, 1759 insertions(+), 68 deletions(-) create mode 100644 gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/a_consumer/main.cpp create mode 100644 gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/b_provider/provider.cpp create mode 100644 gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/b_provider/provider.h create mode 100644 gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/AConsumer/Program.cs create mode 100644 gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/BProvider/User.cs create mode 100644 gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/BProvider/UserFactory.cs create mode 100644 gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/ConsumerBeforeProvider.csproj create mode 100644 gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/app/main.go create mode 100644 gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/go.mod create mode 100644 gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/models/user.go create mode 100644 gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/app/AConsumer.java create mode 100644 gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/models/BProvider.java create mode 100644 gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/models/User.java create mode 100644 gitnexus/test/fixtures/cross-file-binding/js-consumer-before-provider/a-consumer.js create mode 100644 gitnexus/test/fixtures/cross-file-binding/js-consumer-before-provider/b-provider.js create mode 100644 gitnexus/test/fixtures/cross-file-binding/kotlin-consumer-before-provider/app/AConsumer.kt create mode 100644 gitnexus/test/fixtures/cross-file-binding/kotlin-consumer-before-provider/models/BProvider.kt create mode 100644 gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/app/AConsumer.php create mode 100644 gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/app/BProvider.php create mode 100644 gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/composer.json create mode 100644 gitnexus/test/fixtures/cross-file-binding/py-consumer-before-provider/src/a_consumer.py create mode 100644 gitnexus/test/fixtures/cross-file-binding/py-consumer-before-provider/src/b_provider.py create mode 100644 gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/a_consumer.rb create mode 100644 gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/models/b_user.rb create mode 100644 gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/models/b_user_factory.rb create mode 100644 gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/a_consumer.rs create mode 100644 gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/b_provider.rs create mode 100644 gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/main.rs create mode 100644 gitnexus/test/fixtures/cross-file-binding/ts-consumer-before-provider/src/a-consumer.ts create mode 100644 gitnexus/test/fixtures/cross-file-binding/ts-consumer-before-provider/src/b-provider.ts diff --git a/AGENTS.md b/AGENTS.md index 09c3eeb12..e6cefed11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,7 +63,7 @@ Generic “core standards” playbooks are often long and stack-specific. For th # GitNexus — Code Intelligence -This project is indexed by GitNexus as **GitNexus** (3298 symbols, 7954 relationships, 185 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **GitNexus** (3883 symbols, 9861 relationships, 225 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/CLAUDE.md b/CLAUDE.md index 27ab5de74..7b0f175b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,107 @@ If always-on instructions grow, load deep conventions via conditional reads (e.g ## GitNexus rules -GitNexus MCP rules are in the `` … `` block in **[AGENTS.md](AGENTS.md)** — load that section when working with MCP tools or the graph index. +GitNexus MCP rules are in the ` +# GitNexus — Code Intelligence + +This project is indexed by GitNexus as **GitNexus** (3883 symbols, 9861 relationships, 225 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. + +> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. + +## Always Do + +- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. +- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. +- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. +- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. +- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. + +## When Debugging + +1. `gitnexus_query({query: ""})` — find execution flows related to the issue +2. `gitnexus_context({name: ""})` — see all callers, callees, and process participation +3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step +4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed + +## When Refactoring + +- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`. +- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code. +- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed. + +## Never Do + +- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. +- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. +- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. +- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. + +## Tools Quick Reference + +| Tool | When to use | Command | +|------|-------------|---------| +| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | +| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | +| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | +| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | +| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | +| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | + +## Impact Risk Levels + +| Depth | Meaning | Action | +|-------|---------|--------| +| d=1 | WILL BREAK — direct callers/importers | MUST update these | +| d=2 | LIKELY AFFECTED — indirect deps | Should test | +| d=3 | MAY NEED TESTING — transitive | Test if critical path | + +## Resources + +| Resource | Use for | +|----------|---------| +| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness | +| `gitnexus://repo/GitNexus/clusters` | All functional areas | +| `gitnexus://repo/GitNexus/processes` | All execution flows | +| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace | + +## Self-Check Before Finishing + +Before completing any code modification task, verify: +1. `gitnexus_impact` was run for all modified symbols +2. No HIGH/CRITICAL risk warnings were ignored +3. `gitnexus_detect_changes()` confirms changes match expected scope +4. All d=1 (WILL BREAK) dependents were updated + +## Keeping the Index Fresh + +After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it: + +```bash +npx gitnexus analyze +``` + +If the index previously included embeddings, preserve them by adding `--embeddings`: + +```bash +npx gitnexus analyze --embeddings +``` + +To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.** + +> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`. + +## CLI + +| Task | Read this skill file | +|------|---------------------| +| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | +| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | +| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | +| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | +| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | +| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | + +` block in **[AGENTS.md](AGENTS.md)** — load that section when working with MCP tools or the graph index. # GitNexus — Code Intelligence diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 9abd15b0e..3736d0c46 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -17,6 +17,7 @@ "commander": "^12.0.0", "cors": "^2.8.5", "express": "^4.19.2", + "gitnexus-shared": "file:../gitnexus-shared", "glob": "^11.0.0", "graphology": "^0.25.4", "graphology-indices": "^0.17.0", diff --git a/gitnexus/src/core/ingestion/binding-accumulator.ts b/gitnexus/src/core/ingestion/binding-accumulator.ts index 3e7563623..0f7199b2b 100644 --- a/gitnexus/src/core/ingestion/binding-accumulator.ts +++ b/gitnexus/src/core/ingestion/binding-accumulator.ts @@ -139,18 +139,24 @@ const ENTRY_OVERHEAD = 64; // bytes per entry (object overhead + property refs) const MAP_ENTRY_OVERHEAD = 80; // bytes per file entry in the map export class BindingAccumulator { - // Storage is split into two parallel maps so fileScopeEntries() is - // O(n_file_scope) instead of O(n_total). + // Storage is split into two parallel maps so file-scope reads are fast. // - _allByFile holds every BindingEntry (used by getFile, memory estimate). - // - _fileScopeByFile caches the flat [varName, typeName] view of the - // `scope === ''` subset, populated at insert time so reads are O(1) map - // lookup + O(n_file_scope) array return. Both maps carry the same key - // set modulo the `scope === ''` precondition: _allByFile has a key as - // soon as any entry is appended; _fileScopeByFile only has a key once a - // file-scope entry arrives. Code that iterates via files() uses - // _allByFile so files with only function-scope entries remain visible. + // - _fileScopeByFile is a nested Map> for + // O(1) point-lookup via fileScopeGet(). For iteration-based consumers + // (enrichExportedTypeMap), fileScopeEntries() iterates the inner Map. + // Both maps carry the same key set modulo the `scope === ''` precondition: + // _allByFile has a key as soon as any entry is appended; _fileScopeByFile + // only has a key once a file-scope entry arrives. Code that iterates via + // files() uses _allByFile so files with only function-scope entries + // remain visible. + // + // Note: Map.set semantics mean a duplicate varName for the same file + // overwrites the previous value (last-write-wins). This is the correct + // behavior — duplicate top-level bindings in the same file shouldn't + // happen in well-formed source, and if they do the last declaration + // is typically the one the compiler sees. private readonly _allByFile = new Map(); - private readonly _fileScopeByFile = new Map(); + private readonly _fileScopeByFile = new Map>(); private _totalBindings = 0; private _finalized = false; private _disposed = false; @@ -202,15 +208,16 @@ export class BindingAccumulator { } else { this._allByFile.set(filePath, entries.slice()); } - // File-scope fast-path store. Populated lazily on first file-scope entry. - let existingFileScope = this._fileScopeByFile.get(filePath); + // File-scope fast-path store (nested Map for O(1) point-lookup via fileScopeGet). + // Populated lazily on first file-scope entry per file. + let fileScopeMap = this._fileScopeByFile.get(filePath); for (const e of entries) { if (e.scope === '') { - if (existingFileScope === undefined) { - existingFileScope = []; - this._fileScopeByFile.set(filePath, existingFileScope); + if (fileScopeMap === undefined) { + fileScopeMap = new Map(); + this._fileScopeByFile.set(filePath, fileScopeMap); } - existingFileScope.push([e.varName, e.typeName]); + fileScopeMap.set(e.varName, e.typeName); } } this._totalBindings += entries.length; @@ -225,7 +232,7 @@ export class BindingAccumulator { // indicate a bug in `appendFile()` where one map was updated but // not the other. if (process.env.NODE_ENV !== 'production' && !this._finalized) { - for (const [filePath, fileScopeTuples] of this._fileScopeByFile) { + for (const [filePath, fileScopeMap] of this._fileScopeByFile) { const allEntries = this._allByFile.get(filePath); if (allEntries === undefined) { throw new Error( @@ -233,12 +240,16 @@ export class BindingAccumulator { `but no _allByFile entry`, ); } - const projectedCount = allEntries.filter((e) => e.scope === '').length; - if (projectedCount !== fileScopeTuples.length) { + // Count unique file-scope varNames in _allByFile (to match Map dedup + // semantics in _fileScopeByFile where Map.set deduplicates same-name). + const projectedNames = new Set( + allEntries.filter((e) => e.scope === '').map((e) => e.varName), + ); + if (projectedNames.size !== fileScopeMap.size) { throw new Error( `[BindingAccumulator] storage split drift: file ${filePath} has ` + - `${fileScopeTuples.length} file-scope tuples but ${projectedCount} file-scope ` + - `entries in _allByFile`, + `${fileScopeMap.size} file-scope names in Map but ${projectedNames.size} unique ` + + `file-scope varNames in _allByFile`, ); } } @@ -265,12 +276,11 @@ export class BindingAccumulator { * **after** `finalize()`, subsequent `appendFile` calls throw the existing * "finalized" error. * - * Lifecycle note: the pipeline disposes the accumulator after the - * ExportedTypeMap enrichment loop consumes its file-scope entries, so - * the heap is released before Phase 14 (`runCrossFileBindingPropagation`) - * and `runGraphAnalysisPhases` begin their long-running work. When Phase 9 - * wires a consumer into that stage, the dispose call should move later in - * the pipeline or be removed entirely. + * Lifecycle note: the pipeline disposes the accumulator after both Phase 9 + * consumers (`processCallsFromExtracted`, `processAssignmentsFromExtracted`) + * and the ExportedTypeMap enrichment loop have completed, so the heap is + * released before Phase 14 (`runCrossFileBindingPropagation`) and + * `runGraphAnalysisPhases` begin their long-running work. */ dispose(): void { this._allByFile.clear(); @@ -286,21 +296,30 @@ export class BindingAccumulator { /** * Get only scope='' (file-level) entries as [varName, typeName] tuples. - * Backward-compatible with the old workerTypeEnvBindings pattern. + * For iteration-based consumers (e.g., `enrichExportedTypeMap`). * Returns an empty array for an unknown file. * - * O(1) map lookup + O(n_file_scope) defensive-copy construction — does - * NOT walk function-scope entries. See the `_fileScopeByFile` field - * comment for the storage split rationale. + * O(1) map lookup + O(n_file_scope) tuple reconstruction from the inner + * Map. Does NOT walk function-scope entries. * - * The return value is a shallow copy; mutating it does not affect - * subsequent reads or internal state. This encapsulation guard prevents - * a Phase 9 consumer from accidentally corrupting the accumulator via - * `acc.fileScopeEntries(p).push(...)` or similar. + * For point-lookup consumers (e.g., Phase 9 fallback), prefer + * `fileScopeGet(filePath, name)` — O(1) with no allocation. */ fileScopeEntries(filePath: string): readonly (readonly [string, string])[] { - const cached = this._fileScopeByFile.get(filePath); - return cached ? cached.slice() : []; + const map = this._fileScopeByFile.get(filePath); + return map ? [...map.entries()] : []; + } + + /** + * O(1) point-lookup for a single file-scope binding by (filePath, name). + * Returns the typeName if found, `undefined` otherwise. + * + * This is the preferred lookup path for Phase 9 consumers that resolve + * a single callee's return type — avoids the O(n_file_scope) iteration + * and defensive-copy allocation of `fileScopeEntries()`. + */ + fileScopeGet(filePath: string, name: string): string | undefined { + return this._fileScopeByFile.get(filePath)?.get(name); } /** Iterate over all file paths in insertion order. */ diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index bd49f4d09..755ec1e29 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -105,7 +105,13 @@ const MAX_EXPORTS_PER_FILE = 500; const MAX_TYPE_NAME_LENGTH = 256; /** Build a map of imported callee names → return types for cross-file call-result binding. - * Consulted ONLY when SymbolTable has no unambiguous local match (local-first principle). */ + * Consulted ONLY when SymbolTable has no unambiguous local match (local-first principle). + * + * Overlapping mechanism (1 of 3): this is the SymbolTable-backed path. + * See also: + * 2. collectExportedBindings (~line 168) / enrichExportedTypeMap — TypeEnv + graph isExported + * 3. Phase 9 fallback in verifyConstructorBindings (~line 563) — namedImportMap + BindingAccumulator + * A future cleanup should merge these into a single resolution pass. */ export function buildImportedReturnTypes( filePath: string, namedImportMap: ReadonlyMap< @@ -163,8 +169,13 @@ export function buildImportedRawReturnTypes( * quality enrichment"). Both sites populate the same map with subtly * different export-check semantics — this site uses SymbolTable + * graph lookup, the worker loop uses three-candidate-ID graph lookup. - * They must stay in sync until Phase 9 unifies them. If you edit one, - * check the other. */ + * They must stay in sync until unified. If you edit one, check the other. + * + * Overlapping mechanism (2 of 3): this is the TypeEnv + graph isExported path. + * See also: + * 1. buildImportedReturnTypes (~line 109) — namedImportMap + SymbolTable + * 3. Phase 9 fallback in verifyConstructorBindings (~line 563) — namedImportMap + BindingAccumulator + * A future cleanup should merge these into a single resolution pass. */ function collectExportedBindings( typeEnv: { fileScope(): ReadonlyMap }, filePath: string, @@ -512,6 +523,7 @@ const verifyConstructorBindings = ( filePath: string, ctx: ResolutionContext, graph?: KnowledgeGraph, + bindingAccumulator?: BindingAccumulator, ): Map => { const verified = new Map(); @@ -548,12 +560,60 @@ const verifyConstructorBindings = ( } } + let typeName: string | undefined; if (callableDefs && callableDefs.length === 1 && callableDefs[0].returnType) { - const typeName = extractReturnTypeName(callableDefs[0].returnType); - if (typeName) { - verified.set(receiverKey(scope, varName), typeName); + typeName = extractReturnTypeName(callableDefs[0].returnType); + } + + // Phase 9: BindingAccumulator fallback for cross-file return types. + // Used when the SymbolTable has no return type for a cross-file callee + // (e.g., a return type that TypeEnv resolved via fixpoint in the source + // file but was not stored as a SymbolTable returnType annotation). + // namedImportMap tells us which source file exported the callee so we + // can look up its file-scope binding via the O(1) fileScopeGet method. + // + // Tier gating: only fall back to the accumulator when resolution is + // unambiguously import-scoped or global. When tiered.tier is 'same-file', + // the local definition is authoritative even without a return type + // annotation — using the accumulator here would let an imported callee + // with the same name shadow the local one, producing false CALLS edges. + // When multiple callable candidates exist, the accumulator would pick + // arbitrarily — skip to avoid fabricated edges. + // + // Quality note: worker-path accumulator entries are Tier 0/1 only + // (annotation-declared + same-file constructor inference) — see the + // BindingAccumulator class JSDoc. For large repos where the worker + // path dominates, Phase 9 binding accuracy is structurally lower + // than for sequential-path repos where Tier 2 cross-file propagation + // is available. + // + // Overlapping mechanism note: this is one of three cross-file + // return-type resolution paths in the codebase: + // 1. buildImportedReturnTypes (~line 109) — namedImportMap + + // SymbolTable.lookupExactFull (structure-processor captured) + // 2. collectExportedBindings (~line 168) / enrichExportedTypeMap + // — TypeEnv + graph isExported flag + // 3. This fallback — namedImportMap + BindingAccumulator + // A future cleanup should merge these into a single resolution pass. + const shouldFallback = + tiered?.tier !== 'same-file' && (!callableDefs || callableDefs.length <= 1); + if (!typeName && bindingAccumulator && shouldFallback) { + const namedImports = ctx.namedImportMap.get(filePath); + const importBinding = namedImports?.get(calleeName); + if (importBinding) { + const rawType = bindingAccumulator.fileScopeGet( + importBinding.sourcePath, + importBinding.exportedName, + ); + if (rawType) { + typeName = extractReturnTypeName(rawType); + } } } + + if (typeName) { + verified.set(receiverKey(scope, varName), typeName); + } } } @@ -640,10 +700,29 @@ export const processCalls = async ( const logSkipped = isVerboseIngestionEnabled(); const skippedByLang = logSkipped ? new Map() : null; + // ── Prepare-then-resolve: single preparation loop, deferred resolution ── + // All files are prepared (parse → query → heritage → TypeEnv) in one loop, + // then resolved (verifyConstructorBindings → call edges) in a second loop. + // This ensures: + // 1. When bindingAccumulator is present, ALL files flush their TypeEnv + // bindings before ANY verifyConstructorBindings reads — fixing the + // consumer-before-provider ordering bug on the sequential path. + // 2. globalParentMap is fully populated before resolution, improving + // cross-file isSubclassOf accuracy regardless of file order. + // For the sequential path (<15 files), buffering per-file state is negligible. + interface PreparedFile { + file: { path: string; content: string }; + language: SupportedLanguages; + provider: ReturnType; + tree: ReturnType; + matches: ReturnType; + parentMap: ReadonlyMap; + typeEnv: ReturnType; + } + const prepared: PreparedFile[] = []; + for (let i = 0; i < files.length; i++) { const file = files[i]; - enclosingFnExtractCache.clear(); - onProgress?.(i + 1, files.length); if (i % 20 === 0) await yieldToEventLoop(); const language = getLanguageFromFilename(file.path); @@ -673,18 +752,17 @@ export const processCalls = async ( astCache.set(file.path, tree); } - let query; let matches; try { - const language = parser.getLanguage(); - query = new Parser.Query(language, queryStr); + const lang = parser.getLanguage(); + const query = new Parser.Query(lang, queryStr); matches = query.matches(tree.rootNode); } catch (queryError) { console.warn(`Query error for ${file.path}:`, queryError); continue; } - // Pre-pass: extract heritage from query matches to build parentMap for buildTypeEnv. + // Extract heritage from query matches to build parentMap for buildTypeEnv. // Heritage-processor runs in PARALLEL, so graph edges don't exist when buildTypeEnv runs. const fileParentMap = new Map(); for (const match of matches) { @@ -707,7 +785,6 @@ export const processCalls = async ( } const parentMap: ReadonlyMap = fileParentMap; // Merge per-file heritage into globalParentMap for cross-file isSubclassOf lookups. - // Uses a parallel Set (globalParentSeen) for O(1) deduplication instead of O(n) includes(). for (const [cls, parents] of fileParentMap) { let global = globalParentMap.get(cls); let seen = globalParentSeen.get(cls); @@ -743,19 +820,35 @@ export const processCalls = async ( const fileExports = collectExportedBindings(typeEnv, file.path, ctx.symbols, graph); if (fileExports) exportedTypeMap.set(file.path, fileExports); } - // Flush file-scope bindings into the accumulator. `flush()` is narrowed - // to iterate only FILE_SCOPE entries (type-env.ts) — function-scope - // bindings are dropped at the flush boundary until a Phase 9 consumer - // lands. See type-env.ts::flush() JSDoc for the dual-site reversion - // checklist (this sequential path + the worker path in parse-worker.ts). if (bindingAccumulator) { typeEnv.flush(file.path, bindingAccumulator); } + + prepared.push({ file, language, provider, tree, matches, parentMap, typeEnv }); + } + + // ── Resolution loop: verify constructor bindings and resolve calls ── + // The accumulator (if present) is now fully populated from the preparation + // loop above, so verifyConstructorBindings sees all provider bindings + // regardless of file processing order. + for (let i = 0; i < prepared.length; i++) { + const { file, language, provider, tree, matches, parentMap, typeEnv } = prepared[i]; + + enclosingFnExtractCache.clear(); + onProgress?.(i + 1, files.length); + if (i % 20 === 0) await yieldToEventLoop(); + const callRouter = provider.callRouter; const verifiedReceivers = typeEnv.constructorBindings.length > 0 - ? verifyConstructorBindings(typeEnv.constructorBindings, file.path, ctx) + ? verifyConstructorBindings( + typeEnv.constructorBindings, + file.path, + ctx, + undefined, // graph not available on the sequential path here + bindingAccumulator, // Phase 9 fallback — same as worker path (R3 parity) + ) : new Map(); const receiverIndex = buildReceiverTypeIndex(verifiedReceivers); @@ -2474,6 +2567,12 @@ const walkMixedChain = ( /** * Fast path: resolve pre-extracted call sites from workers. * No AST parsing — workers already extracted calledName + sourceId. + * + * @param bindingAccumulator Phase 9: optional accumulator carrying file-scope + * TypeEnv bindings from all worker-processed files. When the SymbolTable has + * no return type for a cross-file callee, `verifyConstructorBindings` falls + * back to the accumulator via `namedImportMap` to bind the variable to the + * callee's resolved type (e.g. `var x = getUser()` → `x: User`). */ export const processCallsFromExtracted = async ( graph: KnowledgeGraph, @@ -2482,6 +2581,7 @@ export const processCallsFromExtracted = async ( onProgress?: (current: number, total: number) => void, constructorBindings?: FileConstructorBindings[], heritageMap?: HeritageMap, + bindingAccumulator?: BindingAccumulator, ) => { // Scope-aware receiver types: keyed by filePath → "funcName\0varName" → typeName. // The scope dimension prevents collisions when two functions in the same file @@ -2489,7 +2589,13 @@ export const processCallsFromExtracted = async ( const fileReceiverTypes = new Map(); if (constructorBindings) { for (const { filePath, bindings } of constructorBindings) { - const verified = verifyConstructorBindings(bindings, filePath, ctx, graph); + const verified = verifyConstructorBindings( + bindings, + filePath, + ctx, + graph, + bindingAccumulator, + ); if (verified.size > 0) { fileReceiverTypes.set(filePath, buildReceiverTypeIndex(verified)); } @@ -2687,12 +2793,19 @@ export const processAssignmentsFromExtracted = ( assignments: ExtractedAssignment[], ctx: ResolutionContext, constructorBindings?: FileConstructorBindings[], + bindingAccumulator?: BindingAccumulator, ): void => { // Build per-file receiver type indexes from verified constructor bindings const fileReceiverTypes = new Map(); if (constructorBindings) { for (const { filePath, bindings } of constructorBindings) { - const verified = verifyConstructorBindings(bindings, filePath, ctx, graph); + const verified = verifyConstructorBindings( + bindings, + filePath, + ctx, + graph, + bindingAccumulator, + ); if (verified.size > 0) { fileReceiverTypes.set(filePath, buildReceiverTypeIndex(verified)); } diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 9a521e7c5..9c754ed2f 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -1000,6 +1000,19 @@ async function runChunkedParseAndResolve( }, deferredConstructorBindings.length > 0 ? deferredConstructorBindings : undefined, fullWorkerHeritageMap, + // Phase 9: pass the accumulator so processCallsFromExtracted can fall back + // to file-scope TypeEnv bindings when the SymbolTable lacks a return type + // for a cross-file callee (e.g. var x = getUser() → x: User). + // + // Lifecycle ordering: the accumulator is populated but NOT yet finalized + // at this seam. finalize() is called later (after the sequential-path + // processCalls which also appends via typeEnv.flush()). Moving finalize() + // before this call would break sequential-path repos. Pre-finalize reads + // are safe because finalize() is a write-lock-only operation with no side + // effects on stored data. All worker-path appendFile calls complete in the + // chunk loop above, so every worker-contributed binding is available via + // fileScopeGet(). + bindingAccumulator, ); } @@ -1009,6 +1022,7 @@ async function runChunkedParseAndResolve( deferredAssignments, ctx, deferredConstructorBindings.length > 0 ? deferredConstructorBindings : undefined, + bindingAccumulator, // Phase 9 fallback parity with processCallsFromExtracted (R3) ); } } finally { @@ -1759,14 +1773,15 @@ export const runPipelineFromRepo = async ( } } - // Release the accumulator's heap footprint now. The ExportedTypeMap - // enrichment loop above is the only current consumer, and the dev - // telemetry log just captured peak state. Phase 14 and - // runGraphAnalysisPhases do not read the accumulator today — keeping - // it alive through those long-running phases pins heap for no reason. - // When Phase 9 wires a consumer into runCrossFileBindingPropagation, - // move this dispose() call to after that consumer completes or delete - // it entirely if the consumer takes lifecycle ownership. + // Release the accumulator's heap footprint now. Both consumers of the + // accumulator have completed: + // 1. ExportedTypeMap enrichment loop (enrichExportedTypeMap, above). + // 2. Phase 9: processCallsFromExtracted in runChunkedParseAndResolve, + // which uses the accumulator as a BindingAccumulator fallback for + // cross-file return types when the SymbolTable has no returnType. + // Phase 14 (runCrossFileBindingPropagation) and runGraphAnalysisPhases + // do not read the accumulator — keeping it alive through those long- + // running phases pins heap for no reason. bindingAccumulator.dispose(); // Happy-path dispose completed — clear the cleanup ref so the catch // handler doesn't attempt a second (harmless but noisy) dispose if a diff --git a/gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/a_consumer/main.cpp b/gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/a_consumer/main.cpp new file mode 100644 index 000000000..56d13b285 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/a_consumer/main.cpp @@ -0,0 +1,6 @@ +#include "../b_provider/provider.h" + +void process() { + User user = get_user(); + user.save(); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/b_provider/provider.cpp b/gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/b_provider/provider.cpp new file mode 100644 index 000000000..155d05003 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/b_provider/provider.cpp @@ -0,0 +1,7 @@ +#include "provider.h" + +void User::save() {} + +User get_user() { + return User(); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/b_provider/provider.h b/gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/b_provider/provider.h new file mode 100644 index 000000000..1d434b7f5 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/b_provider/provider.h @@ -0,0 +1,8 @@ +#pragma once + +class User { +public: + void save(); +}; + +User get_user(); diff --git a/gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/AConsumer/Program.cs b/gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/AConsumer/Program.cs new file mode 100644 index 000000000..c03a9605a --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/AConsumer/Program.cs @@ -0,0 +1,13 @@ +using static ConsumerBeforeProvider.BProvider.UserFactory; + +namespace ConsumerBeforeProvider.AConsumer +{ + public class Program + { + public void Run() + { + var u = GetUser(); + u.Save(); + } + } +} diff --git a/gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/BProvider/User.cs b/gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/BProvider/User.cs new file mode 100644 index 000000000..4ed71631e --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/BProvider/User.cs @@ -0,0 +1,7 @@ +namespace ConsumerBeforeProvider.BProvider +{ + public class User + { + public void Save() {} + } +} diff --git a/gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/BProvider/UserFactory.cs b/gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/BProvider/UserFactory.cs new file mode 100644 index 000000000..3c66c511a --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/BProvider/UserFactory.cs @@ -0,0 +1,7 @@ +namespace ConsumerBeforeProvider.BProvider +{ + public static class UserFactory + { + public static User GetUser() { return new User(); } + } +} diff --git a/gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/ConsumerBeforeProvider.csproj b/gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/ConsumerBeforeProvider.csproj new file mode 100644 index 000000000..75251fdad --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/ConsumerBeforeProvider.csproj @@ -0,0 +1,5 @@ + + + ConsumerBeforeProvider + + diff --git a/gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/app/main.go b/gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/app/main.go new file mode 100644 index 000000000..0951c398d --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/app/main.go @@ -0,0 +1,8 @@ +package main + +import "go-consumer-before-provider/models" + +func main() { + user := models.GetUser() + user.Save() +} diff --git a/gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/go.mod b/gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/go.mod new file mode 100644 index 000000000..00cc50274 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/go.mod @@ -0,0 +1,3 @@ +module go-consumer-before-provider + +go 1.21 diff --git a/gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/models/user.go b/gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/models/user.go new file mode 100644 index 000000000..e17ca9635 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/models/user.go @@ -0,0 +1,9 @@ +package models + +type User struct{} + +func (u User) Save() {} + +func GetUser() User { + return User{} +} diff --git a/gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/app/AConsumer.java b/gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/app/AConsumer.java new file mode 100644 index 000000000..37a8d8fd1 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/app/AConsumer.java @@ -0,0 +1,10 @@ +package app; + +import static models.BProvider.getUser; + +public class AConsumer { + public void run() { + var u = getUser(); + u.save(); + } +} diff --git a/gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/models/BProvider.java b/gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/models/BProvider.java new file mode 100644 index 000000000..5a8ac7d73 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/models/BProvider.java @@ -0,0 +1,7 @@ +package models; + +public class BProvider { + public static User getUser() { + return new User(); + } +} diff --git a/gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/models/User.java b/gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/models/User.java new file mode 100644 index 000000000..4bb729993 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/models/User.java @@ -0,0 +1,5 @@ +package models; + +public class User { + public void save() {} +} diff --git a/gitnexus/test/fixtures/cross-file-binding/js-consumer-before-provider/a-consumer.js b/gitnexus/test/fixtures/cross-file-binding/js-consumer-before-provider/a-consumer.js new file mode 100644 index 000000000..14b939ba1 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/js-consumer-before-provider/a-consumer.js @@ -0,0 +1,10 @@ +// File starts with 'a-' to sort alphabetically before 'b-provider.js'. +// In the sequential path, this file is processed first. Without the +// two-pass fix, the accumulator wouldn't have b-provider's bindings +// when this file's verifyConstructorBindings runs. +import { getUser } from './b-provider'; + +export function main() { + const u = getUser(); + u.save(); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/js-consumer-before-provider/b-provider.js b/gitnexus/test/fixtures/cross-file-binding/js-consumer-before-provider/b-provider.js new file mode 100644 index 000000000..99c85948a --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/js-consumer-before-provider/b-provider.js @@ -0,0 +1,7 @@ +export class User { + save() {} +} + +export function getUser() { + return new User(); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/kotlin-consumer-before-provider/app/AConsumer.kt b/gitnexus/test/fixtures/cross-file-binding/kotlin-consumer-before-provider/app/AConsumer.kt new file mode 100644 index 000000000..9f82882a8 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/kotlin-consumer-before-provider/app/AConsumer.kt @@ -0,0 +1,10 @@ +package app + +import models.getUser + +class AConsumer { + fun run() { + val u = getUser() + u.save() + } +} diff --git a/gitnexus/test/fixtures/cross-file-binding/kotlin-consumer-before-provider/models/BProvider.kt b/gitnexus/test/fixtures/cross-file-binding/kotlin-consumer-before-provider/models/BProvider.kt new file mode 100644 index 000000000..c343056fd --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/kotlin-consumer-before-provider/models/BProvider.kt @@ -0,0 +1,7 @@ +package models + +class User { + fun save() {} +} + +fun getUser(): User = User() diff --git a/gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/app/AConsumer.php b/gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/app/AConsumer.php new file mode 100644 index 000000000..dd44bf692 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/app/AConsumer.php @@ -0,0 +1,12 @@ +save(); + } +} diff --git a/gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/app/BProvider.php b/gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/app/BProvider.php new file mode 100644 index 000000000..9eadcb83c --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/app/BProvider.php @@ -0,0 +1,11 @@ + User: + return User() diff --git a/gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/a_consumer.rb b/gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/a_consumer.rb new file mode 100644 index 000000000..ed92cf60c --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/a_consumer.rb @@ -0,0 +1,6 @@ +require_relative 'models/b_user_factory' + +def process + user = UserFactory.get_user + user.save +end diff --git a/gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/models/b_user.rb b/gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/models/b_user.rb new file mode 100644 index 000000000..a139ede47 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/models/b_user.rb @@ -0,0 +1,4 @@ +class User + def save + end +end diff --git a/gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/models/b_user_factory.rb b/gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/models/b_user_factory.rb new file mode 100644 index 000000000..e7c80feff --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/models/b_user_factory.rb @@ -0,0 +1,7 @@ +require_relative 'b_user' + +class UserFactory + def self.get_user + User.new + end +end diff --git a/gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/a_consumer.rs b/gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/a_consumer.rs new file mode 100644 index 000000000..aa6cd1809 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/a_consumer.rs @@ -0,0 +1,6 @@ +use crate::b_provider::get_user; + +pub fn process() { + let u = get_user(); + u.save(); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/b_provider.rs b/gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/b_provider.rs new file mode 100644 index 000000000..7b20e072e --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/b_provider.rs @@ -0,0 +1,9 @@ +pub struct User; + +impl User { + pub fn save(&self) {} +} + +pub fn get_user() -> User { + User +} diff --git a/gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/main.rs b/gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/main.rs new file mode 100644 index 000000000..eac6f55f1 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/main.rs @@ -0,0 +1,2 @@ +mod a_consumer; +mod b_provider; diff --git a/gitnexus/test/fixtures/cross-file-binding/ts-consumer-before-provider/src/a-consumer.ts b/gitnexus/test/fixtures/cross-file-binding/ts-consumer-before-provider/src/a-consumer.ts new file mode 100644 index 000000000..d6d5f86c1 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/ts-consumer-before-provider/src/a-consumer.ts @@ -0,0 +1,10 @@ +// File starts with 'a-' to sort alphabetically before 'b-provider.ts'. +// In the sequential path, this file is processed first. Without the +// two-pass fix, the accumulator wouldn't have b-provider's bindings +// when this file's verifyConstructorBindings runs. +import { getUser } from './b-provider'; + +export function main() { + const x = getUser(); + x.save(); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/ts-consumer-before-provider/src/b-provider.ts b/gitnexus/test/fixtures/cross-file-binding/ts-consumer-before-provider/src/b-provider.ts new file mode 100644 index 000000000..58beea790 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/ts-consumer-before-provider/src/b-provider.ts @@ -0,0 +1,7 @@ +export class User { + save(): void {} +} + +export function getUser(): User { + return new User(); +} diff --git a/gitnexus/test/integration/cross-file-binding.test.ts b/gitnexus/test/integration/cross-file-binding.test.ts index edae951c0..33063f94f 100644 --- a/gitnexus/test/integration/cross-file-binding.test.ts +++ b/gitnexus/test/integration/cross-file-binding.test.ts @@ -210,3 +210,581 @@ describe('Cross-File Binding Propagation: TypeScript circular imports', () => { expect(paths.some((p) => p.includes('b.ts') && p.includes('a.ts'))).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// SM-15 / Phase 9: Cross-file call-result variable binding — multi-language +// +// Each suite below loads a multi-file fixture where: +// - File A defines a factory function getUser() / get_user() → User +// - File B imports that function, calls `u = getUser()`, then calls u.save() +// +// The acceptance criteria: u.save() / u.save() / u.get_name() must resolve +// to the correct User method via cross-file call-result variable binding. +// These tests cover both the SymbolTable path (languages with explicit return +// type annotations) and validate that the Phase 9 BindingAccumulator wiring +// does not break existing behavior. +// --------------------------------------------------------------------------- + +describe('Phase 9 — Cross-File Call-Result Binding: Java', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(CROSS_FILE_FIXTURES, 'java-cross-file'), () => {}); + }, 60000); + + it('detects User class with save and getName methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + expect(getNodesByLabel(result, 'Method')).toContain('getName'); + }); + + it('detects getUser factory and run method', () => { + expect(getNodesByLabel(result, 'Method')).toContain('getUser'); + expect(getNodesByLabel(result, 'Method')).toContain('run'); + }); + + it('resolves user.save() in run() to User#save via cross-file return type', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => c.target === 'save' && c.source === 'run' && c.targetFilePath.includes('User'), + ); + expect(saveCall).toBeDefined(); + }); + + it('resolves user.getName() in run() to User#getName via cross-file return type', () => { + const calls = getRelationships(result, 'CALLS'); + const getNameCall = calls.find( + (c) => c.target === 'getName' && c.source === 'run' && c.targetFilePath.includes('User'), + ); + expect(getNameCall).toBeDefined(); + }); +}); + +describe('Phase 9 — Cross-File Call-Result Binding: Python', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(CROSS_FILE_FIXTURES, 'py-cross-file'), () => {}); + }, 60000); + + it('detects User class with save and get_name methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + // Python tree-sitter captures all function_definitions as Function, including methods + expect(getNodesByLabel(result, 'Function')).toContain('save'); + expect(getNodesByLabel(result, 'Function')).toContain('get_name'); + }); + + it('detects get_user function and run function', () => { + expect(getNodesByLabel(result, 'Function')).toContain('get_user'); + expect(getNodesByLabel(result, 'Function')).toContain('run'); + }); + + it('resolves u.save() in run() to User#save via cross-file return type', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => c.target === 'save' && c.source === 'run' && c.targetFilePath.includes('models'), + ); + expect(saveCall).toBeDefined(); + }); + + it('resolves u.get_name() in run() to User#get_name via cross-file return type', () => { + const calls = getRelationships(result, 'CALLS'); + const getNameCall = calls.find( + (c) => c.target === 'get_name' && c.source === 'run' && c.targetFilePath.includes('models'), + ); + expect(getNameCall).toBeDefined(); + }); +}); + +describe('Phase 9 — Cross-File Call-Result Binding: Go', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(CROSS_FILE_FIXTURES, 'go-cross-file'), () => {}); + }, 60000); + + it('detects User struct with Save and GetName methods', () => { + expect(getNodesByLabel(result, 'Struct')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('Save'); + expect(getNodesByLabel(result, 'Method')).toContain('GetName'); + }); + + it('detects GetUser function and main function', () => { + expect(getNodesByLabel(result, 'Function')).toContain('GetUser'); + expect(getNodesByLabel(result, 'Function')).toContain('main'); + }); + + it('resolves user.Save() in main() to User#Save via cross-file return type', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => c.target === 'Save' && c.source === 'main' && c.targetFilePath.includes('models'), + ); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Phase 9 — Cross-File Call-Result Binding: Kotlin', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'kotlin-cross-file'), + () => {}, + ); + }, 60000); + + it('detects User class with save and getName methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + expect(getNodesByLabel(result, 'Method')).toContain('getName'); + }); + + it('detects getUser function and run method', () => { + expect(getNodesByLabel(result, 'Function')).toContain('getUser'); + expect(getNodesByLabel(result, 'Method')).toContain('run'); + }); + + it('resolves u.save() in run() to User#save via cross-file return type', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => c.target === 'save' && c.source === 'run' && c.targetFilePath.includes('User'), + ); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Phase 9 — Cross-File Call-Result Binding: Rust', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(CROSS_FILE_FIXTURES, 'rs-cross-file'), () => {}); + }, 60000); + + it('detects User struct with save and get_name methods', () => { + expect(getNodesByLabel(result, 'Struct')).toContain('User'); + // Rust tree-sitter captures impl fns as Function nodes + expect(getNodesByLabel(result, 'Function')).toContain('save'); + expect(getNodesByLabel(result, 'Function')).toContain('get_name'); + }); + + it('detects get_user function and process function', () => { + expect(getNodesByLabel(result, 'Function')).toContain('get_user'); + expect(getNodesByLabel(result, 'Function')).toContain('process'); + }); + + it('resolves u.save() in process() to User#save via cross-file return type', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => c.target === 'save' && c.source === 'process' && c.targetFilePath.includes('models'), + ); + expect(saveCall).toBeDefined(); + }); +}); + +// ── R5: Missing language coverage (PR #763 review finding #5) ──────────── + +describe('Phase 9 — Cross-File Call-Result Binding: JavaScript', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(CROSS_FILE_FIXTURES, 'js-cross-file'), () => {}); + }, 60000); + + it('detects User class with save and getName methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + expect(getNodesByLabel(result, 'Method')).toContain('getName'); + }); + + it('detects getUser factory and run function', () => { + expect(getNodesByLabel(result, 'Function')).toContain('getUser'); + expect(getNodesByLabel(result, 'Function')).toContain('run'); + }); + + it('resolves u.save() in run() to User#save via cross-file return type', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => c.target === 'save' && c.source === 'run' && c.targetFilePath.includes('models'), + ); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Phase 9 — Cross-File Call-Result Binding: C++', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(CROSS_FILE_FIXTURES, 'cpp-cross-file'), () => {}); + }, 60000); + + it('detects User class with save and get_name methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + expect(getNodesByLabel(result, 'Method')).toContain('get_name'); + }); + + it('detects get_user factory function', () => { + expect(getNodesByLabel(result, 'Function')).toContain('get_user'); + }); + + it('resolves user.save() in process() to User#save via cross-file return type', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => c.target === 'save' && c.source === 'process' && c.targetFilePath.includes('user'), + ); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Phase 9 — Cross-File Call-Result Binding: C#', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'csharp-cross-file'), + () => {}, + ); + }, 60000); + + it('detects User class with Save and GetName methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('Save'); + expect(getNodesByLabel(result, 'Method')).toContain('GetName'); + }); + + it('detects GetUser factory and Run method', () => { + expect(getNodesByLabel(result, 'Method')).toContain('GetUser'); + expect(getNodesByLabel(result, 'Method')).toContain('Run'); + }); + + it('resolves u.Save() in Run() to User#Save via cross-file return type', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => c.target === 'Save' && c.source === 'Run' && c.targetFilePath.includes('User'), + ); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Phase 9 — Cross-File Call-Result Binding: PHP', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(CROSS_FILE_FIXTURES, 'php-cross-file'), () => {}); + }, 60000); + + it('detects User class with save and getName methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + expect(getNodesByLabel(result, 'Method')).toContain('getName'); + }); + + it('detects getUser factory function', () => { + expect(getNodesByLabel(result, 'Function')).toContain('getUser'); + }); + + it('resolves $u->save() in run() to User#save via cross-file return type', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => c.target === 'save' && c.source === 'run' && c.targetFilePath.includes('User'), + ); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Phase 9 — Cross-File Call-Result Binding: Ruby', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(CROSS_FILE_FIXTURES, 'rb-cross-file'), () => {}); + }, 60000); + + it('detects User class with save and get_name methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + expect(getNodesByLabel(result, 'Method')).toContain('get_name'); + }); + + it('detects get_user factory method', () => { + expect(getNodesByLabel(result, 'Method')).toContain('get_user'); + }); + + it('resolves user.save in process() to User#save via cross-file return type', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => c.target === 'save' && c.source === 'process' && c.targetFilePath.includes('models'), + ); + expect(saveCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Note: shadowed import tier gating is tested at the unit level +// (call-processor.test.ts "Phase 9 tier gating" tests) because the scenario +// requires invalid TypeScript (same name imported and locally defined). +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Regression: consumer file processed before provider in sequential path +// a-consumer.ts (alphabetically first) imports getUser from b-provider.ts. +// Without the two-pass flush fix, the accumulator wouldn't have b-provider's +// bindings when a-consumer's verifyConstructorBindings runs. +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Consumer-before-provider regression tests (sequential ordering fix) +// +// Each language fixture has a consumer file that sorts alphabetically before +// the provider file. In the sequential path, the consumer is processed first. +// The two-pass flush ensures the accumulator has provider bindings before +// verifyConstructorBindings runs for the consumer. +// --------------------------------------------------------------------------- + +describe('Consumer-Before-Provider: TypeScript', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'ts-consumer-before-provider'), + () => {}, + ); + }, 60000); + + it('detects User class and save method from provider', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + }); + + it('resolves x.save() to User#save despite consumer sorted before provider', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => c.target === 'save' && c.source === 'main' && c.targetFilePath.includes('b-provider'), + ); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Consumer-Before-Provider: JavaScript', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'js-consumer-before-provider'), + () => {}, + ); + }, 60000); + + it('detects User class and save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + }); + + it('resolves u.save() in main() to User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => c.target === 'save' && c.source === 'main' && c.targetFilePath.includes('b-provider'), + ); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Consumer-Before-Provider: Python', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'py-consumer-before-provider'), + () => {}, + ); + }, 60000); + + it('detects User class and save function', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + // Python tree-sitter captures all function_definitions as Function, including methods + expect(getNodesByLabel(result, 'Function')).toContain('save'); + }); + + it('resolves u.save() in main() to User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => c.target === 'save' && c.source === 'main' && c.targetFilePath.includes('b_provider'), + ); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Consumer-Before-Provider: Java', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'java-consumer-before-provider'), + () => {}, + ); + }, 60000); + + it('detects User class and save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + }); + + it('resolves user.save() in run() to User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find((c) => c.target === 'save' && c.source === 'run'); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Consumer-Before-Provider: Go', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'go-consumer-before-provider'), + () => {}, + ); + }, 60000); + + it('detects User struct and Save method', () => { + expect(getNodesByLabel(result, 'Struct')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('Save'); + }); + + it('resolves user.Save() in main() to User#Save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find((c) => c.target === 'Save' && c.source === 'main'); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Consumer-Before-Provider: C++', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'cpp-consumer-before-provider'), + () => {}, + ); + }, 60000); + + it('detects User class and save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + }); + + it('resolves user.save() in process() to User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find((c) => c.target === 'save' && c.source === 'process'); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Consumer-Before-Provider: C#', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'csharp-consumer-before-provider'), + () => {}, + ); + }, 60000); + + it('detects User class and Save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('Save'); + }); + + it('resolves u.Save() in Run() to User#Save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find((c) => c.target === 'Save' && c.source === 'Run'); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Consumer-Before-Provider: Kotlin', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'kotlin-consumer-before-provider'), + () => {}, + ); + }, 60000); + + it('detects User class and save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + }); + + it('resolves u.save() in run() to User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find((c) => c.target === 'save' && c.source === 'run'); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Consumer-Before-Provider: PHP', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'php-consumer-before-provider'), + () => {}, + ); + }, 60000); + + it('detects User class and save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + }); + + it('resolves $u->save() in run() to User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find((c) => c.target === 'save' && c.source === 'run'); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Consumer-Before-Provider: Ruby', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'rb-consumer-before-provider'), + () => {}, + ); + }, 60000); + + it('detects User class and save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + }); + + it('resolves user.save in process() to User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find((c) => c.target === 'save' && c.source === 'process'); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Consumer-Before-Provider: Rust', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'rs-consumer-before-provider'), + () => {}, + ); + }, 60000); + + it('detects User struct and save function', () => { + expect(getNodesByLabel(result, 'Struct')).toContain('User'); + // Rust tree-sitter captures impl fns as Function nodes + expect(getNodesByLabel(result, 'Function')).toContain('save'); + }); + + it('resolves u.save() in process() to User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find((c) => c.target === 'save' && c.source === 'process'); + expect(saveCall).toBeDefined(); + }); +}); diff --git a/gitnexus/test/unit/binding-accumulator.test.ts b/gitnexus/test/unit/binding-accumulator.test.ts index be394d987..95b1ddef8 100644 --- a/gitnexus/test/unit/binding-accumulator.test.ts +++ b/gitnexus/test/unit/binding-accumulator.test.ts @@ -511,6 +511,54 @@ describe('BindingAccumulator', () => { // state. Idempotent and orthogonal to finalize(). // ------------------------------------------------------------------------- + describe('fileScopeGet (O(1) point lookup)', () => { + it('returns the typeName for a known file-scope binding', () => { + const acc = new BindingAccumulator(); + acc.appendFile('src/api.ts', [ + { scope: '', varName: 'getUser', typeName: 'User' }, + { scope: '', varName: 'getPost', typeName: 'Post' }, + ]); + expect(acc.fileScopeGet('src/api.ts', 'getUser')).toBe('User'); + expect(acc.fileScopeGet('src/api.ts', 'getPost')).toBe('Post'); + }); + + it('returns undefined for an unknown file', () => { + const acc = new BindingAccumulator(); + expect(acc.fileScopeGet('nonexistent.ts', 'x')).toBeUndefined(); + }); + + it('returns undefined for an unknown name in a known file', () => { + const acc = new BindingAccumulator(); + acc.appendFile('src/api.ts', [{ scope: '', varName: 'getUser', typeName: 'User' }]); + expect(acc.fileScopeGet('src/api.ts', 'missing')).toBeUndefined(); + }); + + it('ignores function-scope entries', () => { + const acc = new BindingAccumulator(); + acc.appendFile('src/service.ts', [ + { scope: 'handler@10', varName: 'localDb', typeName: 'Database' }, + { scope: '', varName: 'config', typeName: 'Config' }, + ]); + // Only file-scope entries are indexed by fileScopeGet. + expect(acc.fileScopeGet('src/service.ts', 'config')).toBe('Config'); + expect(acc.fileScopeGet('src/service.ts', 'localDb')).toBeUndefined(); + }); + + it('returns undefined after dispose', () => { + const acc = new BindingAccumulator(); + acc.appendFile('src/api.ts', [{ scope: '', varName: 'getUser', typeName: 'User' }]); + acc.dispose(); + expect(acc.fileScopeGet('src/api.ts', 'getUser')).toBeUndefined(); + }); + + it('last-write-wins for duplicate varNames in the same file', () => { + const acc = new BindingAccumulator(); + acc.appendFile('src/api.ts', [{ scope: '', varName: 'getUser', typeName: 'OldType' }]); + acc.appendFile('src/api.ts', [{ scope: '', varName: 'getUser', typeName: 'NewType' }]); + expect(acc.fileScopeGet('src/api.ts', 'getUser')).toBe('NewType'); + }); + }); + describe('dispose', () => { it('empties all read methods after dispose', () => { const acc = new BindingAccumulator(); diff --git a/gitnexus/test/unit/call-processor.test.ts b/gitnexus/test/unit/call-processor.test.ts index b0c2b04eb..a0754082d 100644 --- a/gitnexus/test/unit/call-processor.test.ts +++ b/gitnexus/test/unit/call-processor.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { processCalls, processCallsFromExtracted, + processAssignmentsFromExtracted, seedCrossFileReceiverTypes, extractConsumerAccessedKeys, processNextjsFetchRoutes, @@ -14,7 +15,9 @@ import { type ResolutionContext, } from '../../src/core/ingestion/resolution-context.js'; import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; +import { BindingAccumulator } from '../../src/core/ingestion/binding-accumulator.js'; import type { + ExtractedAssignment, ExtractedCall, ExtractedFetchCall, ExtractedHeritage, @@ -574,6 +577,543 @@ describe('processCallsFromExtracted', () => { expect(rels[0].targetId).toBe('Method:src/models.ts:save'); }); + // ---- Phase 9: BindingAccumulator fallback for cross-file return types ---- + + it('Phase 9: BindingAccumulator fallback — binds variable to return type when SymbolTable has no returnType', async () => { + // getUser is in the SymbolTable but WITHOUT a returnType (e.g., inferred return type + // that the structure processor did not capture). The BindingAccumulator for + // src/api.ts has getUser → User as a file-scope binding. + ctx.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function', { + // No returnType provided — simulates a structure-processor gap + }); + ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ownerId: 'Class:src/models.ts:User', + }); + ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts', 'src/models.ts'])); + // namedImportMap: consumer.ts imports { getUser } from src/api.ts + ctx.namedImportMap.set( + 'src/consumer.ts', + new Map([['getUser', { sourcePath: 'src/api.ts', exportedName: 'getUser' }]]), + ); + + // BindingAccumulator carries the TypeEnv-resolved binding from src/api.ts + const acc = new BindingAccumulator(); + acc.appendFile('src/api.ts', [{ scope: '', varName: 'getUser', typeName: 'User' }]); + + const constructorBindings: FileConstructorBindings[] = [ + { + filePath: 'src/consumer.ts', + bindings: [{ scope: 'main@0', varName: 'x', calleeName: 'getUser' }], + }, + ]; + + const calls: ExtractedCall[] = [ + { + filePath: 'src/consumer.ts', + calledName: 'save', + sourceId: 'Function:src/consumer.ts:main', + receiverName: 'x', + callForm: 'member', + }, + ]; + + await processCallsFromExtracted( + graph, + calls, + ctx, + undefined, + constructorBindings, + undefined, + acc, + ); + + const rels = graph.relationships.filter((r) => r.type === 'CALLS'); + expect(rels).toHaveLength(1); + expect(rels[0].targetId).toBe('Method:src/models.ts:save'); + }); + + it('Phase 9: BindingAccumulator fallback — SymbolTable return type takes precedence', async () => { + // When the SymbolTable DOES have a returnType, the accumulator should not override it. + ctx.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function', { + returnType: 'User', + }); + ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ownerId: 'Class:src/models.ts:User', + }); + ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts', 'src/models.ts'])); + ctx.namedImportMap.set( + 'src/consumer.ts', + new Map([['getUser', { sourcePath: 'src/api.ts', exportedName: 'getUser' }]]), + ); + + // Accumulator has a conflicting (wrong) type — should be ignored + const acc = new BindingAccumulator(); + acc.appendFile('src/api.ts', [{ scope: '', varName: 'getUser', typeName: 'WrongType' }]); + + const constructorBindings: FileConstructorBindings[] = [ + { + filePath: 'src/consumer.ts', + bindings: [{ scope: 'main@0', varName: 'x', calleeName: 'getUser' }], + }, + ]; + + const calls: ExtractedCall[] = [ + { + filePath: 'src/consumer.ts', + calledName: 'save', + sourceId: 'Function:src/consumer.ts:main', + receiverName: 'x', + callForm: 'member', + }, + ]; + + await processCallsFromExtracted( + graph, + calls, + ctx, + undefined, + constructorBindings, + undefined, + acc, + ); + + // Should resolve via SymbolTable (User#save), not the wrong accumulator type + const rels = graph.relationships.filter((r) => r.type === 'CALLS'); + expect(rels).toHaveLength(1); + expect(rels[0].targetId).toBe('Method:src/models.ts:save'); + }); + + it('Phase 9: BindingAccumulator fallback — skips when callee not in namedImportMap', async () => { + // Callee is not tracked in namedImportMap (e.g. a local function), so accumulator + // lookup is skipped. No CALLS edge expected since there is no binding source. + ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ownerId: 'Class:src/models.ts:User', + }); + // No namedImportMap entry for getUser + + const acc = new BindingAccumulator(); + acc.appendFile('src/api.ts', [{ scope: '', varName: 'getUser', typeName: 'User' }]); + + const constructorBindings: FileConstructorBindings[] = [ + { + filePath: 'src/consumer.ts', + bindings: [{ scope: 'main@0', varName: 'x', calleeName: 'getUser' }], + }, + ]; + + // Use a method name that is owned by User (requires receiver type resolution) + // but also exists on multiple types so fuzzy lookup is ambiguous without a + // receiver type. Add a second owner so that unconstrained fuzzy lookup won't + // match unambiguously. + ctx.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class'); + ctx.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', { + ownerId: 'Class:src/other.ts:OtherClass', + }); + + const calls: ExtractedCall[] = [ + { + filePath: 'src/consumer.ts', + calledName: 'save', + sourceId: 'Function:src/consumer.ts:main', + receiverName: 'x', + callForm: 'member', + }, + ]; + + await processCallsFromExtracted( + graph, + calls, + ctx, + undefined, + constructorBindings, + undefined, + acc, + ); + + // Without accumulator fallback (no namedImportMap entry), x is untyped. + // Two methods named 'save' from unrelated types — fuzzy lookup is ambiguous → no edge. + const rels = graph.relationships.filter((r) => r.type === 'CALLS'); + expect(rels).toHaveLength(0); + }); + + it('Phase 9: BindingAccumulator fallback — unwraps Promise type from accumulator', async () => { + // Accumulator stores raw type with Promise wrapper — extractReturnTypeName should unwrap it. + ctx.symbols.add('src/api.ts', 'fetchUser', 'Function:src/api.ts:fetchUser', 'Function'); + ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ownerId: 'Class:src/models.ts:User', + }); + ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts', 'src/models.ts'])); + ctx.namedImportMap.set( + 'src/consumer.ts', + new Map([['fetchUser', { sourcePath: 'src/api.ts', exportedName: 'fetchUser' }]]), + ); + + const acc = new BindingAccumulator(); + // Accumulator stores raw Promise as type — should be unwrapped + acc.appendFile('src/api.ts', [{ scope: '', varName: 'fetchUser', typeName: 'Promise' }]); + + const constructorBindings: FileConstructorBindings[] = [ + { + filePath: 'src/consumer.ts', + bindings: [{ scope: 'main@0', varName: 'x', calleeName: 'fetchUser' }], + }, + ]; + + const calls: ExtractedCall[] = [ + { + filePath: 'src/consumer.ts', + calledName: 'save', + sourceId: 'Function:src/consumer.ts:main', + receiverName: 'x', + callForm: 'member', + }, + ]; + + await processCallsFromExtracted( + graph, + calls, + ctx, + undefined, + constructorBindings, + undefined, + acc, + ); + + const rels = graph.relationships.filter((r) => r.type === 'CALLS'); + expect(rels).toHaveLength(1); + expect(rels[0].targetId).toBe('Method:src/models.ts:save'); + }); + + it('Phase 9: BindingAccumulator fallback — skips primitive types from accumulator', async () => { + // Accumulator stores a primitive type — should not create a CALLS edge. + ctx.symbols.add('src/api.ts', 'getCount', 'Function:src/api.ts:getCount', 'Function'); + ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts'])); + ctx.namedImportMap.set( + 'src/consumer.ts', + new Map([['getCount', { sourcePath: 'src/api.ts', exportedName: 'getCount' }]]), + ); + + const acc = new BindingAccumulator(); + acc.appendFile('src/api.ts', [{ scope: '', varName: 'getCount', typeName: 'number' }]); + + const constructorBindings: FileConstructorBindings[] = [ + { + filePath: 'src/consumer.ts', + bindings: [{ scope: 'main@0', varName: 'count', calleeName: 'getCount' }], + }, + ]; + + const calls: ExtractedCall[] = [ + { + filePath: 'src/consumer.ts', + calledName: 'toString', + sourceId: 'Function:src/consumer.ts:main', + receiverName: 'count', + callForm: 'member', + }, + ]; + + await processCallsFromExtracted( + graph, + calls, + ctx, + undefined, + constructorBindings, + undefined, + acc, + ); + + // Primitive type — no CALLS edge + const rels = graph.relationships.filter((r) => r.type === 'CALLS'); + expect(rels).toHaveLength(0); + }); + + it('Phase 9: BindingAccumulator fallback — handles aliased import (localName ≠ exportedName)', async () => { + // import { getUser as fetchUser } from './api' — namedImportMap maps localName to exportedName + ctx.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function'); + ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ownerId: 'Class:src/models.ts:User', + }); + ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts', 'src/models.ts'])); + // Local alias: fetchUser → api.ts:getUser + ctx.namedImportMap.set( + 'src/consumer.ts', + new Map([['fetchUser', { sourcePath: 'src/api.ts', exportedName: 'getUser' }]]), + ); + + const acc = new BindingAccumulator(); + acc.appendFile('src/api.ts', [{ scope: '', varName: 'getUser', typeName: 'User' }]); + + const constructorBindings: FileConstructorBindings[] = [ + { + filePath: 'src/consumer.ts', + // calleeName is the LOCAL alias used at the call site + bindings: [{ scope: 'main@0', varName: 'x', calleeName: 'fetchUser' }], + }, + ]; + + const calls: ExtractedCall[] = [ + { + filePath: 'src/consumer.ts', + calledName: 'save', + sourceId: 'Function:src/consumer.ts:main', + receiverName: 'x', + callForm: 'member', + }, + ]; + + await processCallsFromExtracted( + graph, + calls, + ctx, + undefined, + constructorBindings, + undefined, + acc, + ); + + const rels = graph.relationships.filter((r) => r.type === 'CALLS'); + expect(rels).toHaveLength(1); + expect(rels[0].targetId).toBe('Method:src/models.ts:save'); + }); + + // ---- Phase 9: Tier gating — accumulator fallback respects resolution tiers ---- + + it('Phase 9 tier gating: same-file callable shadows imported callee — fallback skipped', async () => { + // consumer.ts defines a local getUser() AND imports getUser from api.ts. + // The local definition has no returnType annotation. The accumulator has + // getUser → User from api.ts. The fallback must NOT fire because the + // same-file definition is authoritative (tier: 'same-file'). + ctx.symbols.add('src/consumer.ts', 'getUser', 'Function:src/consumer.ts:getUser', 'Function'); + ctx.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function'); + // Place User and save in non-imported files so import-scoped member-call resolution + // can't resolve save without a receiver type. + ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ownerId: 'Class:src/models.ts:User', + }); + ctx.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class'); + ctx.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', { + ownerId: 'Class:src/other.ts:OtherClass', + }); + // Only import api.ts — NOT models.ts, so save can't be found via import scope. + ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts'])); + ctx.namedImportMap.set( + 'src/consumer.ts', + new Map([['getUser', { sourcePath: 'src/api.ts', exportedName: 'getUser' }]]), + ); + + const acc = new BindingAccumulator(); + acc.appendFile('src/api.ts', [{ scope: '', varName: 'getUser', typeName: 'User' }]); + + const constructorBindings: FileConstructorBindings[] = [ + { + filePath: 'src/consumer.ts', + bindings: [{ scope: 'main@0', varName: 'x', calleeName: 'getUser' }], + }, + ]; + + const calls: ExtractedCall[] = [ + { + filePath: 'src/consumer.ts', + calledName: 'save', + sourceId: 'Function:src/consumer.ts:main', + receiverName: 'x', + callForm: 'member', + }, + ]; + + await processCallsFromExtracted( + graph, + calls, + ctx, + undefined, + constructorBindings, + undefined, + acc, + ); + + // Fallback must NOT fire — local getUser shadows imported getUser (tier: same-file). + // Without a receiver type, member-call 'save' is ambiguous globally → no edge. + const rels = graph.relationships.filter((r) => r.type === 'CALLS'); + expect(rels).toHaveLength(0); + }); + + it('Phase 9 tier gating: multiple callable candidates — fallback skipped', async () => { + // Two functions named getUser in different imported files — resolution is ambiguous + // (multiple candidates at 'import-scoped' tier). The accumulator carries a WRONG type + // (BadType). If the fallback fires, x gets typed as BadType and x.save() looks for + // BadType.save — which doesn't exist → 0 edges. If the fallback is correctly blocked, + // x has no receiver type at all, and save is ambiguous (two owners) → 0 edges. + // Either way, no CALLS edge. But we verify the accumulator's wrong type did NOT leak + // by checking that no ACCESSES edge to BadType is created. + ctx.symbols.add('src/api-v1.ts', 'getUser', 'Function:src/api-v1.ts:getUser', 'Function'); + ctx.symbols.add('src/api-v2.ts', 'getUser', 'Function:src/api-v2.ts:getUser', 'Function'); + ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ownerId: 'Class:src/models.ts:User', + }); + ctx.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class'); + ctx.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', { + ownerId: 'Class:src/other.ts:OtherClass', + }); + // BadType has no methods — if the accumulator wrongly types x as BadType, + // the receiver type is set but save won't resolve at all. + ctx.symbols.add('src/bad.ts', 'BadType', 'Class:src/bad.ts:BadType', 'Class'); + ctx.importMap.set( + 'src/consumer.ts', + new Set(['src/api-v1.ts', 'src/api-v2.ts', 'src/models.ts']), + ); + ctx.namedImportMap.set( + 'src/consumer.ts', + new Map([['getUser', { sourcePath: 'src/api-v1.ts', exportedName: 'getUser' }]]), + ); + + // Accumulator carries WRONG type — proves gating blocks the fallback + const acc = new BindingAccumulator(); + acc.appendFile('src/api-v1.ts', [{ scope: '', varName: 'getUser', typeName: 'BadType' }]); + + const constructorBindings: FileConstructorBindings[] = [ + { + filePath: 'src/consumer.ts', + bindings: [{ scope: 'main@0', varName: 'x', calleeName: 'getUser' }], + }, + ]; + + const calls: ExtractedCall[] = [ + { + filePath: 'src/consumer.ts', + calledName: 'save', + sourceId: 'Function:src/consumer.ts:main', + receiverName: 'x', + callForm: 'member', + }, + ]; + + await processCallsFromExtracted( + graph, + calls, + ctx, + undefined, + constructorBindings, + undefined, + acc, + ); + + // If gating works: x has no receiver type, save may or may not resolve via + // import scope (separate mechanism). Key assertion: BadType never appears + // as an ACCESSES target — proving the accumulator's wrong type did not leak. + const accesses = graph.relationships.filter( + (r) => r.type === 'ACCESSES' && r.targetId === 'Class:src/bad.ts:BadType', + ); + expect(accesses).toHaveLength(0); + }); + + it('Phase 9 tier gating: no callable candidates but named import — fallback fires', async () => { + // getUser is not in the SymbolTable at all (e.g. definition not parsed). + // namedImportMap has the import, accumulator has the type. Fallback should fire. + ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ownerId: 'Class:src/models.ts:User', + }); + ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts', 'src/models.ts'])); + ctx.namedImportMap.set( + 'src/consumer.ts', + new Map([['getUser', { sourcePath: 'src/api.ts', exportedName: 'getUser' }]]), + ); + + const acc = new BindingAccumulator(); + acc.appendFile('src/api.ts', [{ scope: '', varName: 'getUser', typeName: 'User' }]); + + const constructorBindings: FileConstructorBindings[] = [ + { + filePath: 'src/consumer.ts', + bindings: [{ scope: 'main@0', varName: 'x', calleeName: 'getUser' }], + }, + ]; + + const calls: ExtractedCall[] = [ + { + filePath: 'src/consumer.ts', + calledName: 'save', + sourceId: 'Function:src/consumer.ts:main', + receiverName: 'x', + callForm: 'member', + }, + ]; + + await processCallsFromExtracted( + graph, + calls, + ctx, + undefined, + constructorBindings, + undefined, + acc, + ); + + // No SymbolTable entry at all → tiered is null, fallback fires via accumulator. + const rels = graph.relationships.filter((r) => r.type === 'CALLS'); + expect(rels).toHaveLength(1); + expect(rels[0].targetId).toBe('Method:src/models.ts:save'); + }); + + it('Phase 9 tier gating: single same-file callable without returnType — fallback skipped', async () => { + // consumer.ts has a local getUser() without returnType annotation. + // No import of getUser exists. The accumulator has getUser → User from api.ts. + // Tier is 'same-file' so fallback must NOT fire. + ctx.symbols.add('src/consumer.ts', 'getUser', 'Function:src/consumer.ts:getUser', 'Function'); + ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ownerId: 'Class:src/models.ts:User', + }); + // Add a second 'save' so fuzzy lookup is ambiguous without receiver type + ctx.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class'); + ctx.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', { + ownerId: 'Class:src/other.ts:OtherClass', + }); + + const acc = new BindingAccumulator(); + acc.appendFile('src/api.ts', [{ scope: '', varName: 'getUser', typeName: 'User' }]); + + const constructorBindings: FileConstructorBindings[] = [ + { + filePath: 'src/consumer.ts', + bindings: [{ scope: 'main@0', varName: 'x', calleeName: 'getUser' }], + }, + ]; + + const calls: ExtractedCall[] = [ + { + filePath: 'src/consumer.ts', + calledName: 'save', + sourceId: 'Function:src/consumer.ts:main', + receiverName: 'x', + callForm: 'member', + }, + ]; + + await processCallsFromExtracted( + graph, + calls, + ctx, + undefined, + constructorBindings, + undefined, + acc, + ); + + // Same-file callable — local is authoritative even without annotation. + // Fuzzy 'save' lookup is ambiguous → no edge. + const rels = graph.relationships.filter((r) => r.type === 'CALLS'); + expect(rels).toHaveLength(0); + }); + // ---- Scope-aware constructor bindings (Phase 3) ---- it('receiverKey collision: same method name in different classes does not collide', async () => { @@ -1954,3 +2494,59 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { expect(userSave).toBeUndefined(); }); }); + +// ---- processAssignmentsFromExtracted: Phase 9 accumulator fallback ---- + +describe('processAssignmentsFromExtracted', () => { + let graph: ReturnType; + let ctx: ResolutionContext; + + beforeEach(() => { + graph = createKnowledgeGraph(); + ctx = createResolutionContext(); + }); + + it('Phase 9: accumulator fallback resolves receiver type for ACCESSES write edge', () => { + // getUser is in the SymbolTable WITHOUT a returnType. The accumulator + // carries getUser → User from the source file. The constructor binding + // binds x = getUser(). The assignment x.address = value should produce + // an ACCESSES write edge to User.address via the accumulator fallback. + ctx.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function'); + ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.symbols.add('src/models.ts', 'address', 'Property:src/models.ts:address', 'Property', { + ownerId: 'Class:src/models.ts:User', + }); + ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts', 'src/models.ts'])); + ctx.namedImportMap.set( + 'src/consumer.ts', + new Map([['getUser', { sourcePath: 'src/api.ts', exportedName: 'getUser' }]]), + ); + + const acc = new BindingAccumulator(); + acc.appendFile('src/api.ts', [{ scope: '', varName: 'getUser', typeName: 'User' }]); + + const constructorBindings: FileConstructorBindings[] = [ + { + filePath: 'src/consumer.ts', + bindings: [{ scope: 'main@0', varName: 'x', calleeName: 'getUser' }], + }, + ]; + + const assignments: ExtractedAssignment[] = [ + { + filePath: 'src/consumer.ts', + sourceId: 'Function:src/consumer.ts:main', + receiverText: 'x', + propertyName: 'address', + }, + ]; + + processAssignmentsFromExtracted(graph, assignments, ctx, constructorBindings, acc); + + const accesses = graph.relationships.filter( + (r) => r.type === 'ACCESSES' && r.reason === 'write', + ); + expect(accesses).toHaveLength(1); + expect(accesses[0].targetId).toBe('Property:src/models.ts:address'); + }); +}); From e5dafce9f217bb52c7c2639d4546473517a6a0ce Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 13:00:33 +0100 Subject: [PATCH 07/67] =?UTF-8?q?feat(SM-16):=20Restructure=20`resolveUnca?= =?UTF-8?q?ched`=20=E2=80=94=20replace=20`lookupFuzzy`=20data=20source=20f?= =?UTF-8?q?or=20all=20tiers=20(#764)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * chore: initial plan for SM-16 resolveUncached refactor Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/0f505332-25be-46a7-b78e-fde58c1fc6fd Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * feat(SM-16): restructure resolveUncached — replace lookupFuzzy with targeted index lookups - Remove single lookupFuzzy call that fed all Tier 2a/2b/3 in resolveUncached - Tier 2a: iterate importedFiles with lookupExactAll per file (O(imports) × O(1)) - Tier 2b: iterate symbols.getFiles() filtered by isFileInPackageDir + lookupExactAll (O(files) × O(1), avoids global name scan) - Tier 3: replace with lookupClassByName + lookupImplByName + lookupFuzzyCallable (three O(1) index lookups covering class-like, Rust impl blocks, and callables) - Add getFiles() to SymbolTable interface (exposes fileIndex.keys() for Tier 2b) - Add lookupImplByName() to SymbolTable — dedicated Rust Impl index kept separate from classByName to preserve correct heritage-map resolution - Remove allDefs parameter from walkBindingChain; always use lookupExactAll directly - Add 29 new unit tests covering SM-16 changes and per-language fixtures - fuzzyCallCount in getStats() is now 0 for all resolve() calls (acceptance criterion) Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/0f505332-25be-46a7-b78e-fde58c1fc6fd Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix(SM-16): clean up — readable Tier 3 if-else, correct doc comment, remove unused import Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/0f505332-25be-46a7-b78e-fde58c1fc6fd Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix(SM-16): address all PR #764 review findings 1. Eager callableIndex — maintained on add() like classByName/implByName, removing the O(globalIndex) lazy rebuild on the Tier 3 hot path. 2. Tier 2b inverted index — packageDirSuffix→Set built lazily on first Tier 2b hit. Changes O(allFiles×packages) per resolution to O(packages×filesInPackage). 3. Tier 3 type exclusion documented — TypeAlias, Const, Variable are intentionally not reachable at Tier 3. 4 negative/positive tests added. 4. Tier 3 allocation guard simplified — single spread replaces 4-way if-else. 5. getFiles() live iterator documented with safety contract. 6. Remaining lookupFuzzy callers in call-processor.ts documented in the Tier 3 comment block. 7. Tier 2b language fixtures — added Rust, Kotlin, PHP tests (3 new). Also merges origin/main (SM-15 accumulator fixes). * fix(SM-16): address Codex adversarial review — Tier 2b cache lifecycle + Macro/Delegate at Tier 3 1. Tier 2b packageDirIndex now invalidated in clearCache() and clear(), preventing stale snapshots when symbols/packages are added between chunk processing phases. 2. Macro (C/C++) and Delegate (C#) added to CALLABLE_TYPES in the eager callableIndex, restoring Tier 3 reachability for these call targets that the old lookupFuzzy returned. * fix(SM-16): address ce:review findings — Tier 2b cache lifecycle + Tier 3 perf + test gaps 1. packageDirIndex no longer invalidated in clearCache() — the index persists across file boundaries since packageMap and symbols are append-only during the calls phase. Only clear() (pipeline reset) invalidates. Prevents O(files×dirs) rebuild per-file. 2. Tier 3 short-circuit: return null before spread when all three indexes are empty, avoiding allocation on the common miss path. 3. Add Macro (C/C++) and Delegate (C#) Tier 3 regression tests — the only newly-added CALLABLE_TYPES were completely untested. 4. Add packageDirIndex invalidation regression test — verifies clear() resets the index and newly-added symbols are visible. * fix(SM-16): address final review — deduplicate NamedImportMap + doc fixes 1. NamedImportMap: removed duplicate definition from resolution-context.ts, now imported directly from import-processor.ts (no re-export needed — no consumers imported it from resolution-context). 2. packageDirIndex build cost documented accurately in comment. 3. fuzzyCallCount scope documented in test comment. 4. Tier 2a test suite: added comment about Go/Kotlin/PHP coverage. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> Co-authored-by: Gergo Magyar --- .../core/ingestion/named-binding-processor.ts | 10 +- .../src/core/ingestion/resolution-context.ts | 131 +++- gitnexus/src/core/ingestion/symbol-table.ts | 72 ++- gitnexus/test/unit/symbol-resolver.test.ts | 557 ++++++++++++++++++ 4 files changed, 721 insertions(+), 49 deletions(-) diff --git a/gitnexus/src/core/ingestion/named-binding-processor.ts b/gitnexus/src/core/ingestion/named-binding-processor.ts index 1802f86e8..4340bf9e4 100644 --- a/gitnexus/src/core/ingestion/named-binding-processor.ts +++ b/gitnexus/src/core/ingestion/named-binding-processor.ts @@ -11,17 +11,12 @@ import type { NamedImportMap } from './import-processor.js'; * Returns the definitions found at the end of the chain, or null if the * chain breaks (missing binding, circular reference, or depth exceeded). * Max depth 5 to prevent infinite loops. - * - * @param allDefs Pre-computed `symbolTable.lookupFuzzy(name)` result — must be the - * complete unfiltered result. Passing a file-filtered subset will cause - * silent misses at depth=0 for non-aliased bindings. */ export function walkBindingChain( name: string, currentFilePath: string, symbolTable: SymbolTable, namedImportMap: NamedImportMap, - allDefs: SymbolDefinition[], ): SymbolDefinition[] | null { let lookupFile = currentFilePath; let lookupName = name; @@ -39,10 +34,7 @@ export function walkBindingChain( visited.add(key); const targetName = binding.exportedName; - const resolvedDefs = - targetName !== lookupName || depth > 0 - ? symbolTable.lookupExactAll(binding.sourcePath, targetName) - : allDefs.filter((def) => def.filePath === binding.sourcePath); + const resolvedDefs = symbolTable.lookupExactAll(binding.sourcePath, targetName); if (resolvedDefs.length > 0) return resolvedDefs; diff --git a/gitnexus/src/core/ingestion/resolution-context.ts b/gitnexus/src/core/ingestion/resolution-context.ts index da7908638..e2731ac23 100644 --- a/gitnexus/src/core/ingestion/resolution-context.ts +++ b/gitnexus/src/core/ingestion/resolution-context.ts @@ -6,16 +6,24 @@ * call-processor.ts. * * Resolution tiers (highest confidence first): - * 1. Same file (lookupExactFull — authoritative) + * 1. Same file (lookupExactAll — authoritative) * 2a-named. Named binding chain (walkBindingChain via NamedImportMap) - * 2a. Import-scoped (lookupFuzzy filtered by ImportMap) - * 2b. Package-scoped (lookupFuzzy filtered by PackageMap) - * 3. Global (all candidates — consumers must check candidate count) + * 2a. Import-scoped (iterate importedFiles with lookupExactAll per file) + * 2b. Package-scoped (iterate indexed files matching package dir with lookupExactAll) + * 3. Global (lookupClassByName + lookupImplByName + lookupFuzzyCallable — consumers must check count) + * + * SM-16: resolveUncached no longer calls lookupFuzzy. Each tier queries the + * minimum necessary scope directly: + * - Tier 2a iterates the caller's import set (O(imports) × O(1) lookupExactAll). + * - Tier 2b iterates all indexed files filtered by package dir + * (O(files) × O(1) lookupExactAll — avoids a global name scan). + * - Tier 3 combines lookupClassByName + lookupImplByName + lookupFuzzyCallable + * (three O(1) index lookups vs one O(1) lookupFuzzy, with a narrower result set). */ import type { SymbolTable, SymbolDefinition } from './symbol-table.js'; import { createSymbolTable } from './symbol-table.js'; -import type { NamedImportBinding } from './import-processor.js'; +import type { NamedImportMap } from './import-processor.js'; import { isFileInPackageDir } from './import-processor.js'; import { walkBindingChain } from './named-binding-processor.js'; @@ -38,7 +46,6 @@ export const TIER_CONFIDENCE: Record = { // --- Map types --- export type ImportMap = Map>; export type PackageMap = Map>; -export type NamedImportMap = Map>; /** Maps callerFile → (moduleAlias → sourceFilePath) for Python namespace imports. * e.g. `import models` in app.py → moduleAliasMap.get('app.py')?.get('models') === 'models.py' */ export type ModuleAliasMap = Map>; @@ -85,6 +92,13 @@ export const createResolutionContext = (): ResolutionContext => { const namedImportMap: NamedImportMap = new Map(); const moduleAliasMap: ModuleAliasMap = new Map(); + // Inverted index: packageDirSuffix → Set. + // Built lazily on first Tier 2b hit — one-time cost of O(totalFiles × + // allUniqueDirSuffixes) isFileInPackageDir calls across the entire + // packageMap, amortized over the pipeline run. Subsequent Tier 2b + // resolutions are O(callerPackages × filesInPackage × O(1)). + let packageDirIndex: Map> | null = null; + // Per-file cache state let cacheFile: string | null = null; let cache: Map | null = null; @@ -100,45 +114,102 @@ export const createResolutionContext = (): ResolutionContext => { return { candidates: localDefs, tier: 'same-file' }; } - // Get all global definitions for subsequent tiers - const allDefs = symbols.lookupFuzzy(name); - - // Tier 2a-named: Check named bindings BEFORE empty-allDefs early return - // because aliased imports mean lookupFuzzy('U') returns empty but we - // can resolve via the exported name. - const chainResult = walkBindingChain(name, fromFile, symbols, namedImportMap, allDefs); + // Tier 2a-named: Named binding chain (aliased / re-exported imports) + // Checked before import-scoped so that `import { User as U }` resolves + // correctly even when lookupExactAll on the alias name returns nothing. + const chainResult = walkBindingChain(name, fromFile, symbols, namedImportMap); if (chainResult && chainResult.length > 0) { return { candidates: chainResult, tier: 'import-scoped' }; } - if (allDefs.length === 0) return null; - - // Tier 2a: Import-scoped — definition in a file imported by fromFile + // Tier 2a: Import-scoped — iterate the caller's imported files directly. + // O(importedFiles) × O(1) lookupExactAll — no global name scan needed. const importedFiles = importMap.get(fromFile); if (importedFiles) { - const importedDefs = allDefs.filter((def) => importedFiles.has(def.filePath)); + const importedDefs: SymbolDefinition[] = []; + for (const file of importedFiles) { + importedDefs.push(...symbols.lookupExactAll(file, name)); + } if (importedDefs.length > 0) { return { candidates: importedDefs, tier: 'import-scoped' }; } } - // Tier 2b: Package-scoped — definition in a package dir imported by fromFile + // Tier 2b: Package-scoped — look up files in the caller's imported package + // directories via an inverted index (packageDirSuffix → Set), + // then do O(1) lookupExactAll per file. The inverted index is built lazily + // on first Tier 2b hit by scanning symbols.getFiles() once, making + // subsequent Tier 2b resolutions O(packages × filesInPackage) instead of + // O(allFiles × packages). const importedPackages = packageMap.get(fromFile); if (importedPackages) { - const packageDefs = allDefs.filter((def) => { - for (const dirSuffix of importedPackages) { - if (isFileInPackageDir(def.filePath, dirSuffix)) return true; + // Lazily build the inverted index on first use. For each indexed file, + // test it against isFileInPackageDir for all known dirSuffixes collected + // from packageMap. This scans all files once (instead of per-resolution) + // and produces a dirSuffix → Set map. + if (!packageDirIndex) { + // Collect all unique dir suffixes across the entire packageMap + const allDirSuffixes = new Set(); + for (const dirs of packageMap.values()) { + for (const d of dirs) allDirSuffixes.add(d); } - return false; - }); + packageDirIndex = new Map(); + for (const file of symbols.getFiles()) { + for (const dirSuffix of allDirSuffixes) { + if (isFileInPackageDir(file, dirSuffix)) { + let files = packageDirIndex.get(dirSuffix); + if (!files) { + files = new Set(); + packageDirIndex.set(dirSuffix, files); + } + files.add(file); + } + } + } + } + + const packageDefs: SymbolDefinition[] = []; + for (const dirSuffix of importedPackages) { + const filesInDir = packageDirIndex.get(dirSuffix); + if (filesInDir) { + for (const file of filesInDir) { + packageDefs.push(...symbols.lookupExactAll(file, name)); + } + } + } if (packageDefs.length > 0) { return { candidates: packageDefs, tier: 'import-scoped' }; } } - // Tier 3: Global — pass all candidates through. - // Consumers must check candidate count and refuse ambiguous matches. - return { candidates: allDefs, tier: 'global' }; + // Tier 3: Global — three targeted O(1) index lookups replace the single + // lookupFuzzy global scan. Class-like symbols (Class, Struct, Interface, + // Enum, Record, Trait) are covered by lookupClassByName; Rust impl blocks + // by lookupImplByName (separate to avoid polluting heritage resolution); + // callables (Function, Method, Constructor) by lookupFuzzyCallable. + // The three indexes cover disjoint symbol types so no dedup is needed. + // Consumers must check candidates.length and refuse ambiguous matches. + // + // Known exclusion: TypeAlias, Const, and Variable are NOT reachable at + // Tier 3 — they don't belong to any of the three indexes. The old + // lookupFuzzy returned them, but in practice they were never useful as + // Tier 3 candidates: TypeAlias is not a call target, Const/Variable + // are resolved via import or same-file tiers. If a future language + // needs them at Tier 3, add a dedicated index. + // Macro (C/C++) and Delegate (C#) ARE included in callableIndex + // since call-processor.ts treats them as callable targets. + // + // Note: lookupFuzzy is still called directly in call-processor.ts + // (D2 module-alias widen path at ~line 1506/1588). Those callers + // bypass resolveUncached entirely and are tracked for separate removal + // in the roadmap. fuzzyCallCount only reflects resolveUncached usage. + const classDefs = symbols.lookupClassByName(name); + const implDefs = symbols.lookupImplByName(name); + const callableDefs = symbols.lookupFuzzyCallable(name); + + if (classDefs.length === 0 && implDefs.length === 0 && callableDefs.length === 0) return null; + const globalDefs = [...classDefs, ...implDefs, ...callableDefs]; + return { candidates: globalDefs, tier: 'global' }; }; const resolve = (name: string, fromFile: string): TieredCandidates | null => { @@ -173,6 +244,13 @@ export const createResolutionContext = (): ResolutionContext => { cacheFile = null; // Reuse the Map instance — just clear entries to reduce GC pressure at scale. cache?.clear(); + // Note: packageDirIndex is NOT invalidated here. It is built lazily on + // first Tier 2b hit and remains valid across file boundaries because + // packageMap and the symbol file set are append-only during the calls + // phase (all parsing/import processing completes before resolution). + // Invalidating per-file would destroy the amortization benefit — the + // O(files × dirs) rebuild would run per-file instead of once. + // Full invalidation happens in clear() (pipeline reset). }; const getStats = () => ({ @@ -187,6 +265,7 @@ export const createResolutionContext = (): ResolutionContext => { packageMap.clear(); namedImportMap.clear(); moduleAliasMap.clear(); + packageDirIndex = null; // invalidate — will rebuild on next Tier 2b hit clearCache(); cacheHits = 0; cacheMisses = 0; diff --git a/gitnexus/src/core/ingestion/symbol-table.ts b/gitnexus/src/core/ingestion/symbol-table.ts index b8d39170c..451a213b3 100644 --- a/gitnexus/src/core/ingestion/symbol-table.ts +++ b/gitnexus/src/core/ingestion/symbol-table.ts @@ -140,6 +140,22 @@ export interface SymbolTable { */ lookupClassByQualifiedName: (qualifiedName: string) => SymbolDefinition[]; + /** + * Look up Impl nodes by name. + * O(1) via dedicated eagerly-populated index keyed by symbol name. + * Used by Tier 3 resolution to include Rust impl blocks alongside + * class-like candidates so method lookups on `impl User { fn save() }` work + * correctly (Rust methods are indexed under the Impl nodeId, not the Struct). + */ + lookupImplByName: (name: string) => SymbolDefinition[]; + + /** + * Iterate all indexed file paths. + * Used by Tier 2b (package-scoped) resolution to walk files matching a + * package directory suffix without a global name scan. + */ + getFiles: () => IterableIterator; + /** * Debugging: See how many symbols are tracked */ @@ -166,10 +182,10 @@ export const createSymbolTable = (): SymbolTable => { // Structure: SymbolName -> [List of Definitions] const globalIndex = new Map(); - // 3. Lazy Callable Index — populated on first lookupFuzzyCallable call. + // 3. Eagerly-populated Callable Index — maintained on add(). // Structure: SymbolName -> [Callable Definitions] // Only Function, Method, Constructor symbols are indexed. - let callableIndex: Map | null = null; + const callableIndex = new Map(); // 4. Eagerly-populated Field/Property Index — keyed by "ownerNodeId\0fieldName". // Only Property symbols with ownerId and declaredType are indexed. @@ -184,11 +200,18 @@ export const createSymbolTable = (): SymbolTable => { const classByName = new Map(); const classByQualifiedName = new Map(); + // 7. Eagerly-populated Impl Index — keyed by symbol name. + // Rust impl blocks (type 'Impl') are stored here to keep them out of + // classByName (which drives heritage resolution) while still being + // reachable from Tier 3 resolution for method lookup. + const implByName = new Map(); let fuzzyCallCount = 0; let fuzzyCallableCallCount = 0; - const CALLABLE_TYPES = new Set(['Function', 'Method', 'Constructor']); + // Must match CALLABLE_SYMBOL_TYPES in call-processor.ts — Macro (C/C++) + // and Delegate (C#) are callable targets that Tier 3 must surface. + const CALLABLE_TYPES = new Set(['Function', 'Method', 'Constructor', 'Macro', 'Delegate']); const add = ( filePath: string, @@ -291,9 +314,25 @@ export const createSymbolTable = (): SymbolTable => { } } - // D. Invalidate the lazy callable index only when adding callable types + // C4. Rust Impl blocks go to implByName (separate from classByName to avoid + // polluting heritage resolution with Impl nodes as parent candidates). + if (type === 'Impl') { + const existing = implByName.get(name); + if (existing) { + existing.push(def); + } else { + implByName.set(name, [def]); + } + } + + // D. Eagerly maintain callable index (like classByName, implByName). if (CALLABLE_TYPES.has(type)) { - callableIndex = null; + const existing = callableIndex.get(name); + if (existing) { + existing.push(def); + } else { + callableIndex.set(name, [def]); + } } }; @@ -318,14 +357,6 @@ export const createSymbolTable = (): SymbolTable => { const lookupFuzzyCallable = (name: string): SymbolDefinition[] => { fuzzyCallableCallCount++; - if (!callableIndex) { - // Build the callable index lazily on first use - callableIndex = new Map(); - for (const [symName, defs] of globalIndex) { - const callables = defs.filter((d) => CALLABLE_TYPES.has(d.type)); - if (callables.length > 0) callableIndex.set(symName, callables); - } - } return callableIndex.get(name) ?? []; }; @@ -385,6 +416,16 @@ export const createSymbolTable = (): SymbolTable => { return classByQualifiedName.get(qualifiedName) ?? []; }; + const lookupImplByName = (name: string): SymbolDefinition[] => { + return implByName.get(name) ?? []; + }; + + /** Returns a live iterator over all indexed file paths (fileIndex.keys()). + * The iterator is invalidated if add() changes fileIndex.size during + * iteration (ES2015 Map spec). Safe in the current pipeline because all + * symbols are added before resolution begins. */ + const getFiles = (): IterableIterator => fileIndex.keys(); + const getStats = () => ({ fileCount: fileIndex.size, globalSymbolCount: globalIndex.size, @@ -395,11 +436,12 @@ export const createSymbolTable = (): SymbolTable => { const clear = () => { fileIndex.clear(); globalIndex.clear(); - callableIndex = null; + callableIndex.clear(); fieldByOwner.clear(); methodByOwner.clear(); classByName.clear(); classByQualifiedName.clear(); + implByName.clear(); fuzzyCallCount = 0; fuzzyCallableCallCount = 0; }; @@ -415,6 +457,8 @@ export const createSymbolTable = (): SymbolTable => { lookupMethodByOwner, lookupClassByName, lookupClassByQualifiedName, + lookupImplByName, + getFiles, getStats, clear, }; diff --git a/gitnexus/test/unit/symbol-resolver.test.ts b/gitnexus/test/unit/symbol-resolver.test.ts index fbf4067be..e338deb9f 100644 --- a/gitnexus/test/unit/symbol-resolver.test.ts +++ b/gitnexus/test/unit/symbol-resolver.test.ts @@ -625,3 +625,560 @@ describe('per-file cache', () => { expect(r!.tier).toBe('global'); }); }); + +// --------------------------------------------------------------------------- +// SM-16: resolveUncached no longer calls lookupFuzzy +// --------------------------------------------------------------------------- + +// Note: fuzzyCallCount tracks ALL lookupFuzzy calls on the SymbolTable, including +// the D2 module-alias widen path in call-processor.ts which still calls lookupFuzzy +// directly. This test only exercises resolveUncached (via ctx.resolve), so the stat +// is 0 here. In a full pipeline integration test, fuzzyCallCount would be non-zero +// due to D2 callers. +describe('SM-16: resolveUncached does not call lookupFuzzy', () => { + it('lookupFuzzy is never called during resolve — fuzzyCallCount stays at 0', () => { + const ctx = createResolutionContext(); + ctx.symbols.add('src/user.ts', 'User', 'Class:src/user.ts:User', 'Class'); + ctx.symbols.add('src/service.ts', 'UserService', 'Class:src/service.ts:UserService', 'Class'); + ctx.importMap.set('src/app.ts', new Set(['src/user.ts'])); + ctx.packageMap.set('cmd/main.go', new Set(['/internal/'])); + + // Exercise all tiers + ctx.resolve('User', 'src/user.ts'); // Tier 1 same-file + ctx.resolve('User', 'src/app.ts'); // Tier 2a import-scoped + ctx.resolve('UserService', 'src/other.ts'); // Tier 3 global + + expect(ctx.getStats().fuzzyCallCount).toBe(0); + }); +}); + +// Tier 2a uses importMap (file-level imports). Go resolves cross-package symbols +// via packageMap (Tier 2b) instead, so no Go Tier 2a test is needed. Kotlin and +// PHP support file-level imports but the importMap path is language-agnostic — +// the existing TS/Java/Python/C# fixtures prove correctness at the infra level. +describe('SM-16: Tier 2a — iterate importedFiles with lookupExactAll', () => { + let ctx: ResolutionContext; + + beforeEach(() => { + ctx = createResolutionContext(); + }); + + it('collects definitions from all imported files', () => { + ctx.symbols.add('src/a.ts', 'Widget', 'Class:src/a.ts:Widget', 'Class'); + ctx.symbols.add('src/b.ts', 'Widget', 'Class:src/b.ts:Widget', 'Class'); + ctx.importMap.set('src/app.ts', new Set(['src/a.ts', 'src/b.ts'])); + + const result = ctx.resolve('Widget', 'src/app.ts'); + + expect(result).not.toBeNull(); + expect(result!.tier).toBe('import-scoped'); + expect(result!.candidates.length).toBe(2); + expect(result!.candidates.map((c) => c.filePath).sort()).toEqual(['src/a.ts', 'src/b.ts']); + }); + + it('skips files with no matching symbol — no false positives', () => { + ctx.symbols.add('src/a.ts', 'Widget', 'Class:src/a.ts:Widget', 'Class'); + ctx.symbols.add('src/b.ts', 'Button', 'Class:src/b.ts:Button', 'Class'); + ctx.importMap.set('src/app.ts', new Set(['src/a.ts', 'src/b.ts'])); + + const result = ctx.resolve('Widget', 'src/app.ts'); + + expect(result!.candidates.length).toBe(1); + expect(result!.candidates[0].filePath).toBe('src/a.ts'); + }); + + it('returns all overloads from a single imported file', () => { + // Same-name method overloads in one file + ctx.symbols.add('src/math.ts', 'add', 'fn:math:add:0', 'Function', { parameterCount: 1 }); + ctx.symbols.add('src/math.ts', 'add', 'fn:math:add:2', 'Function', { parameterCount: 2 }); + ctx.importMap.set('src/app.ts', new Set(['src/math.ts'])); + + const result = ctx.resolve('add', 'src/app.ts'); + + expect(result!.tier).toBe('import-scoped'); + expect(result!.candidates.length).toBe(2); + }); + + it('Java: resolves class from import via lookupExactAll per file', () => { + ctx.symbols.add( + 'com/example/models/User.java', + 'User', + 'Class:com/example/models/User.java:User', + 'Class', + ); + ctx.importMap.set( + 'com/example/services/UserService.java', + new Set(['com/example/models/User.java']), + ); + + const result = ctx.resolve('User', 'com/example/services/UserService.java'); + + expect(result!.tier).toBe('import-scoped'); + expect(result!.candidates[0].filePath).toBe('com/example/models/User.java'); + }); + + it('Python: resolves function from imported module file', () => { + ctx.symbols.add('models.py', 'User', 'Class:models.py:User', 'Class'); + ctx.importMap.set('app.py', new Set(['models.py'])); + + const result = ctx.resolve('User', 'app.py'); + + expect(result!.tier).toBe('import-scoped'); + expect(result!.candidates[0].filePath).toBe('models.py'); + }); + + it('C#: resolves interface from imported file', () => { + ctx.symbols.add( + 'src/Services/IService.cs', + 'IService', + 'Interface:src/Services/IService.cs:IService', + 'Interface', + ); + ctx.importMap.set('src/Controllers/HomeController.cs', new Set(['src/Services/IService.cs'])); + + const result = ctx.resolve('IService', 'src/Controllers/HomeController.cs'); + + expect(result!.tier).toBe('import-scoped'); + expect(result!.candidates[0].type).toBe('Interface'); + }); + + it('TypeScript: resolves re-exported class via named binding chain', () => { + // index.ts re-exports User from models.ts + ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.namedImportMap.set( + 'src/index.ts', + new Map([['User', { sourcePath: 'src/models.ts', exportedName: 'User' }]]), + ); + ctx.namedImportMap.set( + 'src/app.ts', + new Map([['User', { sourcePath: 'src/index.ts', exportedName: 'User' }]]), + ); + + const result = ctx.resolve('User', 'src/app.ts'); + + expect(result).not.toBeNull(); + expect(result!.tier).toBe('import-scoped'); + expect(result!.candidates[0].filePath).toBe('src/models.ts'); + }); +}); + +describe('SM-16: Tier 2b — iterate getFiles() + isFileInPackageDir', () => { + let ctx: ResolutionContext; + + beforeEach(() => { + ctx = createResolutionContext(); + }); + + it('Go: resolves symbol in package dir via file iteration (no lookupFuzzy)', () => { + ctx.symbols.add( + 'internal/auth/handler.go', + 'Authenticate', + 'Function:internal/auth/handler.go:Authenticate', + 'Function', + ); + ctx.symbols.add( + 'internal/db/repo.go', + 'Authenticate', + 'Function:internal/db/repo.go:Authenticate', + 'Function', + ); + ctx.packageMap.set('cmd/main.go', new Set(['/internal/auth/'])); + + const result = ctx.resolve('Authenticate', 'cmd/main.go'); + + expect(result!.tier).toBe('import-scoped'); + expect(result!.candidates.length).toBe(1); + expect(result!.candidates[0].filePath).toBe('internal/auth/handler.go'); + }); + + it('C#: resolves class from namespace directory', () => { + ctx.symbols.add('MyApp/Models/User.cs', 'User', 'Class:MyApp/Models/User.cs:User', 'Class'); + ctx.symbols.add('MyApp/Other/User.cs', 'User', 'Class:MyApp/Other/User.cs:User', 'Class'); + ctx.packageMap.set('MyApp/Controllers/UserController.cs', new Set(['/MyApp/Models/'])); + + const result = ctx.resolve('User', 'MyApp/Controllers/UserController.cs'); + + expect(result!.tier).toBe('import-scoped'); + expect(result!.candidates.length).toBe(1); + expect(result!.candidates[0].filePath).toBe('MyApp/Models/User.cs'); + }); + + it('Tier 2a (ImportMap) still takes precedence over Tier 2b (PackageMap)', () => { + ctx.symbols.add( + 'internal/auth/handler.go', + 'Validate', + 'Function:internal/auth/handler.go:Validate', + 'Function', + ); + ctx.symbols.add( + 'internal/db/validator.go', + 'Validate', + 'Function:internal/db/validator.go:Validate', + 'Function', + ); + ctx.importMap.set('cmd/main.go', new Set(['internal/db/validator.go'])); + ctx.packageMap.set('cmd/main.go', new Set(['/internal/auth/'])); + + const result = ctx.resolve('Validate', 'cmd/main.go'); + + expect(result!.tier).toBe('import-scoped'); + expect(result!.candidates[0].filePath).toBe('internal/db/validator.go'); + }); +}); + +describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookupFuzzyCallable', () => { + let ctx: ResolutionContext; + + beforeEach(() => { + ctx = createResolutionContext(); + }); + + it('returns class-like symbol (Class) at global tier', () => { + ctx.symbols.add('src/user.ts', 'User', 'Class:src/user.ts:User', 'Class'); + + const result = ctx.resolve('User', 'src/app.ts'); + + expect(result!.tier).toBe('global'); + expect(result!.candidates[0].type).toBe('Class'); + }); + + it('returns callable symbol (Function) at global tier', () => { + ctx.symbols.add('src/utils.ts', 'parseDate', 'Function:src/utils.ts:parseDate', 'Function'); + + const result = ctx.resolve('parseDate', 'src/app.ts'); + + expect(result!.tier).toBe('global'); + expect(result!.candidates[0].type).toBe('Function'); + }); + + it('returns both Class and Function with the same name at global tier', () => { + ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.symbols.add('src/factories.ts', 'User', 'Function:src/factories.ts:User', 'Function'); + + const result = ctx.resolve('User', 'src/app.ts'); + + expect(result!.tier).toBe('global'); + expect(result!.candidates.length).toBe(2); + const types = result!.candidates.map((c) => c.type).sort(); + expect(types).toEqual(['Class', 'Function']); + }); + + it('Rust: returns Impl node at global tier (needed for method resolution)', () => { + ctx.symbols.add('src/user.rs', 'User', 'Struct:src/user.rs:User', 'Struct'); + ctx.symbols.add('src/user.rs', 'User', 'Impl:src/user.rs:User', 'Impl'); + + const result = ctx.resolve('User', 'src/main.rs'); + + expect(result!.tier).toBe('global'); + const types = result!.candidates.map((c) => c.type).sort(); + expect(types).toContain('Struct'); + expect(types).toContain('Impl'); + }); + + it('Rust: Impl is separate from Class-like types — does not affect heritage (lookupClassByName)', () => { + const table = createSymbolTable(); + table.add('src/user.rs', 'User', 'Struct:src/user.rs:User', 'Struct'); + table.add('src/user.rs', 'User', 'Impl:src/user.rs:User', 'Impl'); + + // lookupClassByName excludes Impl (preserves heritage resolution correctness) + const classDefs = table.lookupClassByName('User'); + expect(classDefs.map((d) => d.type)).toEqual(['Struct']); + + // lookupImplByName returns only Impl nodes + const implDefs = table.lookupImplByName('User'); + expect(implDefs.map((d) => d.type)).toEqual(['Impl']); + }); + + it('ambiguous global returns all candidates (consumers decide)', () => { + ctx.symbols.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class'); + ctx.symbols.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class'); + + const result = ctx.resolve('Config', 'src/other.ts'); + + expect(result!.tier).toBe('global'); + expect(result!.candidates.length).toBe(2); + }); + + it('returns null when no symbol exists at any tier', () => { + const result = ctx.resolve('NonExistent', 'src/app.ts'); + expect(result).toBeNull(); + }); + + it('TypeScript: resolves Enum at global tier', () => { + ctx.symbols.add('src/status.ts', 'Status', 'Enum:src/status.ts:Status', 'Enum'); + + const result = ctx.resolve('Status', 'src/app.ts'); + + expect(result!.tier).toBe('global'); + expect(result!.candidates[0].type).toBe('Enum'); + }); + + it('Kotlin: resolves data class (Record) at global tier', () => { + ctx.symbols.add('src/User.kt', 'User', 'Record:src/User.kt:User', 'Record'); + + const result = ctx.resolve('User', 'src/Main.kt'); + + expect(result!.tier).toBe('global'); + expect(result!.candidates[0].type).toBe('Record'); + }); + + it('PHP: resolves Trait at global tier', () => { + ctx.symbols.add('src/Loggable.php', 'Loggable', 'Trait:src/Loggable.php:Loggable', 'Trait'); + + const result = ctx.resolve('Loggable', 'src/App.php'); + + expect(result!.tier).toBe('global'); + expect(result!.candidates[0].type).toBe('Trait'); + }); + + it('Java: resolves Interface at global tier', () => { + ctx.symbols.add( + 'com/example/IService.java', + 'IService', + 'Interface:com/example/IService.java:IService', + 'Interface', + ); + + const result = ctx.resolve('IService', 'com/example/ServiceImpl.java'); + + expect(result!.tier).toBe('global'); + expect(result!.candidates[0].type).toBe('Interface'); + }); + + it('Go: resolves Struct at global tier', () => { + ctx.symbols.add( + 'internal/model/user.go', + 'User', + 'Struct:internal/model/user.go:User', + 'Struct', + ); + + const result = ctx.resolve('User', 'cmd/main.go'); + + expect(result!.tier).toBe('global'); + expect(result!.candidates[0].type).toBe('Struct'); + }); +}); + +describe('SM-16: SymbolTable.getFiles()', () => { + it('returns all indexed file paths', () => { + const table = createSymbolTable(); + table.add('src/a.ts', 'Foo', 'Class:a:Foo', 'Class'); + table.add('src/b.ts', 'Bar', 'Class:b:Bar', 'Class'); + table.add('src/c.ts', 'Baz', 'Function:c:Baz', 'Function'); + + const files = [...table.getFiles()]; + expect(files.sort()).toEqual(['src/a.ts', 'src/b.ts', 'src/c.ts']); + }); + + it('returns empty iterator for empty symbol table', () => { + const table = createSymbolTable(); + const files = [...table.getFiles()]; + expect(files).toHaveLength(0); + }); + + it('does not duplicate files with multiple symbols', () => { + const table = createSymbolTable(); + table.add('src/a.ts', 'Foo', 'Class:a:Foo', 'Class'); + table.add('src/a.ts', 'Bar', 'Class:a:Bar', 'Class'); + + const files = [...table.getFiles()]; + expect(files).toHaveLength(1); + expect(files[0]).toBe('src/a.ts'); + }); +}); + +describe('SM-16: walkBindingChain — no allDefs parameter', () => { + it('resolves non-aliased import via lookupExactAll at depth=0', () => { + const ctx = createResolutionContext(); + ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.namedImportMap.set( + 'src/app.ts', + new Map([['User', { sourcePath: 'src/models.ts', exportedName: 'User' }]]), + ); + + const result = ctx.resolve('User', 'src/app.ts'); + + expect(result!.tier).toBe('import-scoped'); + expect(result!.candidates[0].filePath).toBe('src/models.ts'); + }); + + it('resolves aliased import (U → User) via chain walk', () => { + const ctx = createResolutionContext(); + ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.namedImportMap.set( + 'src/app.ts', + new Map([['U', { sourcePath: 'src/models.ts', exportedName: 'User' }]]), + ); + + const result = ctx.resolve('U', 'src/app.ts'); + + expect(result!.tier).toBe('import-scoped'); + expect(result!.candidates[0].nodeId).toBe('Class:src/models.ts:User'); + }); + + it('follows re-export chain A → B → C', () => { + const ctx = createResolutionContext(); + ctx.symbols.add('src/models.ts', 'Widget', 'Class:src/models.ts:Widget', 'Class'); + // B re-exports Widget from C + ctx.namedImportMap.set( + 'src/index.ts', + new Map([['Widget', { sourcePath: 'src/models.ts', exportedName: 'Widget' }]]), + ); + // A imports Widget from B + ctx.namedImportMap.set( + 'src/app.ts', + new Map([['Widget', { sourcePath: 'src/index.ts', exportedName: 'Widget' }]]), + ); + + const result = ctx.resolve('Widget', 'src/app.ts'); + + expect(result!.tier).toBe('import-scoped'); + expect(result!.candidates[0].filePath).toBe('src/models.ts'); + }); +}); + +// ── F1: Tier 3 TypeAlias/Const/Variable exclusion (documented intentional gap) ── + +describe('SM-16: Tier 3 — TypeAlias, Const, Variable are NOT returned', () => { + let ctx: ResolutionContext; + + beforeEach(() => { + ctx = createResolutionContext(); + }); + + it('TypeAlias is not reachable at Tier 3', () => { + ctx.symbols.add( + 'src/types.ts', + 'Handler', + 'TypeAlias:src/types.ts:Handler', + 'TypeAlias' as any, + ); + const result = ctx.resolve('Handler', 'src/app.ts'); + expect(result).toBeNull(); + }); + + it('Const is not reachable at Tier 3', () => { + ctx.symbols.add( + 'src/config.ts', + 'MAX_RETRIES', + 'Const:src/config.ts:MAX_RETRIES', + 'Const' as any, + ); + const result = ctx.resolve('MAX_RETRIES', 'src/app.ts'); + expect(result).toBeNull(); + }); + + it('Variable is not reachable at Tier 3', () => { + ctx.symbols.add('src/state.ts', 'counter', 'Variable:src/state.ts:counter', 'Variable' as any); + const result = ctx.resolve('counter', 'src/app.ts'); + expect(result).toBeNull(); + }); + + it('Class-like and callable ARE reachable at Tier 3 (control)', () => { + ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.symbols.add('src/utils.ts', 'getUser', 'Function:src/utils.ts:getUser', 'Function'); + + const classResult = ctx.resolve('User', 'src/app.ts'); + expect(classResult).not.toBeNull(); + expect(classResult!.tier).toBe('global'); + + const funcResult = ctx.resolve('getUser', 'src/app.ts'); + expect(funcResult).not.toBeNull(); + expect(funcResult!.tier).toBe('global'); + }); + + it('Macro (C/C++) is reachable at Tier 3 via callableIndex', () => { + ctx.symbols.add('src/macros.h', 'ASSERT', 'Macro:src/macros.h:ASSERT', 'Macro' as any); + const result = ctx.resolve('ASSERT', 'src/main.c'); + expect(result).not.toBeNull(); + expect(result!.tier).toBe('global'); + expect(result!.candidates[0].type).toBe('Macro'); + }); + + it('Delegate (C#) is reachable at Tier 3 via callableIndex', () => { + ctx.symbols.add( + 'src/Events.cs', + 'OnClick', + 'Delegate:src/Events.cs:OnClick', + 'Delegate' as any, + ); + const result = ctx.resolve('OnClick', 'src/App.cs'); + expect(result).not.toBeNull(); + expect(result!.tier).toBe('global'); + expect(result!.candidates[0].type).toBe('Delegate'); + }); +}); + +// ── packageDirIndex invalidation regression test ── + +describe('SM-16: Tier 2b — packageDirIndex picks up symbols added after clear()', () => { + it('resolves newly added symbol after clear() resets the index', () => { + const ctx = createResolutionContext(); + // Initial setup: one symbol in package dir + ctx.symbols.add('pkg/models/user.go', 'User', 'Struct:pkg/models/user.go:User', 'Struct'); + ctx.packageMap.set('cmd/main.go', new Set(['/pkg/models/'])); + + // Prime the packageDirIndex via a Tier 2b resolution + const first = ctx.resolve('User', 'cmd/main.go'); + expect(first!.tier).toBe('import-scoped'); + + // Full reset (simulates pipeline re-run) + ctx.clear(); + + // Re-add symbols with a NEW file in the package dir + ctx.symbols.add('pkg/models/user.go', 'User', 'Struct:pkg/models/user.go:User', 'Struct'); + ctx.symbols.add('pkg/models/order.go', 'Order', 'Struct:pkg/models/order.go:Order', 'Struct'); + ctx.packageMap.set('cmd/main.go', new Set(['/pkg/models/'])); + + // The new symbol must be visible — packageDirIndex was invalidated by clear() + const second = ctx.resolve('Order', 'cmd/main.go'); + expect(second).not.toBeNull(); + expect(second!.tier).toBe('import-scoped'); + expect(second!.candidates[0].filePath).toBe('pkg/models/order.go'); + }); +}); + +// ── F7: Tier 2b language fixtures — Rust, Kotlin, PHP ── + +describe('SM-16: Tier 2b — Rust package-scoped resolution', () => { + it('resolves struct in package dir via Tier 2b', () => { + const ctx = createResolutionContext(); + ctx.symbols.add('src/models/user.rs', 'User', 'Struct:src/models/user.rs:User', 'Struct'); + ctx.symbols.add('src/other/user.rs', 'User', 'Struct:src/other/user.rs:User', 'Struct'); + ctx.packageMap.set('src/main.rs', new Set(['/src/models/'])); + + const result = ctx.resolve('User', 'src/main.rs'); + expect(result!.tier).toBe('import-scoped'); + expect(result!.candidates.length).toBe(1); + expect(result!.candidates[0].filePath).toBe('src/models/user.rs'); + }); +}); + +describe('SM-16: Tier 2b — Kotlin package-scoped resolution', () => { + it('resolves class in package dir via Tier 2b', () => { + const ctx = createResolutionContext(); + ctx.symbols.add('com/app/models/User.kt', 'User', 'Class:com/app/models/User.kt:User', 'Class'); + ctx.symbols.add('com/app/other/User.kt', 'User', 'Class:com/app/other/User.kt:User', 'Class'); + ctx.packageMap.set('com/app/Main.kt', new Set(['/com/app/models/'])); + + const result = ctx.resolve('User', 'com/app/Main.kt'); + expect(result!.tier).toBe('import-scoped'); + expect(result!.candidates.length).toBe(1); + expect(result!.candidates[0].filePath).toBe('com/app/models/User.kt'); + }); +}); + +describe('SM-16: Tier 2b — PHP namespace directory resolution', () => { + it('resolves class in namespace dir via Tier 2b', () => { + const ctx = createResolutionContext(); + ctx.symbols.add('app/Models/User.php', 'User', 'Class:app/Models/User.php:User', 'Class'); + ctx.symbols.add('app/Other/User.php', 'User', 'Class:app/Other/User.php:User', 'Class'); + ctx.packageMap.set('app/Controllers/UserController.php', new Set(['/app/Models/'])); + + const result = ctx.resolve('User', 'app/Controllers/UserController.php'); + expect(result!.tier).toBe('import-scoped'); + expect(result!.candidates.length).toBe(1); + expect(result!.candidates[0].filePath).toBe('app/Models/User.php'); + }); +}); From 100858f8c8ea77e1fbd23d5e091cd8bb3846f108 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:56:24 +0100 Subject: [PATCH 08/67] feat(SM-18): Delete lookupFuzzy, lookupFuzzyCallable, globalIndex, callableIndex (#769) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * Update test files for SymbolTable interface changes Remove lookupFuzzy, lookupFuzzyCallable, globalIndex, and callableIndex references from all test files. Replace lookupFuzzyCallable with lookupCallableByName. Update getStats assertions to only expect { fileCount }. Remove tests that exclusively tested removed methods. Files updated: - symbol-table.test.ts: Remove lookupFuzzy describe block and all globalIndex/callableIndex tests, update callable method references - symbol-resolver.test.ts: Remove SM-16 lookupFuzzy test block, update Tier 3 describe title - type-env.test.ts: Update all mock SymbolTable objects and spy variable names - call-form.test.ts: Update ownerId propagation test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(SM-18): Remove lookupFuzzy, lookupFuzzyCallable, globalIndex, callableIndex Remove from SymbolTable interface and implementation: - lookupFuzzy method - lookupFuzzyCallable method - globalIndex Map - callableIndex Map (renamed to callableByName, backing lookupCallableByName) Add lookupCallableByName as the targeted replacement for fuzzy callable lookups. Migrate all production callers: - resolution-context.ts: lookupFuzzyCallable → lookupCallableByName - type-env.ts: lookupFuzzyCallable → lookupCallableByName - call-processor.ts: lookupFuzzy → lookupCallableByName (D2 widen paths) Remove fuzzyCallCount/fuzzyCallableCallCount stats and globalSymbolCount from getStats(). Update pipeline.ts logging accordingly. Memory savings: globalIndex stored every non-Property symbol (typically the largest index by entry count). Removing it eliminates one Map plus all its per-name arrays — net savings proportional to unique symbol count in the project. Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/4a658c69-41a9-4d57-8527-50ca544ca967 * fix(SM-18): address all PR #769 review findings 1. type-env.test.ts mock: add missing lookupImplByName + getFiles methods. 2. Macro/Delegate tests: 2 new tests confirm C/C++ Macro and C# Delegate are indexed in callableByName. 3. D2 widen path test: module-alias scenario verifying lookupCallableByName resolves methods in aliased files that shadow same-file definitions. 4. CALLABLE_TYPES unified: exported from symbol-table.ts (single source of truth), imported in call-processor.ts. Removed duplicate CALLABLE_SYMBOL_TYPES constant. 5. getStats() observability restored: tier hit counters (tierSameFile, tierImportScoped, tierGlobal, tierMiss) replace the removed fuzzyCallCount diagnostic. * chore(SM-18): remove unnecessary `as any` casts on valid NodeLabel types Macro, Delegate, TypeAlias, Const, and Variable are all valid NodeLabel values in gitnexus-shared. The casts suppressed type checking without purpose and signaled false uncertainty. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Gergo Magyar --- gitnexus/src/core/ingestion/call-processor.ts | 44 ++-- gitnexus/src/core/ingestion/pipeline.ts | 3 - .../src/core/ingestion/resolution-context.ts | 67 ++++-- gitnexus/src/core/ingestion/symbol-table.ts | 100 +++----- gitnexus/src/core/ingestion/type-env.ts | 4 +- .../core/ingestion/type-extractors/types.ts | 2 +- gitnexus/test/unit/call-form.test.ts | 4 +- gitnexus/test/unit/call-processor.test.ts | 43 +++- gitnexus/test/unit/symbol-resolver.test.ts | 74 +----- gitnexus/test/unit/symbol-table.test.ts | 225 +++++++----------- gitnexus/test/unit/type-env.test.ts | 78 +++--- 11 files changed, 279 insertions(+), 365 deletions(-) diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 755ec1e29..a5258fa8e 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -1,7 +1,7 @@ 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 { CLASS_TYPES, CALLABLE_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'; @@ -1299,7 +1299,7 @@ export const processCalls = async ( return collectedHeritage; }; -const CALLABLE_SYMBOL_TYPES = new Set(['Function', 'Method', 'Constructor', 'Macro', 'Delegate']); +// CALLABLE_TYPES imported from symbol-table.ts — single source of truth. const CONSTRUCTOR_TARGET_TYPES = new Set(['Constructor', 'Class', 'Struct', 'Record']); @@ -1317,10 +1317,10 @@ const filterCallableCandidates = ( } else { const types = candidates.filter((c) => CONSTRUCTOR_TARGET_TYPES.has(c.type)); kindFiltered = - types.length > 0 ? types : candidates.filter((c) => CALLABLE_SYMBOL_TYPES.has(c.type)); + types.length > 0 ? types : candidates.filter((c) => CALLABLE_TYPES.has(c.type)); } } else { - kindFiltered = candidates.filter((c) => CALLABLE_SYMBOL_TYPES.has(c.type)); + kindFiltered = candidates.filter((c) => CALLABLE_TYPES.has(c.type)); } if (kindFiltered.length === 0) return []; @@ -1476,7 +1476,7 @@ const dedupSwiftExtensionCandidates = ( * * If filtering still leaves multiple candidates, refuse to emit a CALLS edge. */ -/** Per-file cache for the widen path's lookupFuzzy calls. Cleared between files. */ +/** Per-file cache for the widen path's lookupCallableByName calls. Cleared between files. */ type WidenCache = Map; /** @internal Exported for unit tests of D0 skip conditions (SM-11). Do not use outside tests. */ @@ -1577,7 +1577,7 @@ const resolveCallTarget = ( // 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). + // by calling lookupCallableByName 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); @@ -1591,12 +1591,12 @@ const resolveCallTarget = ( } 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. - // Use per-file widenCache to avoid repeated lookupFuzzy for the same + // Use per-file widenCache to avoid repeated lookupCallableByName for the same // calledName+moduleFile from multiple call sites in the same file. const cacheKey = `${call.calledName}\0${moduleFile}`; let fuzzyDefs = widenCache?.get(cacheKey); if (!fuzzyDefs) { - fuzzyDefs = ctx.symbols.lookupFuzzy(call.calledName); + fuzzyDefs = ctx.symbols.lookupCallableByName(call.calledName); widenCache?.set(cacheKey, fuzzyDefs); } const widened = filterCallableCandidates(fuzzyDefs, call.argCount, call.callForm).filter( @@ -1668,17 +1668,17 @@ const resolveCallTarget = ( const typeFiles = new Set(typeResolved.candidates.map((d) => d.filePath)); // 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 + // it lives in another file. Query the callable index 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 + // disambiguation, do NOT widen back to the full callable pool — that // would undo the alias narrowing and reintroduce homonym candidates // from other files. const methodPool = filteredCandidates.length <= 1 && !aliasNarrowed ? filterCallableCandidates( - ctx.symbols.lookupFuzzy(call.calledName), + ctx.symbols.lookupCallableByName(call.calledName), call.argCount, call.callForm, ) @@ -1715,7 +1715,7 @@ const resolveCallTarget = ( // 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 + // widening 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 @@ -1920,12 +1920,12 @@ const resolveFieldOwnership = ( * * After deduplication: * - * - 0 unique matches → `undefined` (owner-scoped path has no answer; D1-D4 fuzzy - * fallback in `resolveCallTarget` may still find something via lookupFuzzy) + * - 0 unique matches → `undefined` (owner-scoped path has no answer; D1-D4 + * fallback in `resolveCallTarget` may still find something via callable index) * - 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 + * This absorbs what was previously D4's job inside `resolveCallTarget` — "filter * candidates to those whose ownerId is in the receiver type's nodeId set" — into the * owner-scoped path, aligning with the plan's target: * @@ -1933,8 +1933,7 @@ const resolveFieldOwnership = ( * * 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. + * this decouples callers from `ctx.resolve`'s per-file caching contract. */ const resolveMethodByOwner = ( receiverTypeName: string, @@ -2064,17 +2063,12 @@ export const resolveMemberCall = ( * {@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 + * NOT take a `widenCache` parameter and does NOT run a D2 widening * pass. Member calls (`resolveCallTarget`'s main body) widen via - * `lookupFuzzy` to reach parent-class methods defined in different files; + * `lookupCallableByName` 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. + * from `ctx.resolve()`. * * @param calledName - The called function name (e.g. 'doStuff') * @param filePath - File path of the call site diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 9c754ed2f..42c630289 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -1106,9 +1106,6 @@ async function runChunkedParseAndResolve( console.log( `🔍 Resolution cache: ${rcStats.cacheHits} hits, ${rcStats.cacheMisses} misses (${hitRate}% hit rate)`, ); - console.log( - `🔍 Fuzzy Lookups: ${rcStats.fuzzyCallCount} total, ${rcStats.fuzzyCallableCallCount} callable`, - ); } // ── Finalize the accumulator before the read phase begins. All worker-path diff --git a/gitnexus/src/core/ingestion/resolution-context.ts b/gitnexus/src/core/ingestion/resolution-context.ts index e2731ac23..06b2c89be 100644 --- a/gitnexus/src/core/ingestion/resolution-context.ts +++ b/gitnexus/src/core/ingestion/resolution-context.ts @@ -10,15 +10,14 @@ * 2a-named. Named binding chain (walkBindingChain via NamedImportMap) * 2a. Import-scoped (iterate importedFiles with lookupExactAll per file) * 2b. Package-scoped (iterate indexed files matching package dir with lookupExactAll) - * 3. Global (lookupClassByName + lookupImplByName + lookupFuzzyCallable — consumers must check count) + * 3. Global (lookupClassByName + lookupImplByName + lookupCallableByName — consumers must check count) * - * SM-16: resolveUncached no longer calls lookupFuzzy. Each tier queries the - * minimum necessary scope directly: + * Each tier queries the minimum necessary scope directly: * - Tier 2a iterates the caller's import set (O(imports) × O(1) lookupExactAll). * - Tier 2b iterates all indexed files filtered by package dir * (O(files) × O(1) lookupExactAll — avoids a global name scan). - * - Tier 3 combines lookupClassByName + lookupImplByName + lookupFuzzyCallable - * (three O(1) index lookups vs one O(1) lookupFuzzy, with a narrower result set). + * - Tier 3 combines lookupClassByName + lookupImplByName + lookupCallableByName + * (three O(1) index lookups with a narrow, type-specific result set). */ import type { SymbolTable, SymbolDefinition } from './symbol-table.js'; @@ -76,11 +75,12 @@ export interface ResolutionContext { // --- Operational --- getStats(): { fileCount: number; - globalSymbolCount: number; - fuzzyCallCount: number; - fuzzyCallableCallCount: number; cacheHits: number; cacheMisses: number; + tierSameFile: number; + tierImportScoped: number; + tierGlobal: number; + tierMiss: number; }; clear(): void; } @@ -104,6 +104,11 @@ export const createResolutionContext = (): ResolutionContext => { let cache: Map | null = null; let cacheHits = 0; let cacheMisses = 0; + // Tier hit counters — replaces the lost fuzzyCallCount diagnostic + let tierSameFile = 0; + let tierImportScoped = 0; + let tierGlobal = 0; + let tierMiss = 0; // --- Core resolution (single implementation of tier logic) --- @@ -111,6 +116,7 @@ export const createResolutionContext = (): ResolutionContext => { // Tier 1: Same file — authoritative match (returns all overloads) const localDefs = symbols.lookupExactAll(fromFile, name); if (localDefs.length > 0) { + tierSameFile++; return { candidates: localDefs, tier: 'same-file' }; } @@ -119,6 +125,7 @@ export const createResolutionContext = (): ResolutionContext => { // correctly even when lookupExactAll on the alias name returns nothing. const chainResult = walkBindingChain(name, fromFile, symbols, namedImportMap); if (chainResult && chainResult.length > 0) { + tierImportScoped++; return { candidates: chainResult, tier: 'import-scoped' }; } @@ -131,6 +138,7 @@ export const createResolutionContext = (): ResolutionContext => { importedDefs.push(...symbols.lookupExactAll(file, name)); } if (importedDefs.length > 0) { + tierImportScoped++; return { candidates: importedDefs, tier: 'import-scoped' }; } } @@ -178,37 +186,36 @@ export const createResolutionContext = (): ResolutionContext => { } } if (packageDefs.length > 0) { + tierImportScoped++; return { candidates: packageDefs, tier: 'import-scoped' }; } } - // Tier 3: Global — three targeted O(1) index lookups replace the single - // lookupFuzzy global scan. Class-like symbols (Class, Struct, Interface, - // Enum, Record, Trait) are covered by lookupClassByName; Rust impl blocks - // by lookupImplByName (separate to avoid polluting heritage resolution); - // callables (Function, Method, Constructor) by lookupFuzzyCallable. + // Tier 3: Global — targeted O(1) index lookups for each symbol category. + // Class-like symbols (Class, Struct, Interface, Enum, Record, Trait) are + // covered by lookupClassByName; Rust impl blocks by lookupImplByName + // (separate to avoid polluting heritage resolution); callables (Function, + // Method, Constructor, Macro, Delegate) by lookupCallableByName. // The three indexes cover disjoint symbol types so no dedup is needed. // Consumers must check candidates.length and refuse ambiguous matches. // // Known exclusion: TypeAlias, Const, and Variable are NOT reachable at - // Tier 3 — they don't belong to any of the three indexes. The old - // lookupFuzzy returned them, but in practice they were never useful as - // Tier 3 candidates: TypeAlias is not a call target, Const/Variable - // are resolved via import or same-file tiers. If a future language - // needs them at Tier 3, add a dedicated index. - // Macro (C/C++) and Delegate (C#) ARE included in callableIndex + // Tier 3 — they don't belong to any of the three indexes. In practice + // they were never useful as Tier 3 candidates: TypeAlias is not a call + // target, Const/Variable are resolved via import or same-file tiers. + // If a future language needs them at Tier 3, add a dedicated index. + // Macro (C/C++) and Delegate (C#) ARE included in the callable index // since call-processor.ts treats them as callable targets. - // - // Note: lookupFuzzy is still called directly in call-processor.ts - // (D2 module-alias widen path at ~line 1506/1588). Those callers - // bypass resolveUncached entirely and are tracked for separate removal - // in the roadmap. fuzzyCallCount only reflects resolveUncached usage. const classDefs = symbols.lookupClassByName(name); const implDefs = symbols.lookupImplByName(name); - const callableDefs = symbols.lookupFuzzyCallable(name); + const callableDefs = symbols.lookupCallableByName(name); - if (classDefs.length === 0 && implDefs.length === 0 && callableDefs.length === 0) return null; + if (classDefs.length === 0 && implDefs.length === 0 && callableDefs.length === 0) { + tierMiss++; + return null; + } const globalDefs = [...classDefs, ...implDefs, ...callableDefs]; + tierGlobal++; return { candidates: globalDefs, tier: 'global' }; }; @@ -257,6 +264,10 @@ export const createResolutionContext = (): ResolutionContext => { ...symbols.getStats(), cacheHits, cacheMisses, + tierSameFile, + tierImportScoped, + tierGlobal, + tierMiss, }); const clear = (): void => { @@ -269,6 +280,10 @@ export const createResolutionContext = (): ResolutionContext => { clearCache(); cacheHits = 0; cacheMisses = 0; + tierSameFile = 0; + tierImportScoped = 0; + tierGlobal = 0; + tierMiss = 0; }; return { diff --git a/gitnexus/src/core/ingestion/symbol-table.ts b/gitnexus/src/core/ingestion/symbol-table.ts index 451a213b3..eb7a62079 100644 --- a/gitnexus/src/core/ingestion/symbol-table.ts +++ b/gitnexus/src/core/ingestion/symbol-table.ts @@ -14,6 +14,17 @@ export const CLASS_TYPES = new Set([ 'Trait', ]); +/** Callable symbol types indexed in callableByName for Tier 3 resolution + * and D2 widen in call-processor.ts. Single source of truth — do not + * duplicate this set elsewhere. */ +export const CALLABLE_TYPES = new Set([ + 'Function', + 'Method', + 'Constructor', + 'Macro', // C/C++ + 'Delegate', // C# +]); + export interface SymbolDefinition { nodeId: string; filePath: string; @@ -79,17 +90,11 @@ export interface SymbolTable { lookupExactAll: (filePath: string, name: string) => SymbolDefinition[]; /** - * Low Confidence: Look for a symbol anywhere in the project - * Used when imports are missing or for framework magic + * Look up callable symbols (Function, Method, Constructor, Macro, Delegate) by name. + * O(1) via dedicated eagerly-populated index keyed by symbol name. + * Used by Tier 3 resolution and ReturnTypeLookup to resolve callee → return type. */ - lookupFuzzy: (name: string) => SymbolDefinition[]; - - /** - * Low Confidence: Look for callable symbols (Function/Method/Constructor) by name. - * Faster than `lookupFuzzy` + filter — backed by a lazy callable-only index. - * Used by ReturnTypeLookup to resolve callee → return type. - */ - lookupFuzzyCallable: (name: string) => SymbolDefinition[]; + lookupCallableByName: (name: string) => SymbolDefinition[]; /** * Look up a field/property by its owning class nodeId and field name. @@ -128,7 +133,7 @@ export interface SymbolTable { * Look up class-like definitions (Class, Struct, Interface, Enum, Record) by name. * O(1) via dedicated eagerly-populated index keyed by symbol name. * Returns all matching definitions across files (e.g. partial classes). - * Used by Phase 1 semantic-model tasks to replace filtered lookupFuzzy calls. + * Used by Phase 1 semantic-model tasks to replace filtered global lookups. */ lookupClassByName: (name: string) => SymbolDefinition[]; @@ -161,9 +166,6 @@ export interface SymbolTable { */ getStats: () => { fileCount: number; - globalSymbolCount: number; - fuzzyCallCount: number; - fuzzyCallableCallCount: number; }; /** @@ -178,40 +180,31 @@ export const createSymbolTable = (): SymbolTable => { // Array allows overloaded methods (same name, different signatures) to coexist. const fileIndex = new Map>(); - // 2. Global Reverse Index (The "Backup") - // Structure: SymbolName -> [List of Definitions] - const globalIndex = new Map(); - - // 3. Eagerly-populated Callable Index — maintained on add(). + // 2. Eagerly-populated Callable Index — maintained on add(). // Structure: SymbolName -> [Callable Definitions] - // Only Function, Method, Constructor symbols are indexed. - const callableIndex = new Map(); + // Only Function, Method, Constructor, Macro, Delegate symbols are indexed. + const callableByName = new Map(); - // 4. Eagerly-populated Field/Property Index — keyed by "ownerNodeId\0fieldName". + // 3. Eagerly-populated Field/Property Index — keyed by "ownerNodeId\0fieldName". // Only Property symbols with ownerId and declaredType are indexed. const fieldByOwner = new Map(); - // 5. Eagerly-populated Method Index — keyed by "ownerNodeId\0methodName". + // 4. Eagerly-populated Method Index — keyed by "ownerNodeId\0methodName". // Method symbols with ownerId are indexed. Supports overloads (array values). const methodByOwner = new Map(); - // 6. Eagerly-populated Class-type Index — keyed by symbol name. + // 5. Eagerly-populated Class-type Index — keyed by symbol name. // Only Class, Struct, Interface, Enum, Record symbols are indexed. const classByName = new Map(); const classByQualifiedName = new Map(); - // 7. Eagerly-populated Impl Index — keyed by symbol name. + // 6. Eagerly-populated Impl Index — keyed by symbol name. // Rust impl blocks (type 'Impl') are stored here to keep them out of // classByName (which drives heritage resolution) while still being // reachable from Tier 3 resolution for method lookup. const implByName = new Map(); - let fuzzyCallCount = 0; - let fuzzyCallableCallCount = 0; - - // Must match CALLABLE_SYMBOL_TYPES in call-processor.ts — Macro (C/C++) - // and Delegate (C#) are callable targets that Tier 3 must surface. - const CALLABLE_TYPES = new Set(['Function', 'Method', 'Constructor', 'Macro', 'Delegate']); + // Use the module-level CALLABLE_TYPES constant (exported for call-processor.ts). const add = ( filePath: string, @@ -261,31 +254,25 @@ export const createSymbolTable = (): SymbolTable => { fileMap.get(name)!.push(def); } - // B. Properties go to fieldByOwner index only — skip globalIndex to prevent + // B. Properties go to fieldByOwner index only — skip other indexes to prevent // namespace pollution for common names like 'id', 'name', 'type'. // Index ALL properties (even without declaredType) so write-access tracking // can resolve field ownership for dynamically-typed languages (Ruby, JS). if (type === 'Property' && metadata?.ownerId) { fieldByOwner.set(`${metadata.ownerId}\0${name}`, def); - // Still add to fileIndex above (for lookupExact), but skip globalIndex + // Still add to fileIndex above (for lookupExact), but skip other indexes return; } - // C. Add to Global Index (same object reference) - if (!globalIndex.has(name)) { - globalIndex.set(name, []); - } - globalIndex.get(name)!.push(def); - - // C2. Methods, constructors, and ownerId-bound Functions go to - // methodByOwner index (in addition to globalIndex). + // C. Methods, constructors, and ownerId-bound Functions go to + // methodByOwner index. // // 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. + // instead of silently falling through to D1-D4 widening. if ((type === 'Method' || type === 'Constructor' || type === 'Function') && metadata?.ownerId) { const key = `${metadata.ownerId}\0${name}`; const existing = methodByOwner.get(key); @@ -296,7 +283,7 @@ export const createSymbolTable = (): SymbolTable => { } } - // C3. Class-like types go to classByName index (in addition to globalIndex). + // C2. Class-like types go to classByName index. if (CLASS_TYPES.has(type)) { const existing = classByName.get(name); if (existing) { @@ -314,7 +301,7 @@ export const createSymbolTable = (): SymbolTable => { } } - // C4. Rust Impl blocks go to implByName (separate from classByName to avoid + // C3. Rust Impl blocks go to implByName (separate from classByName to avoid // polluting heritage resolution with Impl nodes as parent candidates). if (type === 'Impl') { const existing = implByName.get(name); @@ -327,11 +314,11 @@ export const createSymbolTable = (): SymbolTable => { // D. Eagerly maintain callable index (like classByName, implByName). if (CALLABLE_TYPES.has(type)) { - const existing = callableIndex.get(name); + const existing = callableByName.get(name); if (existing) { existing.push(def); } else { - callableIndex.set(name, [def]); + callableByName.set(name, [def]); } } }; @@ -350,14 +337,8 @@ export const createSymbolTable = (): SymbolTable => { return fileIndex.get(filePath)?.get(name) ?? []; }; - const lookupFuzzy = (name: string): SymbolDefinition[] => { - fuzzyCallCount++; - return globalIndex.get(name) || []; - }; - - const lookupFuzzyCallable = (name: string): SymbolDefinition[] => { - fuzzyCallableCallCount++; - return callableIndex.get(name) ?? []; + const lookupCallableByName = (name: string): SymbolDefinition[] => { + return callableByName.get(name) ?? []; }; const lookupFieldByOwner = ( @@ -428,22 +409,16 @@ export const createSymbolTable = (): SymbolTable => { const getStats = () => ({ fileCount: fileIndex.size, - globalSymbolCount: globalIndex.size, - fuzzyCallableCallCount: fuzzyCallableCallCount, - fuzzyCallCount: fuzzyCallCount, }); const clear = () => { fileIndex.clear(); - globalIndex.clear(); - callableIndex.clear(); + callableByName.clear(); fieldByOwner.clear(); methodByOwner.clear(); classByName.clear(); classByQualifiedName.clear(); implByName.clear(); - fuzzyCallCount = 0; - fuzzyCallableCallCount = 0; }; return { @@ -451,8 +426,7 @@ export const createSymbolTable = (): SymbolTable => { lookupExact, lookupExactFull, lookupExactAll, - lookupFuzzy, - lookupFuzzyCallable, + lookupCallableByName, lookupFieldByOwner, lookupMethodByOwner, lookupClassByName, diff --git a/gitnexus/src/core/ingestion/type-env.ts b/gitnexus/src/core/ingestion/type-env.ts index 2b093fb14..bac187b26 100644 --- a/gitnexus/src/core/ingestion/type-env.ts +++ b/gitnexus/src/core/ingestion/type-env.ts @@ -861,7 +861,7 @@ export const buildTypeEnv = ( // SymbolTable is authoritative when it has an unambiguous match if (symbolTable) { if (provider.isBuiltInName(callee)) return undefined; - const callables = symbolTable.lookupFuzzyCallable(callee); + const callables = symbolTable.lookupCallableByName(callee); if (callables.length === 1) { const rawReturn = callables[0].returnType; if (rawReturn) return extractReturnTypeName(rawReturn); @@ -875,7 +875,7 @@ export const buildTypeEnv = ( lookupRawReturnType(callee: string): string | undefined { if (symbolTable) { if (provider.isBuiltInName(callee)) return undefined; - const callables = symbolTable.lookupFuzzyCallable(callee); + const callables = symbolTable.lookupCallableByName(callee); if (callables.length === 1) return callables[0].returnType; // Ambiguous (2+) → return undefined (conservative, no cross-file fallback) if (callables.length > 1) return undefined; diff --git a/gitnexus/src/core/ingestion/type-extractors/types.ts b/gitnexus/src/core/ingestion/type-extractors/types.ts index 85da9d21a..d6b477978 100644 --- a/gitnexus/src/core/ingestion/type-extractors/types.ts +++ b/gitnexus/src/core/ingestion/type-extractors/types.ts @@ -54,7 +54,7 @@ export type DeclaredTypeUnwrapper = ( ) => string | undefined; /** Narrow lookup interface for resolving a callee name → return type name. - * Backed by SymbolTable.lookupFuzzyCallable; passed via ForLoopExtractorContext. + * Backed by SymbolTable.lookupCallableByName; passed via ForLoopExtractorContext. * Conservative: returns undefined when the callee is ambiguous (0 or 2+ matches). */ export interface ReturnTypeLookup { /** Processed type name after stripping wrappers (e.g., 'User' from 'Promise'). diff --git a/gitnexus/test/unit/call-form.test.ts b/gitnexus/test/unit/call-form.test.ts index 41ed345ce..17e42897e 100644 --- a/gitnexus/test/unit/call-form.test.ts +++ b/gitnexus/test/unit/call-form.test.ts @@ -452,13 +452,13 @@ describe('ownerId on SymbolDefinition', () => { expect(def!.ownerId).toBeUndefined(); }); - it('propagates ownerId through lookupFuzzy', () => { + it('propagates ownerId through lookupCallableByName', () => { const st = createSymbolTable(); st.add('src/foo.ts', 'save', 'Method:src/foo.ts:save', 'Method', { ownerId: 'Class:src/foo.ts:User', }); - const defs = st.lookupFuzzy('save'); + const defs = st.lookupCallableByName('save'); expect(defs).toHaveLength(1); expect(defs[0].ownerId).toBe('Class:src/foo.ts:User'); }); diff --git a/gitnexus/test/unit/call-processor.test.ts b/gitnexus/test/unit/call-processor.test.ts index a0754082d..a12dd0528 100644 --- a/gitnexus/test/unit/call-processor.test.ts +++ b/gitnexus/test/unit/call-processor.test.ts @@ -2218,7 +2218,7 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { // D0: lookupMethodByOwner(classId, 'doWork') → undefined // heritageMap.getAncestors(classId) → [] // lookupMethodByOwnerWithMRO returns undefined → D0 miss - // D1-D4: receiver type resolves to Obj; D2 widens via lookupFuzzy; + // D1-D4: receiver type resolves to Obj; D2 widens via lookupCallableByName; // 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'; @@ -2550,3 +2550,44 @@ describe('processAssignmentsFromExtracted', () => { expect(accesses[0].targetId).toBe('Property:src/models.ts:address'); }); }); + +// ---- D2 widen: module-alias + lookupCallableByName resolves method in aliased file ---- + +describe('D2 widen path: lookupCallableByName via module alias', () => { + let graph: ReturnType; + let ctx: ResolutionContext; + + beforeEach(() => { + graph = createKnowledgeGraph(); + ctx = createResolutionContext(); + }); + + it('resolves method via module alias widen using lookupCallableByName', async () => { + // Python pattern: `import auth; auth.login()` — auth is a module alias + // pointing to auth.py. login() is defined only in auth.py (not imported + // by consumer.py). The D2 widen path should find login via the global + // callable index filtered to the aliased module file. + ctx.symbols.add('src/auth.py', 'login', 'Function:src/auth.py:login', 'Function'); + // Consumer has a same-file function that shadows 'login' at Tier 1 + ctx.symbols.add('src/consumer.py', 'login', 'Function:src/consumer.py:login', 'Function'); + // Module alias: consumer.py → auth → src/auth.py + ctx.moduleAliasMap.set('src/consumer.py', new Map([['auth', 'src/auth.py']])); + + const calls: ExtractedCall[] = [ + { + filePath: 'src/consumer.py', + calledName: 'login', + sourceId: 'Function:src/consumer.py:main', + receiverName: 'auth', + callForm: 'member', + }, + ]; + + await processCallsFromExtracted(graph, calls, ctx); + + const rels = graph.relationships.filter((r) => r.type === 'CALLS'); + expect(rels).toHaveLength(1); + // Should resolve to auth.py's login, NOT consumer.py's same-file shadow + expect(rels[0].targetId).toBe('Function:src/auth.py:login'); + }); +}); diff --git a/gitnexus/test/unit/symbol-resolver.test.ts b/gitnexus/test/unit/symbol-resolver.test.ts index e338deb9f..67797e61f 100644 --- a/gitnexus/test/unit/symbol-resolver.test.ts +++ b/gitnexus/test/unit/symbol-resolver.test.ts @@ -417,16 +417,6 @@ describe('lookupExactFull', () => { expect(result).toBeUndefined(); }); - it('shares same object reference between fileIndex and globalIndex', () => { - const symbolTable = createSymbolTable(); - symbolTable.add('src/x.ts', 'Bar', 'Class:src/x.ts:Bar', 'Class'); - - const fromExact = symbolTable.lookupExactFull('src/x.ts', 'Bar'); - const fromFuzzy = symbolTable.lookupFuzzy('Bar')[0]; - - expect(fromExact).toBe(fromFuzzy); - }); - it('preserves optional callable metadata on stored definitions', () => { const symbolTable = createSymbolTable(); symbolTable.add('src/math.ts', 'sum', 'Function:src/math.ts:sum', 'Function', { @@ -434,11 +424,10 @@ describe('lookupExactFull', () => { }); const fromExact = symbolTable.lookupExactFull('src/math.ts', 'sum'); - const fromFuzzy = symbolTable.lookupFuzzy('sum')[0]; + const fromCallable = symbolTable.lookupCallableByName('sum')[0]; expect(fromExact?.parameterCount).toBe(2); - expect(fromFuzzy.parameterCount).toBe(2); - expect(fromExact).toBe(fromFuzzy); + expect(fromCallable.parameterCount).toBe(2); }); }); @@ -626,32 +615,6 @@ describe('per-file cache', () => { }); }); -// --------------------------------------------------------------------------- -// SM-16: resolveUncached no longer calls lookupFuzzy -// --------------------------------------------------------------------------- - -// Note: fuzzyCallCount tracks ALL lookupFuzzy calls on the SymbolTable, including -// the D2 module-alias widen path in call-processor.ts which still calls lookupFuzzy -// directly. This test only exercises resolveUncached (via ctx.resolve), so the stat -// is 0 here. In a full pipeline integration test, fuzzyCallCount would be non-zero -// due to D2 callers. -describe('SM-16: resolveUncached does not call lookupFuzzy', () => { - it('lookupFuzzy is never called during resolve — fuzzyCallCount stays at 0', () => { - const ctx = createResolutionContext(); - ctx.symbols.add('src/user.ts', 'User', 'Class:src/user.ts:User', 'Class'); - ctx.symbols.add('src/service.ts', 'UserService', 'Class:src/service.ts:UserService', 'Class'); - ctx.importMap.set('src/app.ts', new Set(['src/user.ts'])); - ctx.packageMap.set('cmd/main.go', new Set(['/internal/'])); - - // Exercise all tiers - ctx.resolve('User', 'src/user.ts'); // Tier 1 same-file - ctx.resolve('User', 'src/app.ts'); // Tier 2a import-scoped - ctx.resolve('UserService', 'src/other.ts'); // Tier 3 global - - expect(ctx.getStats().fuzzyCallCount).toBe(0); - }); -}); - // Tier 2a uses importMap (file-level imports). Go resolves cross-package symbols // via packageMap (Tier 2b) instead, so no Go Tier 2a test is needed. Kotlin and // PHP support file-level imports but the importMap path is language-agnostic — @@ -769,7 +732,7 @@ describe('SM-16: Tier 2b — iterate getFiles() + isFileInPackageDir', () => { ctx = createResolutionContext(); }); - it('Go: resolves symbol in package dir via file iteration (no lookupFuzzy)', () => { + it('Go: resolves symbol in package dir via file iteration', () => { ctx.symbols.add( 'internal/auth/handler.go', 'Authenticate', @@ -826,7 +789,7 @@ describe('SM-16: Tier 2b — iterate getFiles() + isFileInPackageDir', () => { }); }); -describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookupFuzzyCallable', () => { +describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookupCallableByName', () => { let ctx: ResolutionContext; beforeEach(() => { @@ -1048,29 +1011,19 @@ describe('SM-16: Tier 3 — TypeAlias, Const, Variable are NOT returned', () => }); it('TypeAlias is not reachable at Tier 3', () => { - ctx.symbols.add( - 'src/types.ts', - 'Handler', - 'TypeAlias:src/types.ts:Handler', - 'TypeAlias' as any, - ); + ctx.symbols.add('src/types.ts', 'Handler', 'TypeAlias:src/types.ts:Handler', 'TypeAlias'); const result = ctx.resolve('Handler', 'src/app.ts'); expect(result).toBeNull(); }); it('Const is not reachable at Tier 3', () => { - ctx.symbols.add( - 'src/config.ts', - 'MAX_RETRIES', - 'Const:src/config.ts:MAX_RETRIES', - 'Const' as any, - ); + ctx.symbols.add('src/config.ts', 'MAX_RETRIES', 'Const:src/config.ts:MAX_RETRIES', 'Const'); const result = ctx.resolve('MAX_RETRIES', 'src/app.ts'); expect(result).toBeNull(); }); it('Variable is not reachable at Tier 3', () => { - ctx.symbols.add('src/state.ts', 'counter', 'Variable:src/state.ts:counter', 'Variable' as any); + ctx.symbols.add('src/state.ts', 'counter', 'Variable:src/state.ts:counter', 'Variable'); const result = ctx.resolve('counter', 'src/app.ts'); expect(result).toBeNull(); }); @@ -1088,21 +1041,16 @@ describe('SM-16: Tier 3 — TypeAlias, Const, Variable are NOT returned', () => expect(funcResult!.tier).toBe('global'); }); - it('Macro (C/C++) is reachable at Tier 3 via callableIndex', () => { - ctx.symbols.add('src/macros.h', 'ASSERT', 'Macro:src/macros.h:ASSERT', 'Macro' as any); + it('Macro (C/C++) is reachable at Tier 3 via callable index', () => { + ctx.symbols.add('src/macros.h', 'ASSERT', 'Macro:src/macros.h:ASSERT', 'Macro'); const result = ctx.resolve('ASSERT', 'src/main.c'); expect(result).not.toBeNull(); expect(result!.tier).toBe('global'); expect(result!.candidates[0].type).toBe('Macro'); }); - it('Delegate (C#) is reachable at Tier 3 via callableIndex', () => { - ctx.symbols.add( - 'src/Events.cs', - 'OnClick', - 'Delegate:src/Events.cs:OnClick', - 'Delegate' as any, - ); + it('Delegate (C#) is reachable at Tier 3 via callable index', () => { + ctx.symbols.add('src/Events.cs', 'OnClick', 'Delegate:src/Events.cs:OnClick', 'Delegate'); const result = ctx.resolve('OnClick', 'src/App.cs'); expect(result).not.toBeNull(); expect(result!.tier).toBe('global'); diff --git a/gitnexus/test/unit/symbol-table.test.ts b/gitnexus/test/unit/symbol-table.test.ts index 9378a6008..4763adfcc 100644 --- a/gitnexus/test/unit/symbol-table.test.ts +++ b/gitnexus/test/unit/symbol-table.test.ts @@ -11,7 +11,6 @@ describe('SymbolTable', () => { describe('add', () => { it('registers a symbol in the table', () => { table.add('src/index.ts', 'main', 'func:main', 'Function'); - expect(table.getStats().globalSymbolCount).toBe(1); expect(table.getStats().fileCount).toBe(1); }); @@ -19,15 +18,12 @@ describe('SymbolTable', () => { table.add('src/index.ts', 'main', 'func:main', 'Function'); table.add('src/index.ts', 'helper', 'func:helper', 'Function'); expect(table.getStats().fileCount).toBe(1); - expect(table.getStats().globalSymbolCount).toBe(2); }); it('handles same name in different files', () => { table.add('src/a.ts', 'init', 'func:a:init', 'Function'); table.add('src/b.ts', 'init', 'func:b:init', 'Function'); expect(table.getStats().fileCount).toBe(2); - // Global index groups by name, so 'init' has one entry with two definitions - expect(table.getStats().globalSymbolCount).toBe(1); }); it('allows duplicate adds for same file and name (overloads preserved)', () => { @@ -37,8 +33,6 @@ describe('SymbolTable', () => { expect(table.lookupExact('src/a.ts', 'foo')).toBe('func:foo:1'); // lookupExactAll returns all overloads expect(table.lookupExactAll('src/a.ts', 'foo')).toHaveLength(2); - // Global index appends - expect(table.lookupFuzzy('foo')).toHaveLength(2); }); }); @@ -63,36 +57,10 @@ describe('SymbolTable', () => { }); }); - describe('lookupFuzzy', () => { - it('finds all definitions of a symbol across files', () => { - table.add('src/a.ts', 'render', 'func:a:render', 'Function'); - table.add('src/b.ts', 'render', 'func:b:render', 'Method'); - const results = table.lookupFuzzy('render'); - expect(results).toHaveLength(2); - expect(results[0]).toEqual({ - nodeId: 'func:a:render', - filePath: 'src/a.ts', - type: 'Function', - }); - expect(results[1]).toEqual({ nodeId: 'func:b:render', filePath: 'src/b.ts', type: 'Method' }); - }); - - it('returns empty array for unknown symbol', () => { - expect(table.lookupFuzzy('nonexistent')).toEqual([]); - }); - - it('returns empty array for empty table', () => { - expect(table.lookupFuzzy('anything')).toEqual([]); - }); - }); - describe('getStats', () => { it('returns zero counts for empty table', () => { expect(table.getStats()).toEqual({ fileCount: 0, - globalSymbolCount: 0, - fuzzyCallCount: 0, - fuzzyCallableCallCount: 0, }); }); @@ -102,14 +70,6 @@ describe('SymbolTable', () => { table.add('src/b.ts', 'baz', 'func:baz', 'Function'); expect(table.getStats().fileCount).toBe(2); }); - - it('tracks unique global symbol names', () => { - table.add('src/a.ts', 'foo', 'func:a:foo', 'Function'); - table.add('src/b.ts', 'foo', 'func:b:foo', 'Function'); - table.add('src/a.ts', 'bar', 'func:a:bar', 'Function'); - // 'foo' and 'bar' are 2 unique global names - expect(table.getStats().globalSymbolCount).toBe(2); - }); }); describe('returnType metadata', () => { @@ -120,13 +80,13 @@ describe('SymbolTable', () => { expect(def!.returnType).toBe('User'); }); - it('returnType is available via lookupFuzzy', () => { + it('returnType is available via lookupExactFull', () => { table.add('src/utils.ts', 'getUser', 'func:getUser', 'Function', { returnType: 'Promise', }); - const results = table.lookupFuzzy('getUser'); - expect(results).toHaveLength(1); - expect(results[0].returnType).toBe('Promise'); + const result = table.lookupExactFull('src/utils.ts', 'getUser'); + expect(result).toBeDefined(); + expect(result!.returnType).toBe('Promise'); }); it('omits returnType when not provided', () => { @@ -169,28 +129,28 @@ describe('SymbolTable', () => { }); }); - describe('Property exclusion from globalIndex', () => { - it('Property with ownerId is NOT added to globalIndex', () => { + describe('Property exclusion from callable index', () => { + it('Property with ownerId is NOT in callable index', () => { table.add('src/models.ts', 'name', 'prop:name', 'Property', { declaredType: 'string', ownerId: 'class:User', }); - // Should not appear in fuzzy lookup - expect(table.lookupFuzzy('name')).toEqual([]); + // Should not appear in callable lookup + expect(table.lookupCallableByName('name')).toEqual([]); // But should still be in fileIndex expect(table.lookupExact('src/models.ts', 'name')).toBe('prop:name'); }); - it('Property without ownerId IS added to globalIndex', () => { + it('Property without ownerId is NOT in callable index', () => { table.add('src/models.ts', 'name', 'prop:name', 'Property'); - expect(table.lookupFuzzy('name')).toHaveLength(1); + expect(table.lookupCallableByName('name')).toEqual([]); }); - it('Property without declaredType is still added to fieldByOwner index only (not globalIndex)', () => { + it('Property without declaredType is still added to fieldByOwner index only', () => { table.add('src/models.ts', 'name', 'prop:name', 'Property', { ownerId: 'class:User' }); // No declaredType → still indexed in fieldByOwner (for write-access tracking - // in dynamically-typed languages like Ruby/JS), but excluded from globalIndex - expect(table.lookupFuzzy('name')).toEqual([]); + // in dynamically-typed languages like Ruby/JS), but excluded from callable index + expect(table.lookupCallableByName('name')).toEqual([]); expect(table.lookupFieldByOwner('class:User', 'name')).toEqual({ nodeId: 'prop:name', filePath: 'src/models.ts', @@ -199,40 +159,50 @@ describe('SymbolTable', () => { }); }); - it('non-Property types are always added to globalIndex', () => { + it('non-Property callable types are in callable index', () => { table.add('src/models.ts', 'save', 'method:save', 'Method', { ownerId: 'class:User' }); - expect(table.lookupFuzzy('save')).toHaveLength(1); + expect(table.lookupCallableByName('save')).toHaveLength(1); }); }); - describe('conditional callableIndex invalidation', () => { - it('adding a Function invalidates callableIndex', () => { + describe('conditional callable index behaviour', () => { + it('adding a Function makes it available in callable index', () => { table.add('src/a.ts', 'foo', 'func:foo', 'Function', { returnType: 'void' }); - // First call builds the index - expect(table.lookupFuzzyCallable('foo')).toHaveLength(1); - // Add another callable — should invalidate and rebuild + expect(table.lookupCallableByName('foo')).toHaveLength(1); + // Add another callable table.add('src/a.ts', 'bar', 'func:bar', 'Method'); - expect(table.lookupFuzzyCallable('bar')).toHaveLength(1); + expect(table.lookupCallableByName('bar')).toHaveLength(1); }); - it('adding a Property does NOT invalidate callableIndex', () => { + it('adding a Property does NOT add it to callable index', () => { table.add('src/a.ts', 'foo', 'func:foo', 'Function'); - // Build callable index - expect(table.lookupFuzzyCallable('foo')).toHaveLength(1); - // Add a Property — callable index should still be valid (foo still found) + expect(table.lookupCallableByName('foo')).toHaveLength(1); + // Add a Property — callable index should still only contain foo table.add('src/models.ts', 'name', 'prop:name', 'Property', { declaredType: 'string', ownerId: 'class:User', }); - expect(table.lookupFuzzyCallable('foo')).toHaveLength(1); + expect(table.lookupCallableByName('foo')).toHaveLength(1); }); - it('adding a Class does NOT invalidate callableIndex', () => { + it('adding a Class does NOT add it to callable index', () => { table.add('src/a.ts', 'foo', 'func:foo', 'Function'); - expect(table.lookupFuzzyCallable('foo')).toHaveLength(1); + expect(table.lookupCallableByName('foo')).toHaveLength(1); table.add('src/models.ts', 'User', 'class:User', 'Class'); - // Class is not callable, should not trigger rebuild - expect(table.lookupFuzzyCallable('foo')).toHaveLength(1); + // Class is not callable, should not appear + expect(table.lookupCallableByName('foo')).toHaveLength(1); + }); + + it('Macro (C/C++) is indexed in callable index', () => { + table.add('src/macros.h', 'ASSERT', 'macro:ASSERT', 'Macro'); + expect(table.lookupCallableByName('ASSERT')).toHaveLength(1); + expect(table.lookupCallableByName('ASSERT')[0].type).toBe('Macro'); + }); + + it('Delegate (C#) is indexed in callable index', () => { + table.add('src/Events.cs', 'OnClick', 'delegate:OnClick', 'Delegate'); + expect(table.lookupCallableByName('OnClick')).toHaveLength(1); + expect(table.lookupCallableByName('OnClick')[0].type).toBe('Delegate'); }); }); @@ -355,8 +325,8 @@ describe('SymbolTable', () => { it('does NOT index Method without ownerId', () => { table.add('src/utils.ts', 'helper', 'method:helper', 'Method'); expect(table.lookupMethodByOwner('', 'helper')).toBeUndefined(); - // But it should still be in lookupFuzzy - expect(table.lookupFuzzy('helper')).toHaveLength(1); + // But it should still be in lookupCallableByName + expect(table.lookupCallableByName('helper')).toHaveLength(1); }); it('returns first match for overloads with same returnType (unambiguous)', () => { @@ -400,8 +370,8 @@ describe('SymbolTable', () => { parameterCount: 0, ownerId: 'class:User', }); - // But it should be in lookupFuzzyCallable - expect(table.lookupFuzzyCallable('User')).toHaveLength(1); + // But it should be in lookupCallableByName + expect(table.lookupCallableByName('User')).toHaveLength(1); }); it('returns undefined for overloads with different returnTypes (ambiguous)', () => { @@ -418,14 +388,12 @@ describe('SymbolTable', () => { expect(table.lookupMethodByOwner('class:Converter', 'convert')).toBeUndefined(); }); - it('Method with ownerId is still available via lookupFuzzy and lookupFuzzyCallable', () => { + it('Method with ownerId is still available via lookupCallableByName', () => { table.add('src/models.ts', 'save', 'method:save', 'Method', { returnType: 'void', ownerId: 'class:User', }); - // Methods stay in globalIndex (unlike Properties) - expect(table.lookupFuzzy('save')).toHaveLength(1); - expect(table.lookupFuzzyCallable('save')).toHaveLength(1); + expect(table.lookupCallableByName('save')).toHaveLength(1); }); it('after clear(), lookupMethodByOwner returns undefined', () => { @@ -439,37 +407,37 @@ describe('SymbolTable', () => { }); }); - describe('lookupFuzzyCallable', () => { + describe('lookupCallableByName', () => { it('returns only callable types (Function, Method, Constructor)', () => { table.add('src/a.ts', 'foo', 'func:foo', 'Function'); table.add('src/a.ts', 'bar', 'method:bar', 'Method'); table.add('src/a.ts', 'Baz', 'ctor:Baz', 'Constructor'); table.add('src/a.ts', 'User', 'class:User', 'Class'); table.add('src/a.ts', 'IUser', 'iface:IUser', 'Interface'); - expect(table.lookupFuzzyCallable('foo')).toHaveLength(1); - expect(table.lookupFuzzyCallable('bar')).toHaveLength(1); - expect(table.lookupFuzzyCallable('Baz')).toHaveLength(1); - expect(table.lookupFuzzyCallable('User')).toEqual([]); - expect(table.lookupFuzzyCallable('IUser')).toEqual([]); + expect(table.lookupCallableByName('foo')).toHaveLength(1); + expect(table.lookupCallableByName('bar')).toHaveLength(1); + expect(table.lookupCallableByName('Baz')).toHaveLength(1); + expect(table.lookupCallableByName('User')).toEqual([]); + expect(table.lookupCallableByName('IUser')).toEqual([]); }); it('returns empty array for unknown name', () => { table.add('src/a.ts', 'foo', 'func:foo', 'Function'); - expect(table.lookupFuzzyCallable('unknown')).toEqual([]); + expect(table.lookupCallableByName('unknown')).toEqual([]); }); - it('rebuilds index after adding new callable', () => { + it('includes newly added callable', () => { table.add('src/a.ts', 'foo', 'func:foo', 'Function'); - expect(table.lookupFuzzyCallable('foo')).toHaveLength(1); - expect(table.lookupFuzzyCallable('bar')).toEqual([]); + expect(table.lookupCallableByName('foo')).toHaveLength(1); + expect(table.lookupCallableByName('bar')).toEqual([]); table.add('src/a.ts', 'bar', 'func:bar', 'Function'); - expect(table.lookupFuzzyCallable('bar')).toHaveLength(1); + expect(table.lookupCallableByName('bar')).toHaveLength(1); }); it('filters non-callable types from mixed name entries', () => { table.add('src/a.ts', 'save', 'func:save', 'Function'); table.add('src/b.ts', 'save', 'class:save', 'Class'); - const callables = table.lookupFuzzyCallable('save'); + const callables = table.lookupCallableByName('save'); expect(callables).toHaveLength(1); expect(callables[0].type).toBe('Function'); }); @@ -491,15 +459,11 @@ describe('SymbolTable', () => { table.clear(); expect(table.getStats()).toEqual({ fileCount: 0, - globalSymbolCount: 0, - fuzzyCallCount: 0, - fuzzyCallableCallCount: 0, }); expect(table.lookupExact('src/a.ts', 'foo')).toBeUndefined(); - expect(table.lookupFuzzy('foo')).toEqual([]); expect(table.lookupFieldByOwner('class:User', 'address')).toBeUndefined(); expect(table.lookupMethodByOwner('class:User', 'save')).toBeUndefined(); - expect(table.lookupFuzzyCallable('foo')).toEqual([]); + expect(table.lookupCallableByName('foo')).toEqual([]); expect(table.lookupClassByName('User')).toEqual([]); }); @@ -509,23 +473,20 @@ describe('SymbolTable', () => { table.add('src/b.ts', 'bar', 'func:bar', 'Function'); expect(table.getStats()).toEqual({ fileCount: 1, - globalSymbolCount: 1, - fuzzyCallCount: 0, - fuzzyCallableCallCount: 0, }); }); - it('resets callableIndex so first lookup after clear rebuilds from scratch', () => { + it('resets callable index so first lookup after clear rebuilds from scratch', () => { table.add('src/a.ts', 'foo', 'func:foo', 'Function'); - // Populate the lazy callable index - expect(table.lookupFuzzyCallable('foo')).toHaveLength(1); + // Verify callable is found + expect(table.lookupCallableByName('foo')).toHaveLength(1); table.clear(); // After clear the callable index must be gone — empty table returns nothing - expect(table.lookupFuzzyCallable('foo')).toEqual([]); - // Re-adding and looking up rebuilds successfully + expect(table.lookupCallableByName('foo')).toEqual([]); + // Re-adding and looking up works correctly table.add('src/a.ts', 'foo', 'func:foo2', 'Function'); - expect(table.lookupFuzzyCallable('foo')).toHaveLength(1); - expect(table.lookupFuzzyCallable('foo')[0].nodeId).toBe('func:foo2'); + expect(table.lookupCallableByName('foo')).toHaveLength(1); + expect(table.lookupCallableByName('foo')[0].nodeId).toBe('func:foo2'); }); }); @@ -540,7 +501,7 @@ describe('SymbolTable', () => { expect(def!.ownerId).toBeUndefined(); }); - it('stores only ownerId on a Method (non-Property) — still added to globalIndex', () => { + it('stores only ownerId on a Method (non-Property) — still in callable index', () => { table.add('src/models.ts', 'save', 'method:save', 'Method', { ownerId: 'class:Repo' }); const def = table.lookupExactFull('src/models.ts', 'save'); expect(def).toBeDefined(); @@ -548,12 +509,12 @@ describe('SymbolTable', () => { expect(def!.parameterCount).toBeUndefined(); expect(def!.returnType).toBeUndefined(); expect(def!.declaredType).toBeUndefined(); - // Non-Property with ownerId must still appear in globalIndex - expect(table.lookupFuzzy('save')).toHaveLength(1); + // Non-Property with ownerId must still appear in callable index + expect(table.lookupCallableByName('save')).toHaveLength(1); }); - it('stores declaredType alone (no ownerId) — symbol goes to globalIndex', () => { - // A Variable/Property without an owner should still be globally visible + it('stores declaredType alone (no ownerId) — symbol in file index', () => { + // A Variable/Property without an owner should still be accessible via file index table.add('src/config.ts', 'DEFAULT_TIMEOUT', 'var:DEFAULT_TIMEOUT', 'Variable', { declaredType: 'number', }); @@ -561,9 +522,6 @@ describe('SymbolTable', () => { expect(def).toBeDefined(); expect(def!.declaredType).toBe('number'); expect(def!.ownerId).toBeUndefined(); - // No ownerId → not a Property exclusion path → must be in globalIndex - expect(table.lookupFuzzy('DEFAULT_TIMEOUT')).toHaveLength(1); - expect(table.lookupFuzzy('DEFAULT_TIMEOUT')[0].declaredType).toBe('number'); }); it('stores all four optional metadata fields simultaneously on a Method', () => { @@ -600,46 +558,40 @@ describe('SymbolTable', () => { }); }); - describe('lookupFuzzyCallable — lazy index behaviour', () => { + describe('lookupCallableByName — eager index behavior', () => { it('returns empty array when table has no callables', () => { table.add('src/models.ts', 'User', 'class:User', 'Class'); table.add('src/models.ts', 'IUser', 'iface:IUser', 'Interface'); - expect(table.lookupFuzzyCallable('User')).toEqual([]); - expect(table.lookupFuzzyCallable('IUser')).toEqual([]); + expect(table.lookupCallableByName('User')).toEqual([]); + expect(table.lookupCallableByName('IUser')).toEqual([]); }); - it('uses cached index on second call without adding new symbols', () => { + it('returns consistent result on repeated calls', () => { table.add('src/a.ts', 'fetch', 'func:fetch', 'Function', { returnType: 'Response' }); - // First call — builds the lazy index - const first = table.lookupFuzzyCallable('fetch'); + const first = table.lookupCallableByName('fetch'); expect(first).toHaveLength(1); - // Second call — must return equivalent result from cache - const second = table.lookupFuzzyCallable('fetch'); + const second = table.lookupCallableByName('fetch'); expect(second).toHaveLength(1); expect(second[0].nodeId).toBe('func:fetch'); - // Both calls return the same array reference (same cache entry) - expect(first).toBe(second); }); - it('invalidated cache is rebuilt correctly after adding a Method', () => { + it('includes newly added Method', () => { table.add('src/a.ts', 'alpha', 'func:alpha', 'Function'); - // Warm the cache - expect(table.lookupFuzzyCallable('alpha')).toHaveLength(1); - expect(table.lookupFuzzyCallable('beta')).toEqual([]); - // Add a Method — must invalidate cache + expect(table.lookupCallableByName('alpha')).toHaveLength(1); + expect(table.lookupCallableByName('beta')).toEqual([]); + // Add a Method table.add('src/a.ts', 'beta', 'method:beta', 'Method'); - // Rebuilt cache must now include beta - const result = table.lookupFuzzyCallable('beta'); + const result = table.lookupCallableByName('beta'); expect(result).toHaveLength(1); expect(result[0].type).toBe('Method'); }); - it('invalidated cache is rebuilt correctly after adding a Constructor', () => { + it('includes newly added Constructor', () => { table.add('src/a.ts', 'existing', 'func:existing', 'Function'); - expect(table.lookupFuzzyCallable('existing')).toHaveLength(1); + expect(table.lookupCallableByName('existing')).toHaveLength(1); table.add('src/models.ts', 'MyClass', 'ctor:MyClass', 'Constructor'); - expect(table.lookupFuzzyCallable('MyClass')).toHaveLength(1); - expect(table.lookupFuzzyCallable('MyClass')[0].type).toBe('Constructor'); + expect(table.lookupCallableByName('MyClass')).toHaveLength(1); + expect(table.lookupCallableByName('MyClass')[0].type).toBe('Constructor'); }); }); @@ -859,10 +811,9 @@ describe('SymbolTable', () => { expect(results[0].ownerId).toBe('module:models'); }); - it('class-like symbols are still available via lookupFuzzy', () => { + it('class-like symbols are available via lookupClassByName', () => { table.add('src/models.ts', 'User', 'class:User', 'Class'); - // classByName is an additional index, not a replacement for globalIndex - expect(table.lookupFuzzy('User')).toHaveLength(1); + // classByName is the dedicated index for class-like lookups expect(table.lookupClassByName('User')).toHaveLength(1); }); diff --git a/gitnexus/test/unit/type-env.test.ts b/gitnexus/test/unit/type-env.test.ts index 97818819c..ce771b857 100644 --- a/gitnexus/test/unit/type-env.test.ts +++ b/gitnexus/test/unit/type-env.test.ts @@ -83,17 +83,15 @@ const createMockSymbolTable = (overrides: Partial = {}): SymbolTabl lookupExact: () => undefined, lookupExactFull: () => undefined, lookupExactAll: () => [], - lookupFuzzy: () => [], - lookupFuzzyCallable: () => [], + lookupCallableByName: () => [], lookupFieldByOwner: () => undefined, lookupMethodByOwner: () => undefined, lookupClassByName: () => [], lookupClassByQualifiedName: () => [], + lookupImplByName: () => [], + getFiles: () => [][Symbol.iterator](), getStats: () => ({ fileCount: 0, - globalSymbolCount: 0, - fuzzyCallCount: 0, - fuzzyCallableCallCount: 0, }), clear: () => {}, ...overrides, @@ -1195,7 +1193,7 @@ class RepoService { describe('destructured call results', () => { // Minimal mock SymbolTable for call-result return type lookup const makeSymbolTable = (callables: Array<{ name: string; returnType?: string }>) => ({ - lookupFuzzyCallable: (name: string) => + lookupCallableByName: (name: string) => callables .filter((c) => c.name === name) .map((c) => ({ @@ -1205,11 +1203,10 @@ class RepoService { returnType: c.returnType, })), lookupClassByName: () => [], - lookupFuzzy: () => [], lookupExact: () => undefined, lookupExactFull: () => undefined, add: () => {}, - getStats: () => ({ fileCount: 0, globalSymbolCount: 0 }), + getStats: () => ({ fileCount: 0 }), clear: () => {}, }); @@ -2055,7 +2052,7 @@ class RepoService { lookupExact: () => undefined, lookupExactFull: () => undefined, add: () => {}, - getStats: () => ({ fileCount: 0, globalSymbolCount: 0 }), + getStats: () => ({ fileCount: 0 }), clear: () => {}, }; const typeEnv = buildTypeEnv(tree, 'kotlin', { symbolTable: mockSymbolTable as any }); @@ -2073,14 +2070,12 @@ class RepoService { ); const mockSymbolTable = { lookupClassByName: () => [], - lookupFuzzy: (name: string) => - name === 'doStuff' ? [{ nodeId: 'n1', filePath: 'utils.kt', type: 'Function' }] : [], - lookupFuzzyCallable: () => [], + lookupCallableByName: () => [], lookupFieldByOwner: () => undefined, lookupExact: () => undefined, lookupExactFull: () => undefined, add: () => {}, - getStats: () => ({ fileCount: 0, globalSymbolCount: 0 }), + getStats: () => ({ fileCount: 0 }), clear: () => {}, }; const typeEnv = buildTypeEnv(tree, 'kotlin', { symbolTable: mockSymbolTable as any }); @@ -2460,7 +2455,7 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupFuzzyCallable = vi.fn(() => []); + const lookupCallableByName = vi.fn(() => []); const symbolTable = createMockSymbolTable({ lookupClassByName: (name: string) => name === 'Repo' ? [createClassDef('Repo', 'Class', 'models.ts')] : [], @@ -2474,11 +2469,11 @@ function process(repo: Repo) { returnType: 'Profile', } : undefined, - lookupFuzzyCallable, + lookupCallableByName, }); const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); expect(flatGet(typeEnv, 'profile')).toBe('Profile'); - expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile'); + expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile'); }); it('inherited method return type resolution uses lookupMethodByOwner on parent owners', () => { @@ -2490,7 +2485,7 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupFuzzyCallable = vi.fn(() => []); + const lookupCallableByName = vi.fn(() => []); const symbolTable = createMockSymbolTable({ lookupClassByName: (name: string) => { if (name === 'Repo') return [createClassDef('Repo', 'Class', 'models.ts')]; @@ -2507,14 +2502,14 @@ function process(repo: Repo) { returnType: 'Profile', } : undefined, - lookupFuzzyCallable, + lookupCallableByName, }); const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable, parentMap: new Map([['Repo', ['BaseRepo']]]), }); expect(flatGet(typeEnv, 'profile')).toBe('Profile'); - expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile'); + expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile'); }); it('method return type resolution handles multiple class defs when only one owner has the method', () => { @@ -2526,7 +2521,7 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupFuzzyCallable = vi.fn(() => []); + const lookupCallableByName = vi.fn(() => []); const symbolTable = createMockSymbolTable({ lookupClassByName: (name: string) => name === 'Repo' @@ -2549,11 +2544,11 @@ function process(repo: Repo) { } : undefined, lookupExactAll: () => [], - lookupFuzzyCallable, + lookupCallableByName, }); const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); expect(flatGet(typeEnv, 'profile')).toBe('Profile'); - expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile'); + expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile'); }); it('method return type resolution with multiple class defs falls back to MRO when direct owners miss', () => { @@ -2565,7 +2560,7 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupFuzzyCallable = vi.fn(() => []); + const lookupCallableByName = vi.fn(() => []); const symbolTable = createMockSymbolTable({ lookupClassByName: (name: string) => { if (name === 'Repo') { @@ -2588,14 +2583,14 @@ function process(repo: Repo) { } : undefined, lookupExactAll: () => [], - lookupFuzzyCallable, + lookupCallableByName, }); const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable, parentMap: new Map([['Repo', ['BaseRepo']]]), }); expect(flatGet(typeEnv, 'profile')).toBe('Profile'); - expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile'); + expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile'); }); it('method return type resolution stays unresolved when multiple class defs each define the method', () => { @@ -2607,7 +2602,7 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupFuzzyCallable = vi.fn(() => []); + const lookupCallableByName = vi.fn(() => []); const symbolTable = createMockSymbolTable({ lookupClassByName: (name: string) => name === 'Repo' @@ -2642,11 +2637,11 @@ function process(repo: Repo) { return undefined; }, lookupExactAll: () => [], - lookupFuzzyCallable, + lookupCallableByName, }); const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); expect(flatGet(typeEnv, 'profile')).toBeUndefined(); - expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile'); + expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile'); }); it('method return type resolution preserves same-return overload success', () => { @@ -2658,7 +2653,7 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupFuzzyCallable = vi.fn(() => []); + const lookupCallableByName = vi.fn(() => []); const symbolTable = createMockSymbolTable({ lookupClassByName: (name: string) => name === 'Repo' ? [createClassDef('Repo', 'Class', 'models.ts')] : [], @@ -2691,11 +2686,11 @@ function process(repo: Repo) { }, ] : [], - lookupFuzzyCallable, + lookupCallableByName, }); const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); expect(flatGet(typeEnv, 'profile')).toBe('Profile'); - expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile'); + expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile'); }); it('method return type resolution stays unresolved for ambiguous overloads with differing returns', () => { @@ -2707,7 +2702,7 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupFuzzyCallable = vi.fn(() => []); + const lookupCallableByName = vi.fn(() => []); const symbolTable = createMockSymbolTable({ lookupClassByName: (name: string) => { if (name === 'Repo') return [createClassDef('Repo', 'Class', 'models.ts')]; @@ -2743,14 +2738,14 @@ function process(repo: Repo) { }, ] : [], - lookupFuzzyCallable, + lookupCallableByName, }); const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable, parentMap: new Map([['Repo', ['BaseRepo']]]), }); expect(flatGet(typeEnv, 'profile')).toBeUndefined(); - expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile'); + expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile'); }); it('inherited method return type resolution preserves same-return overload success on parent owners', () => { @@ -2762,7 +2757,7 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupFuzzyCallable = vi.fn(() => []); + const lookupCallableByName = vi.fn(() => []); const symbolTable = createMockSymbolTable({ lookupClassByName: (name: string) => { if (name === 'Repo') return [createClassDef('Repo', 'Class', 'models.ts')]; @@ -2798,14 +2793,14 @@ function process(repo: Repo) { }, ] : [], - lookupFuzzyCallable, + lookupCallableByName, }); const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable, parentMap: new Map([['Repo', ['BaseRepo']]]), }); expect(flatGet(typeEnv, 'profile')).toBe('Profile'); - expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile'); + expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile'); }); it('inherited method return type resolution stays unresolved for ambiguous overloads on parent owners', () => { @@ -2830,13 +2825,13 @@ function process(repo: Repo) { parameterCount: 2, returnType: 'Admin', }); - const lookupFuzzyCallable = vi.spyOn(symbolTable, 'lookupFuzzyCallable'); + const lookupCallableByName = vi.spyOn(symbolTable, 'lookupCallableByName'); const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable, parentMap: new Map([['Repo', ['BaseRepo']]]), }); expect(flatGet(typeEnv, 'profile')).toBeUndefined(); - expect(lookupFuzzyCallable).not.toHaveBeenCalled(); + expect(lookupCallableByName).not.toHaveBeenCalled(); }); }); @@ -5747,7 +5742,7 @@ function process() { describe('importedReturnTypes (Phase 14 E3)', () => { // Minimal mock SymbolTable that returns a known callable const makeSymbolTable = (callables: Array<{ name: string; returnType?: string }>) => ({ - lookupFuzzyCallable: (name: string) => + lookupCallableByName: (name: string) => callables .filter((c) => c.name === name) .map((c) => ({ @@ -5757,11 +5752,10 @@ function process() { returnType: c.returnType, })), lookupClassByName: () => [], - lookupFuzzy: () => [], lookupExact: () => undefined, lookupExactFull: () => undefined, add: () => {}, - getStats: () => ({ fileCount: 0, globalSymbolCount: 0 }), + getStats: () => ({ fileCount: 0 }), clear: () => {}, }); From d87744fffc13fa99e0a716a8ea4954ebc1aebccd Mon Sep 17 00:00:00 2001 From: Yogesh Singh <153002901+Yogesh1290@users.noreply.github.com> Date: Sat, 11 Apr 2026 00:44:16 +0545 Subject: [PATCH 09/67] fix: resolve false 404 errors and stale repo context during multi-repo switching on Windows (#633) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: resolve false 404s and stale repo context during multi-repo switching on Windows * test(e2e): add repo-switching tests — hold-queue 503, ?project= URL, Windows path normalization * test(e2e): fix repo-switching specs — use live backend with ?server= param --- gitnexus-web/e2e/repo-switching.spec.ts | 168 ++++++++++++++++++ gitnexus-web/src/App.tsx | 98 +++++----- .../src/components/CodeReferencesPanel.tsx | 2 +- gitnexus-web/src/hooks/useAppState.tsx | 78 +++++--- gitnexus-web/src/services/backend-client.ts | 23 ++- gitnexus/src/server/analyze-job.ts | 5 + gitnexus/src/server/api.ts | 91 +++++++++- 7 files changed, 381 insertions(+), 84 deletions(-) create mode 100644 gitnexus-web/e2e/repo-switching.spec.ts diff --git a/gitnexus-web/e2e/repo-switching.spec.ts b/gitnexus-web/e2e/repo-switching.spec.ts new file mode 100644 index 000000000..4cf6bf8c8 --- /dev/null +++ b/gitnexus-web/e2e/repo-switching.spec.ts @@ -0,0 +1,168 @@ +import { test, expect } from '@playwright/test'; + +/** + * E2E tests for the repo-switching and false-404 fixes. + * + * Most tests use the live backend (same pattern as multi-repo-scoping.spec.ts). + * The 503 hold-queue test uses route interception to simulate a slow analysis. + */ + +const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:4747'; +const FRONTEND_URL = process.env.FRONTEND_URL ?? 'http://localhost:5173'; + +let firstRepoName: string; + +test.beforeAll(async () => { + if (process.env.E2E) { + try { + const res = await fetch(`${BACKEND_URL}/api/repos`); + const repos = await res.json(); + firstRepoName = repos[0]?.name ?? ''; + } catch { + firstRepoName = ''; + } + return; + } + try { + const [backendRes, frontendRes] = await Promise.allSettled([ + fetch(`${BACKEND_URL}/api/repos`), + fetch(FRONTEND_URL), + ]); + if ( + backendRes.status === 'rejected' || + (backendRes.status === 'fulfilled' && !backendRes.value.ok) + ) { + test.skip(true, 'gitnexus serve not available'); + return; + } + if ( + frontendRes.status === 'rejected' || + (frontendRes.status === 'fulfilled' && !frontendRes.value.ok) + ) { + test.skip(true, 'Vite dev server not available'); + return; + } + if (backendRes.status === 'fulfilled') { + const repos = await backendRes.value.json(); + if (!repos.length) { + test.skip(true, 'No indexed repos'); + return; + } + firstRepoName = repos[0].name; + } + } catch { + test.skip(true, 'servers not available'); + } +}); + +// ── 1. Hold-queue: 503 → descriptive user message ──────────────────────────── + +test.describe('Hold-queue timeout error', () => { + test('shows descriptive message when /api/repo returns 503', async ({ page }, testInfo) => { + // Intercept only /api/repo (singular) — not /api/repos — to return a 503 + // regex: /api/repo followed by end, ?, or # — NOT /api/repos + await page.route(/\/api\/repo(?!s)(\?.*)?$/, (route) => + route.fulfill({ + status: 503, + contentType: 'application/json', + body: JSON.stringify({ + error: `Repository analysis for "${firstRepoName}" is taking longer than expected. Please try again in a moment.`, + }), + }), + ); + + await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`); + + // UI should show the 503 error message + await expect(page.getByText(/taking longer than expected/i)).toBeVisible({ + timeout: 20_000, + }); + + await page.screenshot({ path: testInfo.outputPath('hold-queue-503.png') }); + }); +}); + +// ── 2. ?project= URL persistence ───────────────────────────────────────────── + +test.describe('?project= URL persistence', () => { + test('?project= is set in URL after connecting via ?server=', async ({ page }) => { + await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`); + + await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); + + const url = new URL(page.url()); + const project = url.searchParams.get('project'); + expect(project).toBeTruthy(); + // first repo returned by the live backend + if (firstRepoName) expect(project).toBe(firstRepoName); + }); + + test('?project= is still present after F5 reload', async ({ page }) => { + await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`); + await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); + + // After connect, URL has ?server=&project= — F5 re-uses both params + await page.reload(); + await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); + + const url = new URL(page.url()); + expect(url.searchParams.get('project')).toBeTruthy(); + }); +}); + +// ── 3. ?project= + ?server= combined auto-connect ──────────────────────────── + +test.describe('?project= auto-connect', () => { + test('navigating with ?server=&project= connects to the correct repo', async ({ + page, + }, testInfo) => { + if (!firstRepoName) test.skip(true, 'no repo name available'); + + await page.goto( + `/?server=${encodeURIComponent(BACKEND_URL)}&project=${encodeURIComponent(firstRepoName)}`, + ); + + await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); + + // ?project= in URL should match what we passed in + const url = new URL(page.url()); + expect(url.searchParams.get('project')).toBe(firstRepoName); + + await page.screenshot({ path: testInfo.outputPath('project-param-connect.png') }); + }); +}); + +// ── 4. Windows path normalization ───────────────────────────────────────────── + +test.describe('Windows path normalization', () => { + test('project name uses basename when /api/repo returns a Windows-style repoPath', async ({ + page, + }) => { + const repoName = firstRepoName || 'test-repo'; + const windowsPath = `C:\\Users\\LENOVO\\.gitnexus\\repos\\${repoName}`; + + // Mock /api/repo to return a Windows backslash path while keeping name correct + await page.route(/\/api\/repo(?!s)(\?.*)?$/, (route) => + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + // intentionally omit `name` to force path-based extraction + path: windowsPath, + repoPath: windowsPath, + }), + }), + ); + + await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`); + + await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); + + // URL ?project= must be the short basename, NOT the full Windows path + const url = new URL(page.url()); + const project = url.searchParams.get('project'); + expect(project).toBeTruthy(); + expect(project).not.toContain('\\'); + expect(project).not.toContain('LENOVO'); + expect(project).toBe(repoName); + }); +}); diff --git a/gitnexus-web/src/App.tsx b/gitnexus-web/src/App.tsx index 97af8d14c..2ee3569a8 100644 --- a/gitnexus-web/src/App.tsx +++ b/gitnexus-web/src/App.tsx @@ -56,16 +56,14 @@ const AppContent = () => { // backend calls (queries, search, grep, readFile) scope to this repo. const repoName = result.repoInfo.name; const repoPath = result.repoInfo.repoPath ?? result.repoInfo.path; + // Normalize both Windows (\) and Unix (/) path separators before splitting const projectName = - repoName || repoPath?.split('/').filter(Boolean).pop() || 'server-project'; + result.repoInfo.name || + (repoPath || '').replace(/\\/g, '/').split('/').filter(Boolean).pop() || + 'server-project'; setProjectName(projectName); setCurrentRepo(projectName); - // Update URL so F5 / bookmarks preserve which repo is open - const url = new URL(window.location.href); - url.searchParams.set('project', projectName); - window.history.replaceState(null, '', url.toString()); - // Build KnowledgeGraph from server data for visualization const graph = createKnowledgeGraph(); for (const node of result.nodes) { @@ -76,6 +74,11 @@ const AppContent = () => { } setGraph(graph); + // Persist the active project in the URL for bookmarkability and F5 refresh resilience + const urlObj = new URL(window.location.href); + urlObj.searchParams.set('project', projectName); + window.history.replaceState(null, '', urlObj.toString()); + // Transition directly to exploring view setViewMode('exploring'); @@ -99,22 +102,17 @@ const AppContent = () => { ], ); - // Auto-connect when ?server query param is present (bookmarkable shortcut). - // Also reads ?project= to connect to a specific repo. + // Auto-connect when ?server or ?project query param is present (bookmarkable shortcut) const autoConnectRan = useRef(false); useEffect(() => { if (autoConnectRan.current) return; const params = new URLSearchParams(window.location.search); - if (!params.has('server')) return; + const serverUrlParam = params.get('server'); + const projectParam = params.get('project'); + + if (!serverUrlParam && !projectParam) return; autoConnectRan.current = true; - const serverUrl = params.get('server') || window.location.origin; - const projectParam = params.get('project') || undefined; - - // Keep ?server= in the URL so F5 reconnects to the same server. - // autoConnectRan.current prevents re-trigger within the same session. - // handleServerConnect() will add/update ?project= after connecting. - setProgress({ phase: 'extracting', percent: 0, @@ -123,39 +121,45 @@ const AppContent = () => { }); setViewMode('loading'); + const serverUrl = serverUrlParam || window.location.origin; const baseUrl = normalizeServerUrl(serverUrl); - connectToServer( - serverUrl, - (phase, downloaded, total) => { - if (phase === 'validating') { - setProgress({ - phase: 'extracting', - percent: 5, - message: 'Connecting to server...', - detail: 'Validating server', - }); - } else if (phase === 'downloading') { - const pct = total ? Math.round((downloaded / total) * 90) + 5 : 50; - const mb = (downloaded / (1024 * 1024)).toFixed(1); - setProgress({ - phase: 'extracting', - percent: pct, - message: 'Downloading graph...', - detail: `${mb} MB downloaded`, - }); - } else if (phase === 'extracting') { - setProgress({ - phase: 'extracting', - percent: 97, - message: 'Processing...', - detail: 'Extracting file contents', - }); - } - }, - undefined, - projectParam, - ) + const tryConnect = async () => { + return await connectToServer( + serverUrl, + (phase, downloaded, total) => { + if (phase === 'validating') { + setProgress({ + phase: 'extracting', + percent: 5, + message: 'Connecting to server...', + detail: 'Validating server', + }); + } else if (phase === 'downloading') { + const pct = total ? Math.round((downloaded / total) * 90) + 5 : 50; + const mb = (downloaded / (1024 * 1024)).toFixed(1); + setProgress({ + phase: 'extracting', + percent: pct, + message: 'Downloading graph...', + detail: `${mb} MB downloaded`, + }); + } else if (phase === 'extracting') { + setProgress({ + phase: 'extracting', + percent: 97, + message: 'Processing...', + detail: 'Extracting file contents', + }); + } + }, + undefined, + projectParam || undefined, + { awaitAnalysis: true }, // enable backend hold-queue for repos still being analyzed + ); + }; + + tryConnect() .then(async (result) => { await handleServerConnect(result); setProgress(null); diff --git a/gitnexus-web/src/components/CodeReferencesPanel.tsx b/gitnexus-web/src/components/CodeReferencesPanel.tsx index d70847599..1b7a0c5b0 100644 --- a/gitnexus-web/src/components/CodeReferencesPanel.tsx +++ b/gitnexus-web/src/components/CodeReferencesPanel.tsx @@ -231,7 +231,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) = repo: projectName, }; - readFile(selectedFilePath, options) + readFile(selectedFilePath, { ...options, repo: projectName || undefined }) .then((result) => { if (!cancelled) { setFileResult(result); diff --git a/gitnexus-web/src/hooks/useAppState.tsx b/gitnexus-web/src/hooks/useAppState.tsx index 2fa5218ba..25c57767f 100644 --- a/gitnexus-web/src/hooks/useAppState.tsx +++ b/gitnexus-web/src/hooks/useAppState.tsx @@ -579,6 +579,13 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { try { const effectiveProjectName = overrideProjectName || projectName || 'project'; + + // Sync repoRef so all agent backend calls target the correct repo. + // initializeAgent can be called from App.tsx (handleServerConnect) which + // never sets repoRef.current directly — without this, queries default to repo[0]. + if (overrideProjectName) { + repoRef.current = overrideProjectName; + } const repo = repoRef.current; // Build backend interface for Graph RAG tools @@ -610,7 +617,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { setIsAgentInitializing(false); } }, - [projectName], + // eslint-disable-next-line react-hooks/exhaustive-deps + [], // repoRef is a stable ref — we sync it explicitly on entry; no state deps needed ); const sendChatMessage = useCallback( @@ -1042,6 +1050,9 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { setCodePanelOpen(false); setCodeReferenceFocus(null); + let connectedRepo: BackendRepo | undefined; + let pNameStr = repoName || 'server-project'; + try { const result: ConnectResult = await connectToServer( serverBaseUrl, @@ -1073,44 +1084,28 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { }, undefined, repoName, + { awaitAnalysis: true }, // enable backend hold-queue for repos still being analyzed ); // Build graph for visualization const repoPath = result.repoInfo.repoPath ?? result.repoInfo.path; + // Prefer the registry name, then normalize Windows \ and Unix / paths const pName = - repoName || result.repoInfo.name || repoPath?.split('/').pop() || 'server-project'; + repoName || + result.repoInfo.name || + (repoPath || '').replace(/\\/g, '/').split('/').filter(Boolean).pop() || + 'server-project'; setProjectName(pName); repoRef.current = pName; - // Update URL so F5 / bookmarks open the correct repo - const url = new URL(window.location.href); - url.searchParams.set('project', pName); - window.history.replaceState(null, '', url.toString()); + connectedRepo = result.repoInfo; + pNameStr = pName; const newGraph = createKnowledgeGraph(); for (const node of result.nodes) newGraph.addNode(node); for (const rel of result.relationships) newGraph.addRelationship(rel); setGraph(newGraph); - - // No fileContents needed — grep/read tools use backend HTTP - - // Initialize agent with backend queries, then start embeddings - try { - if (getActiveProviderConfig()) { - await initializeAgent(pName); - } - setViewMode('exploring'); - startEmbeddingsWithFallback(); - setProgress(null); - } catch (err) { - console.warn('Failed to initialize agent:', err); - setIsAgentReady(false); - agentRef.current = null; - setAgentError('Failed to initialize agent'); - setViewMode('exploring'); - setProgress(null); - } - } catch (err) { + } catch (err: unknown) { console.error('Repo switch failed:', err); setProgress({ phase: 'error', @@ -1124,6 +1119,36 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { setViewMode('exploring'); setProgress(null); }, ERROR_RESET_DELAY_MS); + return; // Abort the whole switchRepo process + } + + if (pNameStr) { + // Persist the selected project in the URL so a refresh re-opens it + const urlObj = new URL(window.location.href); + urlObj.searchParams.set('project', pNameStr); + window.history.replaceState(null, '', urlObj.toString()); + } + + // Reset the agent and clear chat history so the AI starts fresh for the new repo + agentRef.current = null; + setIsAgentReady(false); + setChatMessages([]); + + // Re-initialize agent with the new repo's graph context + try { + if (getActiveProviderConfig()) { + await initializeAgent(pNameStr); + } + setViewMode('exploring'); + startEmbeddingsWithFallback(); + setProgress(null); + } catch (err) { + console.warn('Failed to initialize agent:', err); + setIsAgentReady(false); + agentRef.current = null; + setAgentError('Failed to initialize agent'); + setViewMode('exploring'); + setProgress(null); } }, [ @@ -1143,6 +1168,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { setCodeReferences, setCodePanelOpen, setCodeReferenceFocus, + setChatMessages, ], ); diff --git a/gitnexus-web/src/services/backend-client.ts b/gitnexus-web/src/services/backend-client.ts index 49a521949..d7862a63b 100644 --- a/gitnexus-web/src/services/backend-client.ts +++ b/gitnexus-web/src/services/backend-client.ts @@ -386,10 +386,22 @@ export const fetchRepos = async (): Promise => { return response.json() as Promise; }; -/** Fetch repo metadata. */ -export const fetchRepoInfo = async (repo?: string): Promise => { +/** Fetch repo metadata. + * Pass `awaitAnalysis: true` when connecting to a repo that may still be cloning/analyzing — + * this enables the backend's hold-queue and uses a 5-minute timeout to match. + * Normal calls (e.g. repo switching between already-indexed repos) use the default 10s timeout. + * + * Must stay in sync with HOLD_QUEUE_TIMEOUT_SECS in gitnexus/src/server/api.ts. + */ +const HOLD_QUEUE_TIMEOUT_MS = 300_000; // 5 minutes — matches backend HOLD_QUEUE_TIMEOUT_SECS + +export const fetchRepoInfo = async ( + repo?: string, + opts?: { awaitAnalysis?: boolean }, +): Promise => { const url = `${_backendUrl}/api/repo${repo ? `?${repoParam(repo)}` : ''}`; - const response = await fetchWithTimeout(url); + const timeout = opts?.awaitAnalysis ? HOLD_QUEUE_TIMEOUT_MS : undefined; + const response = await fetchWithTimeout(url, {}, timeout); await assertOk(response); const data = await response.json(); return { ...data, repoPath: data.repoPath ?? data.path }; @@ -736,18 +748,21 @@ export interface ConnectResult { /** * Connect to a server: validate, fetch repo info, download graph. * Content is NOT included (use readFile/grep for file access). + * Pass `awaitAnalysis: true` when the repo may still be cloning/analyzing — + * this enables the backend hold-queue and a 5-minute fetch timeout. */ export async function connectToServer( url: string, onProgress?: (phase: string, downloaded: number, total: number | null) => void, signal?: AbortSignal, repoName?: string, + opts?: { awaitAnalysis?: boolean }, ): Promise { const baseUrl = normalizeServerUrl(url); setBackendUrl(baseUrl); onProgress?.('validating', 0, null); - const repoInfo = await fetchRepoInfo(repoName); + const repoInfo = await fetchRepoInfo(repoName, { awaitAnalysis: opts?.awaitAnalysis }); onProgress?.('downloading', 0, null); const { nodes, relationships } = await fetchGraph(repoName, { diff --git a/gitnexus/src/server/analyze-job.ts b/gitnexus/src/server/analyze-job.ts index a58cdc497..f7d97e97d 100644 --- a/gitnexus/src/server/analyze-job.ts +++ b/gitnexus/src/server/analyze-job.ts @@ -87,6 +87,11 @@ export class JobManager { return this.jobs.get(id); } + /** Return a snapshot of all tracked jobs for inspection. */ + listJobs(): AnalyzeJob[] { + return Array.from(this.jobs.values()); + } + updateJob( id: string, update: Partial< diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index bb223696a..3d4cf9a6a 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -480,12 +480,84 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => activeRepoPaths.delete(repoPath); }; - // Helper: resolve a repo by name from the global registry, or default to first - const resolveRepo = async (repoName?: string) => { + /** + * Maximum time the hold-queue will wait for an active analysis job to complete. + * Must stay in sync with the frontend's `fetchRepoInfo({ awaitAnalysis: true })` timeout. + */ + const HOLD_QUEUE_TIMEOUT_SECS = 300; // 5 minutes + + // Helper: resolve a repo by name from the global registry, or default to first. + // Pass `req` to enable early exit if the client disconnects during the hold-queue wait. + const resolveRepo = async (repoName?: string, isRetry = false, req?: any): Promise => { const repos = await listRegisteredRepos(); - if (repos.length === 0) return null; - if (repoName) return repos.find((r) => r.name === repoName) || null; - return repos[0]; // default to first + let found = null; + + // Normalize: if a full path is passed, extract just the basename. + // e.g. "C:\Users\LENOVO\.gitnexus\repos\todo.txt-cli" -> "todo.txt-cli" + const normalizedName = repoName ? path.basename(repoName) : undefined; + + if (normalizedName) { + found = + repos.find((r) => r.name === normalizedName) || + repos.find((r) => r.name.toLowerCase() === normalizedName.toLowerCase()) || + null; + } else if (repos.length > 0) { + found = repos[0]; // default to first repo + } + + // If not yet in the registry, check whether a background job is actively cloning or + // analyzing this repo. Hold the connection open (up to 5 minutes) until it completes. + // We only wait for in-progress jobs ('queued'|'cloning'|'analyzing') — a 'complete' job + // whose repo is still missing means the registry sync failed; the fallback below handles it. + if (!found && normalizedName) { + const lower = normalizedName.toLowerCase(); + + // Track client disconnect to cancel the wait early + let clientGone = false; + req?.on('close', () => { + clientGone = true; + }); + + for (const job of jobManager.listJobs()) { + const isMatch = + job.repoName?.toLowerCase() === lower || + (job.repoUrl && path.basename(job.repoUrl).replace('.git', '').toLowerCase() === lower) || + (job.repoPath && path.basename(job.repoPath).toLowerCase() === lower); + + if (isMatch && ['queued', 'cloning', 'analyzing'].includes(job.status)) { + if (process.env.DEBUG) { + console.log( + `[debug] resolveRepo waiting for active job ${job.id} (${normalizedName})...`, + ); + } + for (let wait = 0; wait < HOLD_QUEUE_TIMEOUT_SECS; wait++) { + if (clientGone) return null; // client disconnected — stop polling + const currentJob = jobManager.getJob(job.id); + if (!currentJob || currentJob.status === 'failed') break; + if (currentJob.status === 'complete') { + await backend.init(); + const freshRepos = await listRegisteredRepos(); + return freshRepos.find((r) => r.name === normalizedName) || null; + } + await new Promise((r) => setTimeout(r, 1000)); + } + // Timed out — signal to the caller with a specific message + return { __timedOut: true, repoName: normalizedName }; + } + } + } + + // Emergency fallback: re-sync the registry to handle Windows file-system race conditions + // (e.g. registry file not yet flushed after clone completes). + if (!found && normalizedName && !isRetry) { + if (process.env.DEBUG) { + console.log(`[debug] resolveRepo 404 for "${normalizedName}". Triggering deep init...`); + } + await backend.init(); + return await resolveRepo(normalizedName, true, req); + } + + return found; }; // SSE heartbeat — clients connect to detect server liveness instantly. @@ -548,11 +620,18 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => // Get repo info app.get('/api/repo', async (req, res) => { try { - const entry = await resolveRepo(requestedRepo(req)); + const entry = await resolveRepo(requestedRepo(req), false, req); if (!entry) { res.status(404).json({ error: 'Repository not found. Run: gitnexus analyze' }); return; } + // Timed out waiting for an active analysis job + if (entry.__timedOut) { + res.status(503).json({ + error: `Repository analysis for "${entry.repoName}" is taking longer than expected. Please try again in a moment.`, + }); + return; + } const meta = await loadMeta(entry.storagePath); res.json({ name: entry.name, From 7c983d798fa2ca9a366d75e4531d4fa7803ab021 Mon Sep 17 00:00:00 2001 From: "Mr. WorldwideBrown" Date: Sat, 11 Apr 2026 10:48:14 +0530 Subject: [PATCH 10/67] Fix OpenCode config path, FTS extension load order, error messages, and CLAUDE.md stats (#781) --- gitnexus-web/src/services/backend-client.ts | 4 ++- gitnexus/src/cli/ai-context.ts | 12 +++++++-- gitnexus/src/cli/analyze.ts | 5 +++- gitnexus/src/cli/index.ts | 1 + gitnexus/src/cli/setup.ts | 2 +- gitnexus/src/core/lbug/lbug-adapter.ts | 27 +++++++++++++-------- gitnexus/src/core/run-analyze.ts | 4 ++- 7 files changed, 39 insertions(+), 16 deletions(-) diff --git a/gitnexus-web/src/services/backend-client.ts b/gitnexus-web/src/services/backend-client.ts index d7862a63b..ebc18c17d 100644 --- a/gitnexus-web/src/services/backend-client.ts +++ b/gitnexus-web/src/services/backend-client.ts @@ -264,11 +264,13 @@ const fetchWithTimeout = async ( const assertOk = async (response: Response): Promise => { if (response.ok) return; - let message = `Backend returned ${response.status} ${response.statusText}`; + let message = response.statusText; try { const body = await response.json(); if (body && typeof body.error === 'string') { message = body.error; + } else if (body && typeof body.message === 'string') { + message = body.message; } } catch { // Response body was not JSON diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index 1c7a95d7a..ae7f984fb 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -26,6 +26,7 @@ interface RepoStats { export interface AIContextOptions { skipAgentsMd?: boolean; + noStats?: boolean; } const GITNEXUS_START_MARKER = ''; @@ -64,6 +65,7 @@ function generateGitNexusContent( stats: RepoStats, generatedSkills?: GeneratedSkillInfo[], groupNames?: string[], + noStats?: boolean, ): string { const generatedRows = generatedSkills && generatedSkills.length > 0 @@ -87,7 +89,7 @@ function generateGitNexusContent( return `${GITNEXUS_START_MARKER} # GitNexus — Code Intelligence -This project is indexed by GitNexus as **${projectName}** (${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows)`}. Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run \`npx gitnexus analyze\` in terminal first. @@ -332,7 +334,13 @@ export async function generateAIContextFiles( options?: AIContextOptions, ): Promise<{ files: string[] }> { const groupNames = await findGroupsContainingRegistryName(projectName); - const content = generateGitNexusContent(projectName, stats, generatedSkills, groupNames); + const content = generateGitNexusContent( + projectName, + stats, + generatedSkills, + groupNames, + options?.noStats, + ); const createdFiles: string[] = []; if (!options?.skipAgentsMd) { diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index c77903de0..d520c3404 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -47,6 +47,8 @@ export interface AnalyzeOptions { verbose?: boolean; /** Skip AGENTS.md and CLAUDE.md gitnexus block updates. */ skipAgentsMd?: boolean; + /** Omit volatile symbol/relationship counts from AGENTS.md and CLAUDE.md. */ + noStats?: boolean; /** Index the folder even when no .git directory is present. */ skipGit?: boolean; } @@ -177,6 +179,7 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption embeddings: options?.embeddings, skipGit: options?.skipGit, skipAgentsMd: options?.skipAgentsMd, + noStats: options?.noStats, }, { onProgress: (_phase, percent, message) => { @@ -240,7 +243,7 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption processes: s.processes, }, skillResult.skills, - { skipAgentsMd: options?.skipAgentsMd }, + { skipAgentsMd: options?.skipAgentsMd, noStats: options?.noStats }, ); } } catch { diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 75940bcbf..02581ae47 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -26,6 +26,7 @@ program .option('--embeddings', 'Enable embedding generation for semantic search (off by default)') .option('--skills', 'Generate repo-specific skill files from detected communities') .option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md') + .option('--no-stats', 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md') .option('--skip-git', 'Index a folder without requiring a .git directory') .option('-v, --verbose', 'Enable verbose ingestion warnings (default: false)') .addHelpText( diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index ed941e6a4..8263405a5 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -265,7 +265,7 @@ async function setupOpenCode(result: SetupResult): Promise { return; } - const configPath = path.join(opencodeDir, 'config.json'); + const configPath = path.join(opencodeDir, 'opencode.json'); try { const existing = await readJsonFile(configPath); const config = existing || {}; diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index 88a6e9bba..90e663f40 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -909,19 +909,26 @@ export const loadFTSExtension = async (): Promise => { throw new Error('LadybugDB not initialized. Call initLbug first.'); } try { - await conn.query('INSTALL fts'); + // Try loading locally first (no network required) await conn.query('LOAD EXTENSION fts'); ftsLoaded = true; - } catch (err: any) { - const msg = err?.message || ''; - if ( - msg.includes('already loaded') || - msg.includes('already installed') || - msg.includes('already exists') - ) { + } catch { + // Fall back to install + load (requires network) + try { + await conn.query('INSTALL fts'); + await conn.query('LOAD EXTENSION fts'); ftsLoaded = true; - } else { - console.error('GitNexus: FTS extension load failed:', msg); + } catch (err: any) { + const msg = err?.message || ''; + if ( + msg.includes('already loaded') || + msg.includes('already installed') || + msg.includes('already exists') + ) { + ftsLoaded = true; + } else { + console.error('GitNexus: FTS extension load failed:', msg); + } } } }; diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index e8a108c71..f7b662705 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -48,6 +48,8 @@ export interface AnalyzeOptions { skipGit?: boolean; /** Skip AGENTS.md and CLAUDE.md gitnexus block updates. */ skipAgentsMd?: boolean; + /** Omit volatile symbol/relationship counts from AGENTS.md and CLAUDE.md. */ + noStats?: boolean; } export interface AnalyzeResult { @@ -327,7 +329,7 @@ export async function runFullAnalysis( processes: pipelineResult.processResult?.stats.totalProcesses, }, undefined, - { skipAgentsMd: options.skipAgentsMd }, + { skipAgentsMd: options.skipAgentsMd, noStats: options.noStats }, ); } catch { // Best-effort — don't fail the entire analysis for context file issues From d9960c62bfff30d53e9c22295159cf7c26f00bbf Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 10:20:57 +0100 Subject: [PATCH 11/67] =?UTF-8?q?SM-19:=20Delete=20resolveCallTarget=20?= =?UTF-8?q?=E2=80=94=20replace=20with=20thin=20dispatcher=20(#770)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * SM-19: Replace resolveCallTarget with thin dispatcher Delete the monolithic resolveCallTarget function (~200 lines) and replace it with a 15-line thin dispatcher that routes to resolveMemberCall, resolveStaticCall, or resolveFreeCall. Extract module-alias resolution and file-based member-call fallback into dedicated helper functions. - resolveCallTarget body reduced from ~200 lines to ~15 lines - Extract resolveModuleAliasedCall helper (Python/Ruby module imports) - Extract resolveMemberCallByFile helper (trait dispatch, overload disambiguation) - Extract singleCandidate helper (constructor alias fallback, name-based fallback) - Update unit tests for new dispatcher semantics - Update doc comments referencing deleted D0-D4 paths Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/469eac38-b0c0-4a26-a2ff-3eb06299730b Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * SM-19: Add singleCandidate tail fallback for member calls with unresolvable receiver type Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/469eac38-b0c0-4a26-a2ff-3eb06299730b Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix(SM-19): address all PR #770 review findings + fix CI Fixes all 5 test failures (2 unit + 3 integration) and addresses 10 review findings from comment 4225312416. Critical fix — singleCandidate null-route guard The SM-19 dispatcher chained singleCandidate as an unconditional tail fallback for member calls with receiverTypeName. This bypassed the SM-10 R3 null-route contract: when the receiver type IS in the index but file/owner filtering produced zero matches, the old code returned null (genuine miss), but the new code fell through to singleCandidate (false-positive CALLS edge). Root cause: resolveMemberCallByFile returns null for two semantically different reasons — (1) type not found in the index at all, and (2) type found but no candidate matched after narrowing. The dispatcher treated both as "try the next fallback." The old resolveCallTarget exited the entire function on case 2. Fix: after the scoped resolvers both return null, check whether the receiver type resolves in the index. If it does (case 2), null-route — the scoped resolvers made the right decision. If it doesn't (case 1, e.g. PHP 'mixed', dynamic types), singleCandidate is the correct last resort. ctx.resolve is cached so the check is free. This fixes: - Unit: no heritageMap null-route test (was getting 1 edge, expects 0) - Integration: Rust c.trait_only() negative test - Integration: 3 PHP heritage + alias tests (singleCandidate correctly fires when the receiver type is not in the index) Performance (findings #1, #2, #3) - Thread pre-computed tiered result into resolveModuleAliasedCall via new tieredOverride parameter — eliminates the duplicate ctx.resolve call on every module-alias path. - Add countCallableCandidates helper that short-circuits at threshold without allocating an intermediate array — replaces the filterCallableCandidates(...).length > 1 allocation in skipMember. - resolveMemberCallByFile lookupCallableByName caching deferred to a follow-up (finding #2) — the fix requires threading widenCache through the file-scoped resolver which is a larger change. Code quality (findings #4, #5) - Remove dead code: redundant conditional in resolveMemberCallByFile where both branches returned null. - Move WidenCache type declaration from mid-file (between JSDoc blocks) to adjacent to CONSTRUCTOR_TARGET_TYPES with other type declarations. Formatting - Applied prettier to call-processor.ts (CI format check was failing). Verification - tsc --noEmit clean - 3188 unit tests pass (0 skipped real tests) - 1766 resolver integration tests pass - Zero regressions — all PHP, Rust, and no-heritageMap tests green Review: https://github.com/abhigyanpatwari/GitNexus/pull/770#issuecomment-4225312416 * fix(SM-19): restore module-alias narrowing and constructor disambiguation Codex adversarial review on PR #770 surfaced two silent regressions in the SM-19 thin dispatcher: Finding 1 [high] — Typed member calls bypassed module-alias narrowing. When two homonym receiver types are both imported by the caller, the import-scoped tier no longer narrows and the owner/file resolvers see genuine ambiguity. The dispatcher null-routed silently, dropping valid CALLS edges. Fix: consult `resolveModuleAliasedCall` at the top of the typed-member branch so an active alias on `call.receiverName` picks the aliased file before the generic resolvers run. Finding 2 [medium] — Constructor dispatch lost overload disambiguation. When `resolveStaticCall` bails (ambiguous or ownerless Constructor pool) and the caller supplied `overloadHints` / `preComputedArgTypes`, the branch fell straight through to `singleCandidate` — which also bails on multiple same-arity survivors. Fix: between `resolveStaticCall` and `singleCandidate`, run constructor-filtered overload disambiguation on the tiered pool. Only engages when a narrowing signal is present; preserves SM-10 R3 null-route for genuinely ambiguous cases. Tests: - call-processor.test.ts: 3 new dispatcher-level regression tests covering real-homonym alias narrowing, constructor overload disambiguation with `argTypes`, and null-route control - symbol-table.test.ts: update `module alias homonyms` test which previously codified the Finding 1 regression; now asserts resolution to the aliased file's method Verification: 3191 unit + 2398 integration tests pass; tsc --noEmit clean; prettier clean. * refactor(SM-19): address code review findings with clean-code pass Code review on commit f424685e surfaced one P1 correctness regression and two P2 maintainability concerns. This commit closes all ten findings: P1 — Alias helper placement regression - resolveModuleAliasedCall now runs as a FALLBACK in the typed-member branch, after resolveMemberCall/resolveMemberCallByFile return null. Previously it short-circuited BEFORE scoped resolvers, leaking unrelated homonyms from the aliased file when a local var coincidentally matched a module alias. - Added type-file verification guard: alias narrowing only fires when the alias target file is among the receiver type's defining files. Prevents cross-type false positives and hardens SM-10 R3. P2 — Thin-dispatcher drift (roadmap Phase 3) - Extracted disambiguateByOverloadOrArgTypes shared helper. Centralizes the overloadHints → preComputedArgTypes precedence rule used by both member and constructor resolvers. - Folded constructor overload disambiguation into resolveStaticCall as step 4.5 (between the ambiguous-pool bail and the instantiable-class fallback). resolveStaticCall now accepts optional overloadHints / preComputedArgTypes symmetric with resolveMemberCallByFile. - Dispatcher's constructor branch returns to a 2-line delegation. - resolveMemberCallByFile now calls the shared helper instead of inlining the ternary. P2 — Missing test coverage - owner-scoped wins over alias narrowing (alias with unrelated target class must not override unique owner-scoped answer) - alias narrowing rejects unrelated target type (type-file guard) - alias fallthrough: receiverName not in alias map - alias fallthrough: alias target file has no matching method (overloadHints-for-constructor variant transitively covered via the extracted helper's member-path tests; direct dispatcher test deferred as it requires real OverloadHints fixture parsing) P3 — Clarity and durability - Stripped "Codex SM-19 Finding N" prefixes from comments. Replaced with durable explanations of WHY each guarded branch exists. - Added cross-reference comment at the tail-branch resolveModuleAliasedCall call site pointing to the typed-member branch usage. Verification: 3195 unit + 1766 resolver integration + 2398 full integration tests pass. tsc --noEmit clean. prettier clean. Plan: docs/plans/2026-04-11-002-fix-sm19-code-review-findings-plan.md --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> Co-authored-by: Gergo Magyar --- gitnexus/src/core/ingestion/call-processor.ts | 560 ++++++++++-------- gitnexus/test/unit/call-processor.test.ts | 322 ++++++++++ gitnexus/test/unit/symbol-table.test.ts | 73 +-- 3 files changed, 661 insertions(+), 294 deletions(-) diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index a5258fa8e..5edd72737 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -1303,6 +1303,9 @@ export const processCalls = async ( const CONSTRUCTOR_TARGET_TYPES = new Set(['Constructor', 'Class', 'Struct', 'Record']); +/** Per-file cache for module-alias widening. Cleared between files. */ +type WidenCache = Map; + const filterCallableCandidates = ( candidates: readonly SymbolDefinition[], argCount?: number, @@ -1339,6 +1342,40 @@ const filterCallableCandidates = ( ); }; +/** + * Count callable candidates matching the kind + arity filter without + * allocating an intermediate array. Short-circuits once count exceeds + * `threshold` (default 1) — used by the dispatcher's `skipMember` check + * where we only need to know "more than one survivor". + */ +const countCallableCandidates = ( + candidates: readonly SymbolDefinition[], + argCount?: number, + callForm?: 'free' | 'member' | 'constructor', + threshold = 1, +): number => { + let count = 0; + for (const c of candidates) { + // Kind filter (mirrors filterCallableCandidates) + const typeOk = + callForm === 'constructor' + ? CONSTRUCTOR_TARGET_TYPES.has(c.type) + : CALLABLE_TYPES.has(c.type); + if (!typeOk) continue; + // Arity filter + if ( + argCount !== undefined && + c.parameterCount !== undefined && + (argCount < (c.requiredParameterCount ?? c.parameterCount) || argCount > c.parameterCount) + ) { + continue; + } + count++; + if (count > threshold) return count; // early exit + } + return count; +}; + const toResolveResult = (definition: SymbolDefinition, tier: ResolutionTier): ResolveResult => ({ nodeId: definition.nodeId, confidence: TIER_CONFIDENCE[tier], @@ -1428,6 +1465,31 @@ const tryOverloadDisambiguation = ( return matchCandidatesByArgTypes(candidates, argTypes); }; +/** + * Apply overload-hint or arg-type disambiguation to a pre-filtered candidate + * pool. Returns the unique survivor, or null when neither signal is present, + * neither can disambiguate, or the pool remains ambiguous. + * + * Precedence rule: `overloadHints` wins over `preComputedArgTypes` when both + * are supplied. The AST-based disambiguator has access to live type inference + * hooks, whereas `preComputedArgTypes` is a worker-path pre-computation that + * may be coarser-grained. + * + * Single source of truth for the narrowing-signal precedence used by member + * and constructor resolution paths. Add a new narrowing signal here once, not + * at each call site. + */ +const disambiguateByOverloadOrArgTypes = ( + pool: SymbolDefinition[], + overloadHints: OverloadHints | undefined, + preComputedArgTypes: (string | undefined)[] | undefined, +): SymbolDefinition | null => { + if (!overloadHints && !preComputedArgTypes) return null; + if (overloadHints) return tryOverloadDisambiguation(pool, overloadHints); + if (preComputedArgTypes) return matchCandidatesByArgTypes(pool, preComputedArgTypes); + return null; +}; + /** * Collapse Swift-extension duplicate Class/Struct candidates to the primary * definition, preferring the shortest file path. @@ -1450,9 +1512,8 @@ const tryOverloadDisambiguation = ( * 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. + * Used by `resolveFreeCall`. Having a single source of truth prevents + * duplication if the heuristic is ever tuned. */ const dedupSwiftExtensionCandidates = ( candidates: readonly SymbolDefinition[], @@ -1467,19 +1528,139 @@ const dedupSwiftExtensionCandidates = ( }; /** - * Resolve a function call to its target node ID using priority strategy: - * A. Narrow candidates by scope tier via ctx.resolve() - * B. Filter to callable symbol kinds (constructor-aware when callForm is set) - * C. Apply arity filtering when parameter metadata is available - * D. Apply receiver-type filtering for member calls with typed receivers - * E. Apply overload disambiguation via argument literal types (when available) + * Thin dispatcher that routes a call to the appropriate specialized resolver. * - * If filtering still leaves multiple candidates, refuse to emit a CALLS edge. + * - `free` → {@link resolveFreeCall} + * - `constructor` → {@link resolveStaticCall} (with pre-resolved tiered pool) + * - `member` with a known receiver type → {@link resolveMemberCall}, with + * file-based fallback for traits/interfaces + * - `member` without receiver type → module-alias check, then tiered lookup + * + * Replaces the former 200+ line function (SM-19: fuzzy-free call resolution). */ -/** Per-file cache for the widen path's lookupCallableByName calls. Cleared between files. */ -type WidenCache = Map; +/** + * Module-alias resolution for member calls without a receiver type. + * + * Handles Python/Ruby `import mod; mod.Symbol()` patterns where the receiver + * is a module name, not a typed variable. Uses `moduleAliasMap` to scope + * candidates to the correct module file. + */ +const resolveModuleAliasedCall = ( + call: Pick, + currentFile: string, + ctx: ResolutionContext, + widenCache?: WidenCache, + tieredOverride?: TieredCandidates, +): ResolveResult | null => { + if (!call.receiverName) return null; + const aliasMap = ctx.moduleAliasMap?.get(currentFile); + if (!aliasMap) return null; + const moduleFile = aliasMap.get(call.receiverName); + if (!moduleFile) return null; -/** @internal Exported for unit tests of D0 skip conditions (SM-11). Do not use outside tests. */ + // Reuse the caller's pre-computed tiered result when available — + // the dispatcher already called ctx.resolve(call.calledName, currentFile). + const tiered = tieredOverride ?? ctx.resolve(call.calledName, currentFile); + if (!tiered) return null; + + // Try member-form, then constructor-form (for `module.ClassName()` patterns) + let filtered = filterCallableCandidates(tiered.candidates, call.argCount, call.callForm).filter( + (c) => c.filePath === moduleFile, + ); + if (filtered.length === 0) { + filtered = filterCallableCandidates(tiered.candidates, call.argCount, 'constructor').filter( + (c) => c.filePath === moduleFile, + ); + } + if (filtered.length === 0) { + // Widen to global callable index scoped to the aliased module file. + const cacheKey = `${call.calledName}\0${moduleFile}`; + let defs = widenCache?.get(cacheKey); + if (!defs) { + defs = ctx.symbols.lookupCallableByName(call.calledName); + widenCache?.set(cacheKey, defs); + } + filtered = filterCallableCandidates(defs, call.argCount, call.callForm).filter( + (c) => c.filePath === moduleFile, + ); + if (filtered.length === 0) { + filtered = filterCallableCandidates(defs, call.argCount, 'constructor').filter( + (c) => c.filePath === moduleFile, + ); + } + } + return filtered.length === 1 ? toResolveResult(filtered[0], tiered.tier) : null; +}; + +/** + * File-based fallback for member calls where owner-scoped resolution fails. + * + * Resolves the receiver type via `ctx.resolve()` and narrows all callable + * symbols with the method name to the receiver type's defining file(s), + * then applies ownerId filtering and overload disambiguation. + * + * Handles Rust trait dispatch (`repo.find()` where `find` is on a trait impl), + * cross-file overloaded methods, and similar patterns where ownerId + * relationships may not be established on all candidates. + */ +const resolveMemberCallByFile = ( + calledName: string, + receiverTypeName: string, + currentFile: string, + ctx: ResolutionContext, + argCount?: number, + callForm?: 'free' | 'member' | 'constructor', + overloadHints?: OverloadHints, + preComputedArgTypes?: (string | undefined)[], +): ResolveResult | null => { + const typeResolved = ctx.resolve(receiverTypeName, currentFile); + if (!typeResolved || typeResolved.candidates.length === 0) return null; + const typeNodeIds = new Set(typeResolved.candidates.map((d) => d.nodeId)); + const typeFiles = new Set(typeResolved.candidates.map((d) => d.filePath)); + + const methodPool = filterCallableCandidates( + ctx.symbols.lookupCallableByName(calledName), + argCount, + callForm, + ); + const fileFiltered = methodPool.filter((c) => typeFiles.has(c.filePath)); + if (fileFiltered.length === 1) { + return toResolveResult(fileFiltered[0], typeResolved.tier); + } + + // ownerId fallback: narrow by ownerId matching the type's nodeId + const pool = fileFiltered.length > 0 ? fileFiltered : methodPool; + const ownerFiltered = pool.filter((c) => c.ownerId && typeNodeIds.has(c.ownerId)); + if (ownerFiltered.length === 1) return toResolveResult(ownerFiltered[0], typeResolved.tier); + + // Overload disambiguation on the narrowed pool + if (fileFiltered.length > 1 || ownerFiltered.length > 1) { + const overloadPool = ownerFiltered.length > 1 ? ownerFiltered : fileFiltered; + const disambiguated = disambiguateByOverloadOrArgTypes( + overloadPool, + overloadHints, + preComputedArgTypes, + ); + if (disambiguated) return toResolveResult(disambiguated, typeResolved.tier); + } + + // Zero-match null-route: receiver type resolved but no candidate matched + // after file-based and owner-based narrowing. Refuse to emit a CALLS edge + // rather than guess — matches the SM-10 R3 null-route contract. + return null; +}; + +/** Return the sole survivor from a tiered pool after callable + arity filtering, or null. */ +const singleCandidate = ( + tiered: TieredCandidates, + argCount?: number, + callForm?: 'free' | 'member' | 'constructor', +): ResolveResult | null => { + const filtered = filterCallableCandidates(tiered.candidates, argCount, callForm); + return filtered.length === 1 ? toResolveResult(filtered[0], tiered.tier) : null; +}; + +/** @internal Exported for unit tests. Do not use outside tests. */ export const _resolveCallTargetForTesting = ( call: Pick< ExtractedCall, @@ -1519,8 +1700,6 @@ const resolveCallTarget = ( 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, @@ -1532,223 +1711,95 @@ const resolveCallTarget = ( preComputedArgTypes, ); } - - let filteredCandidates = filterCallableCandidates( - tiered.candidates, - call.argCount, - call.callForm, - ); - - // 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 (staticResult) return staticResult; - } - - // Module-qualified constructor pattern: e.g. Python `import models; models.User()`. - // The attribute access gives callForm='member', but the callee may be a Class — a valid - // constructor target. Re-try with constructor-form filtering so that `module.ClassName()` - // emits a CALLS edge to the class node. - if (filteredCandidates.length === 0 && call.callForm === 'member') { - filteredCandidates = filterCallableCandidates(tiered.candidates, call.argCount, 'constructor'); - } - - // Module-alias disambiguation: Python `import auth; auth.User()` — receiverName='auth' - // 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 lookupCallableByName 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) { - const moduleFile = aliasMap.get(call.receiverName); - if (moduleFile) { - 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. - // Use per-file widenCache to avoid repeated lookupCallableByName for the same - // calledName+moduleFile from multiple call sites in the same file. - const cacheKey = `${call.calledName}\0${moduleFile}`; - let fuzzyDefs = widenCache?.get(cacheKey); - if (!fuzzyDefs) { - fuzzyDefs = ctx.symbols.lookupCallableByName(call.calledName); - widenCache?.set(cacheKey, fuzzyDefs); - } - const widened = filterCallableCandidates(fuzzyDefs, call.argCount, call.callForm).filter( - (c) => c.filePath === moduleFile, - ); - if (widened.length > 0) { - filteredCandidates = widened; - aliasNarrowed = true; - } - } - } - } - } - - // D. Receiver-type filtering: for member calls with a known receiver type, - // resolve the type through the same tiered import infrastructure, then - // filter method candidates to the type's defining file. Fall back to - // fuzzy ownerId matching only when file-based narrowing is inconclusive. - // - // Applied regardless of candidate count — the sole same-file candidate may - // 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, + return ( + resolveStaticCall( call.calledName, currentFile, ctx, - heritageMap, call.argCount, + tiered, + overloadHints, + preComputedArgTypes, + ) ?? singleCandidate(tiered, call.argCount, 'constructor') + ); + } + if (call.receiverTypeName) { + // Skip the owner-scoped MRO path when the tiered pool has genuine + // overload ambiguity that needs D1-D4+E handling, not D0. + const skipMember = + (!!overloadHints || !!preComputedArgTypes) && + countCallableCandidates(tiered.candidates, call.argCount, call.callForm) > 1; + // Try owner-scoped (resolveMemberCall) then file-scoped (resolveMemberCallByFile). + const memberResult = + (!skipMember + ? resolveMemberCall( + call.receiverTypeName, + call.calledName, + currentFile, + ctx, + heritageMap, + call.argCount, + ) + : null) ?? + resolveMemberCallByFile( + call.calledName, + call.receiverTypeName, + currentFile, + ctx, + call.argCount, + call.callForm, + overloadHints, + preComputedArgTypes, ); - if (memberResult) return memberResult; + if (memberResult) return memberResult; + + // Module-alias narrowing runs as a FALLBACK, after owner/file-scoped + // resolvers have returned null. This ordering is load-bearing: placing + // alias narrowing first would short-circuit unique owner-scoped answers + // when a local variable coincidentally matches an alias name, leaking + // unrelated homonyms from the aliased file onto the wrong receiver type. + // + // The type-file verification guard is load-bearing for SM-10 R3: an + // alias is only a VALID narrowing signal when the alias target file is + // among the receiver type's defining files. If the alias points at a + // file that does not hold `receiverTypeName`, any candidate we would + // pick from there would belong to an unrelated class — a cross-type + // false positive. ctx.resolve is cached per (name, file), so resolving + // the receiver type a second time here is free. + const typeResolves = ctx.resolve(call.receiverTypeName, currentFile); + const aliasMap = ctx.moduleAliasMap?.get(currentFile); + const aliasTargetFile = + call.receiverName && aliasMap ? aliasMap.get(call.receiverName) : undefined; + if ( + aliasTargetFile && + typeResolves && + typeResolves.candidates.some((c) => c.filePath === aliasTargetFile) + ) { + const aliasResult = resolveModuleAliasedCall(call, currentFile, ctx, widenCache, tiered); + if (aliasResult) return aliasResult; } - // D1. Resolve the receiver type - const typeResolved = ctx.resolve(call.receiverTypeName, currentFile); - if (typeResolved && typeResolved.candidates.length > 0) { - const typeNodeIds = new Set(typeResolved.candidates.map((d) => d.nodeId)); - const typeFiles = new Set(typeResolved.candidates.map((d) => d.filePath)); - - // D2. Widen candidates: same-file tier may miss the parent's method when - // it lives in another file. Query the callable index 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 callable pool — that - // would undo the alias narrowing and reintroduce homonym candidates - // from other files. - const methodPool = - filteredCandidates.length <= 1 && !aliasNarrowed - ? filterCallableCandidates( - ctx.symbols.lookupCallableByName(call.calledName), - call.argCount, - call.callForm, - ) - : filteredCandidates; - - // D3. File-based: prefer candidates whose filePath matches the resolved type's file - const fileFiltered = methodPool.filter((c) => typeFiles.has(c.filePath)); - if (fileFiltered.length === 1) { - return toResolveResult(fileFiltered[0], tiered.tier); - } - - // D4. ownerId fallback: narrow by ownerId matching the type's nodeId - const pool = fileFiltered.length > 0 ? fileFiltered : methodPool; - const ownerFiltered = pool.filter((c) => c.ownerId && typeNodeIds.has(c.ownerId)); - if (ownerFiltered.length === 1) { - return toResolveResult(ownerFiltered[0], tiered.tier); - } - // E. Try overload disambiguation on the narrowed pool - if (fileFiltered.length > 1 || ownerFiltered.length > 1) { - const overloadPool = ownerFiltered.length > 1 ? ownerFiltered : fileFiltered; - const disambiguated = overloadHints - ? tryOverloadDisambiguation(overloadPool, overloadHints) - : preComputedArgTypes - ? matchCandidatesByArgTypes(overloadPool, preComputedArgTypes) - : null; - 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 - // widening 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; - } + // SM-10 R3 null-route: when the receiver type resolves to indexed types + // but no scoped resolver (nor the guarded alias fallback) produced a + // match, that's a genuine miss — refuse to emit a CALLS edge rather + // than guess via an unscoped singleCandidate that ignores the class + // hierarchy. When the type is NOT in the index (PHP `mixed`, dynamic + // types, unresolvable aliases), the scoped resolvers had nothing to + // work with and singleCandidate is the correct last resort. + if (typeResolves && typeResolves.candidates.length > 0) { + return null; // null-route: type resolved, no candidate matched } + return singleCandidate(tiered, call.argCount, call.callForm); } - - // E. Overload disambiguation: when multiple candidates survive arity + receiver filtering, - // try matching argument types against parameter types (Phase P). - // Sequential path uses AST-based hints; worker path uses pre-computed argTypes. - 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` — 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; - } - - return toResolveResult(filteredCandidates[0], tiered.tier); + // Member call with no inferred receiver type — e.g. Python `mod.fn()` + // where `mod` is a module alias. Module-alias narrowing is the primary + // disambiguation signal here. Also consulted from the typed-member + // branch above as a guarded fallback after owner/file-scoped resolvers. + return ( + resolveModuleAliasedCall(call, currentFile, ctx, widenCache, tiered) ?? + singleCandidate(tiered, call.argCount, call.callForm) + ); }; // ── Scope key helpers ──────────────────────────────────────────────────── @@ -1762,9 +1813,6 @@ const resolveCallTarget = ( // classes (e.g. User.save@100 and Repo.save@200 are distinct keys). // Lookup uses a secondary funcName-only index built in lookupReceiverType. -/** Extract the function name from a scope key ("funcName@startIndex" → "funcName"). */ -const extractFuncNameFromScope = (scope: string): string => scope.slice(0, scope.indexOf('@')); - /** Extract the bare function name from a sourceId. * Handles both unqualified ("Function:filepath:funcName" → "funcName") * and qualified ("Function:filepath:ClassName.funcName" → "funcName"). @@ -1920,17 +1968,10 @@ const resolveFieldOwnership = ( * * After deduplication: * - * - 0 unique matches → `undefined` (owner-scoped path has no answer; D1-D4 - * fallback in `resolveCallTarget` may still find something via callable index) + * - 0 unique matches → `undefined` (owner-scoped path has no answer) * - 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 - * 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. @@ -2003,14 +2044,10 @@ const resolveMethodByOwner = ( * 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). + * {@link resolveCallTarget} delegates here for member calls. * - * **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 + * **SEMANTIC CHANGE (2026-04-09):** The confidence tier reflects how the + * owner TYPE was resolved, not how the method NAME was resolved globally. * 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. @@ -2060,14 +2097,10 @@ export const resolveMemberCall = ( * 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. + * {@link resolveCallTarget} delegates here for `callForm === 'free'`. * - * **Asymmetry vs `resolveCallTarget`:** `resolveFreeCall` intentionally does - * NOT take a `widenCache` parameter and does NOT run a D2 widening - * pass. Member calls (`resolveCallTarget`'s main body) widen via - * `lookupCallableByName` to reach parent-class methods defined in different files; - * free calls have no receiver type and rely exclusively on the tiered pool + * `resolveFreeCall` does not take a `widenCache` parameter. Free calls + * have no receiver type and rely exclusively on the tiered pool * from `ctx.resolve()`. * * @param calledName - The called function name (e.g. 'doStuff') @@ -2182,8 +2215,7 @@ export const resolveFreeCall = ( * 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). + * that target a class. * * Resolution strategy: * 1. `lookupClassByName(className)` — O(1) pre-check; bail early if no class exists. @@ -2224,6 +2256,8 @@ export const resolveStaticCall = ( ctx: ResolutionContext, argCount?: number, tieredOverride?: TieredCandidates, + overloadHints?: OverloadHints, + preComputedArgTypes?: (string | undefined)[], ): 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 @@ -2285,10 +2319,30 @@ export const resolveStaticCall = ( // 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. + // step 4.5 (overload/arg-type disambiguation) or the caller's fallback. + // Do not remove this check without also handling the ambiguous step-3 + // path explicitly. if (typeResolved.candidates.some((c) => c.type === 'Constructor')) { + // 4.5. Overload / arg-type disambiguation for ambiguous or ownerless + // Constructor pools. When the caller supplied a narrowing signal + // (AST-based overload hints from the sequential path, or pre- + // computed arg types from the worker path), give disambiguation a + // chance before null-routing. Symmetric with resolveMemberCallByFile's + // disambiguation pass — both resolvers now share the same signal + // precedence via disambiguateByOverloadOrArgTypes. Only fires when + // at least one narrowing signal is present; preserves SM-10 R3 for + // genuinely ambiguous cases with no disambiguating input. + if (overloadHints || preComputedArgTypes) { + const ctorPool = filterCallableCandidates(typeResolved.candidates, argCount, 'constructor'); + if (ctorPool.length > 1) { + const disambiguated = disambiguateByOverloadOrArgTypes( + ctorPool, + overloadHints, + preComputedArgTypes, + ); + if (disambiguated) return toResolveResult(disambiguated, typeResolved.tier); + } + } return null; } @@ -2527,7 +2581,7 @@ const walkMixedChain = ( continue; } } - // Fallback: fuzzy resolution via resolveCallTarget (cross-file, inherited, etc.) + // Fallback: resolve via resolveCallTarget dispatcher (delegates to resolveMemberCall) const resolved = resolveCallTarget( { calledName: step.name, callForm: 'member', receiverTypeName: currentType }, filePath, diff --git a/gitnexus/test/unit/call-processor.test.ts b/gitnexus/test/unit/call-processor.test.ts index a12dd0528..e69250ceb 100644 --- a/gitnexus/test/unit/call-processor.test.ts +++ b/gitnexus/test/unit/call-processor.test.ts @@ -2493,6 +2493,328 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { expect(authSave).toBeDefined(); expect(userSave).toBeUndefined(); }); + + it('module-alias guard (real homonym): both files imported, alias narrows typed member call to aliased file', async () => { + // When both homonym files are imported by the caller, import-scoped + // tiering no longer narrows the tiered pool — the dispatcher sees two + // `save` candidates. Module-alias narrowing is the only remaining + // disambiguation signal. The typed-member branch must consult the alias + // map (as a guarded fallback after owner/file-scoped resolvers fail) or + // null-route silently. + const authModFile = 'src/auth_mod.py'; + const userModFile = 'src/user_mod.py'; + const appFile = 'src/app.py'; + const authUserId = 'class:src/auth_mod.py:User'; + const userUserId = 'class:src/user_mod.py:User'; + const authSaveId = 'method:src/auth_mod.py:save'; + const userSaveId = 'method:src/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', + }); + // BOTH files imported by app.py — creates real ambiguity in tiered pool. + ctx.importMap.set(appFile, new Set([authModFile, userModFile])); + // Alias: `auth` points to auth_mod.py. + ctx.moduleAliasMap.set(appFile, new Map([['auth', authModFile]])); + + // Call `auth.User.save(user)` — receiverName is `auth` (matches alias), + // receiverTypeName is `User` (the class). This is the class-as-receiver + // static-style pattern parse-worker emits when it sees `auth.User.save(x)`. + const calls: ExtractedCall[] = [ + { + filePath: appFile, + calledName: 'save', + sourceId: 'Function:src/app.py:run', + argCount: 1, + callForm: 'member', + receiverName: 'auth', + receiverTypeName: 'User', + }, + ]; + + await processCallsFromExtracted(graph, calls, ctx); + + const rels = graph.relationships.filter((r) => r.type === 'CALLS'); + // Module alias narrows to auth_mod.py. Without it the dispatcher would + // null-route because both User classes own a `save` method and there's + // no heritage or overload signal to pick between them. + expect(rels).toHaveLength(1); + expect(rels[0].targetId).toBe(authSaveId); + }); + + it('owner-scoped wins over alias narrowing: unique owner-scoped answer beats coincidental alias on unrelated file', async () => { + // Receiver type `User` has exactly one definition, in models.py. Module + // alias `auth → auth.py` exists (because the caller also imports auth.py + // for its own reasons), and auth.py contains an unrelated `Widget` class + // with a homonym `save` method. The caller has `receiverName='auth'` + // (e.g., a local variable coincidentally named `auth`), + // `receiverTypeName='User'`. Owner-scoped resolution must win — alias + // narrowing must not short-circuit a unique correct answer with an + // unrelated homonym from the aliased file. + const modelsFile = 'src/models.py'; + const authFile = 'src/auth.py'; + const appFile = 'src/app.py'; + const modelsUserId = 'class:src/models.py:User'; + const authWidgetId = 'class:src/auth.py:Widget'; + const modelsSaveId = 'method:src/models.py:User:save'; + const authSaveId = 'method:src/auth.py:Widget:save'; + + ctx.symbols.add(modelsFile, 'User', modelsUserId, 'Class'); + ctx.symbols.add(authFile, 'Widget', authWidgetId, 'Class'); + ctx.symbols.add(modelsFile, 'save', modelsSaveId, 'Method', { + ownerId: modelsUserId, + returnType: 'None', + }); + ctx.symbols.add(authFile, 'save', authSaveId, 'Method', { + ownerId: authWidgetId, + returnType: 'None', + }); + ctx.importMap.set(appFile, new Set([modelsFile, authFile])); + ctx.moduleAliasMap.set(appFile, new Map([['auth', authFile]])); + + const calls: ExtractedCall[] = [ + { + filePath: appFile, + calledName: 'save', + sourceId: 'Function:src/app.py:run', + argCount: 1, + callForm: 'member', + receiverName: 'auth', + receiverTypeName: 'User', + }, + ]; + + await processCallsFromExtracted(graph, calls, ctx); + + const rels = graph.relationships.filter((r) => r.type === 'CALLS'); + // Owner-scoped runs first and uniquely resolves User.save to models.py. + // Alias narrowing never fires because the scoped resolver already won. + expect(rels).toHaveLength(1); + expect(rels[0].targetId).toBe(modelsSaveId); + }); + + it('alias narrowing rejects unrelated target type: null-route when alias file does not hold receiver type', async () => { + // Receiver type `User` lives only in models.py, but has no `save` method + // defined. Alias `auth → auth.py`, and auth.py contains an unrelated + // `Widget.save`. Owner-scoped and file-scoped resolvers return null (no + // save on User). Without the type-file verification guard, alias + // narrowing would pick auth.py's `Widget.save` — a cross-type false + // positive. With the guard, auth.py is not in the receiver type's + // defining-files set (which is {models.py}), so alias narrowing bails + // and SM-10 R3 null-routes. + const modelsFile = 'src/models.py'; + const authFile = 'src/auth.py'; + const appFile = 'src/app.py'; + const modelsUserId = 'class:src/models.py:User'; + const authWidgetId = 'class:src/auth.py:Widget'; + const authSaveId = 'method:src/auth.py:Widget:save'; + + ctx.symbols.add(modelsFile, 'User', modelsUserId, 'Class'); + ctx.symbols.add(authFile, 'Widget', authWidgetId, 'Class'); + // NO save on User — deliberately absent to force null-route. + ctx.symbols.add(authFile, 'save', authSaveId, 'Method', { + ownerId: authWidgetId, + returnType: 'None', + }); + ctx.importMap.set(appFile, new Set([modelsFile, authFile])); + ctx.moduleAliasMap.set(appFile, new Map([['auth', authFile]])); + + const calls: ExtractedCall[] = [ + { + filePath: appFile, + calledName: 'save', + sourceId: 'Function:src/app.py:run', + argCount: 1, + callForm: 'member', + receiverName: 'auth', + receiverTypeName: 'User', + }, + ]; + + await processCallsFromExtracted(graph, calls, ctx); + + const rels = graph.relationships.filter((r) => r.type === 'CALLS'); + // Null-route: no CALLS edge. The type-file guard prevented the alias + // from leaking auth.py's Widget.save onto a User-typed receiver. + expect(rels).toHaveLength(0); + }); + + it('alias fallthrough: receiverName not in alias map falls through to owner-scoped resolver', async () => { + // Receiver variable `user` does NOT match any alias entry (alias only + // covers `auth`). Owner-scoped resolution must run to completion and + // pick models.py's User.save — the alias helper's early-bail must not + // interfere with unrelated typed member calls. This exercises the 99% + // hot path where alias narrowing is irrelevant. + const modelsFile = 'src/models.py'; + const authFile = 'src/auth.py'; + const appFile = 'src/app.py'; + const modelsUserId = 'class:src/models.py:User'; + const modelsSaveId = 'method:src/models.py:User:save'; + + ctx.symbols.add(modelsFile, 'User', modelsUserId, 'Class'); + ctx.symbols.add(modelsFile, 'save', modelsSaveId, 'Method', { + ownerId: modelsUserId, + returnType: 'None', + }); + ctx.importMap.set(appFile, new Set([modelsFile, authFile])); + ctx.moduleAliasMap.set(appFile, new Map([['auth', authFile]])); + + const calls: ExtractedCall[] = [ + { + filePath: appFile, + calledName: 'save', + sourceId: 'Function:src/app.py:run', + argCount: 0, + callForm: 'member', + receiverName: 'user', // NOT 'auth' — no alias match + receiverTypeName: 'User', + }, + ]; + + await processCallsFromExtracted(graph, calls, ctx); + + const rels = graph.relationships.filter((r) => r.type === 'CALLS'); + expect(rels).toHaveLength(1); + expect(rels[0].targetId).toBe(modelsSaveId); + }); + + it('alias fallthrough: alias target file has no matching method falls through to owner-scoped', async () => { + // Alias `auth → empty.py` where empty.py exists in the import map but + // has no `save` method at all. Owner-scoped finds models.py's User.save + // uniquely. Even if the type-file guard let alias narrowing fire (it + // won't, because empty.py isn't in the receiver type's files), the + // helper would return null and resolution must still succeed. + const modelsFile = 'src/models.py'; + const emptyFile = 'src/empty.py'; + const appFile = 'src/app.py'; + const modelsUserId = 'class:src/models.py:User'; + const modelsSaveId = 'method:src/models.py:User:save'; + + ctx.symbols.add(modelsFile, 'User', modelsUserId, 'Class'); + ctx.symbols.add(modelsFile, 'save', modelsSaveId, 'Method', { + ownerId: modelsUserId, + returnType: 'None', + }); + // empty.py: no symbols at all. + ctx.importMap.set(appFile, new Set([modelsFile, emptyFile])); + ctx.moduleAliasMap.set(appFile, new Map([['auth', emptyFile]])); + + const calls: ExtractedCall[] = [ + { + filePath: appFile, + calledName: 'save', + sourceId: 'Function:src/app.py:run', + argCount: 0, + callForm: 'member', + receiverName: 'auth', + receiverTypeName: 'User', + }, + ]; + + await processCallsFromExtracted(graph, calls, ctx); + + const rels = graph.relationships.filter((r) => r.type === 'CALLS'); + expect(rels).toHaveLength(1); + expect(rels[0].targetId).toBe(modelsSaveId); + }); + + it('constructor overload disambiguation: same-arity ownerless constructors picked via preComputedArgTypes', async () => { + // When two homonym constructors across different files have the same + // arity but different parameter types, `resolveStaticCall` correctly + // bails (step 3 ambiguity → step 4 bail because the tiered pool contains + // Constructor nodes). Step 4.5 then runs overload/arg-type disambiguation + // on the constructor-filtered pool, picking the string overload when the + // caller supplies matching `argTypes` / `preComputedArgTypes`. + const userFile = 'src/models/User.ts'; + const repoFile = 'src/models/Repo.ts'; + const appFile = 'src/app.ts'; + const userClassId = 'Class:src/models/User.ts:User'; + const repoClassId = 'Class:src/models/Repo.ts:User'; + const userCtorId = 'Constructor:src/models/User.ts:User(string)'; + const repoCtorId = 'Constructor:src/models/Repo.ts:User(number)'; + + ctx.symbols.add(userFile, 'User', userClassId, 'Class'); + ctx.symbols.add(repoFile, 'User', repoClassId, 'Class'); + ctx.symbols.add(userFile, 'User', userCtorId, 'Constructor', { + ownerId: userClassId, + parameterCount: 1, + parameterTypes: ['string'], + }); + ctx.symbols.add(repoFile, 'User', repoCtorId, 'Constructor', { + ownerId: repoClassId, + parameterCount: 1, + parameterTypes: ['number'], + }); + ctx.importMap.set(appFile, new Set([userFile, repoFile])); + + const calls: ExtractedCall[] = [ + { + filePath: appFile, + calledName: 'User', + sourceId: 'Function:src/app.ts:main', + argCount: 1, + callForm: 'constructor', + argTypes: ['string'], + }, + ]; + + await processCallsFromExtracted(graph, calls, ctx); + + const rels = graph.relationships.filter((r) => r.type === 'CALLS'); + expect(rels).toHaveLength(1); + expect(rels[0].targetId).toBe(userCtorId); + }); + + it('constructor overload disambiguation: null-routes when disambiguation cannot pick unique survivor', async () => { + // Control test for Finding 2 fix: when `preComputedArgTypes` does not + // match any candidate uniquely, the dispatcher must null-route rather + // than pick arbitrarily. Preserves SM-10 R3. + const userFile = 'src/models/User.ts'; + const repoFile = 'src/models/Repo.ts'; + const appFile = 'src/app.ts'; + const userClassId = 'Class:src/models/User.ts:User'; + const repoClassId = 'Class:src/models/Repo.ts:User'; + const userCtorId = 'Constructor:src/models/User.ts:User(string)'; + const repoCtorId = 'Constructor:src/models/Repo.ts:User(string)'; + + ctx.symbols.add(userFile, 'User', userClassId, 'Class'); + ctx.symbols.add(repoFile, 'User', repoClassId, 'Class'); + // Both constructors take `string` — genuinely ambiguous. + ctx.symbols.add(userFile, 'User', userCtorId, 'Constructor', { + ownerId: userClassId, + parameterCount: 1, + parameterTypes: ['string'], + }); + ctx.symbols.add(repoFile, 'User', repoCtorId, 'Constructor', { + ownerId: repoClassId, + parameterCount: 1, + parameterTypes: ['string'], + }); + ctx.importMap.set(appFile, new Set([userFile, repoFile])); + + const calls: ExtractedCall[] = [ + { + filePath: appFile, + calledName: 'User', + sourceId: 'Function:src/app.ts:main', + argCount: 1, + callForm: 'constructor', + argTypes: ['string'], + }, + ]; + + await processCallsFromExtracted(graph, calls, ctx); + + const rels = graph.relationships.filter((r) => r.type === 'CALLS'); + expect(rels).toHaveLength(0); + }); }); // ---- processAssignmentsFromExtracted: Phase 9 accumulator fallback ---- diff --git a/gitnexus/test/unit/symbol-table.test.ts b/gitnexus/test/unit/symbol-table.test.ts index 4763adfcc..0f12851d6 100644 --- a/gitnexus/test/unit/symbol-table.test.ts +++ b/gitnexus/test/unit/symbol-table.test.ts @@ -1735,31 +1735,36 @@ describe('resolveMemberCall', () => { }); // --------------------------------------------------------------------------- -// T1: D0 skip-condition tests — verify resolveCallTarget bypasses the -// resolveMemberCall fast path when overloadHints, preComputedArgTypes, or a -// module alias is active. +// T1: resolveCallTarget thin dispatcher (SM-19) — verify the dispatcher +// routes member/constructor/free calls to the appropriate specialized resolver. // --------------------------------------------------------------------------- -describe('resolveCallTarget D0 skip conditions (SM-11)', () => { +// --------------------------------------------------------------------------- +// resolveCallTarget thin dispatcher (SM-19) +// After SM-19, resolveCallTarget is a thin dispatcher that routes to +// resolveMemberCall, resolveStaticCall, or resolveFreeCall. The D0-D4 fuzzy +// widening paths have been removed. +// --------------------------------------------------------------------------- + +describe('resolveCallTarget thin dispatcher (SM-19)', () => { let ctx: ResolutionContext; beforeEach(() => { ctx = createResolutionContext(); }); - it('module alias: picks alias-scoped class over homonym (D0 actually bypassed)', () => { + it('module alias homonyms: dispatcher resolves via module-alias narrowing to aliased file', () => { // Python-style: `import auth; auth.User.save()` where BOTH auth.py and - // other.py define a `User` class with a `save` method. The test proves: + // other.py define a `User` class with a `save` method. // - // 1. Without the alias: resolveMemberCall sees two homonym Users, - // both own `save`, and correctly returns null (refuses to guess). - // 2. With the alias: D0 is skipped via `hasActiveModuleAlias`, and - // D1-D4 — respecting the alias-narrowed filteredCandidates — picks - // the auth.py User.save method. - // - // A regression where D0 silently ran would produce null (ambiguous) - // instead of the correct answer, so this test actually exercises the - // skip path rather than just verifying a single-candidate happy path. + // When both homonym files are imported, owner-scoped resolution sees + // genuine ambiguity (both `User` classes own a `save` method) and the + // only remaining disambiguation signal is the module alias on + // `call.receiverName`. The dispatcher consults alias narrowing as a + // guarded fallback after owner/file-scoped resolvers return null; the + // type-file verification guard requires the alias target file to be + // among the receiver type's defining files before alias narrowing is + // considered a valid signal. ctx.symbols.add('src/auth.py', 'User', 'class:auth:User', 'Class'); ctx.symbols.add('src/auth.py', 'save', 'method:auth:User:save', 'Method', { returnType: 'None', @@ -1773,37 +1778,25 @@ describe('resolveCallTarget D0 skip conditions (SM-11)', () => { ctx.importMap.set('src/app.py', new Set(['src/auth.py', 'src/other.py'])); ctx.moduleAliasMap.set('src/app.py', new Map([['auth', 'src/auth.py']])); - // Control: without alias narrowing, resolveMemberCall sees both Users - // own `save` and correctly refuses to pick one. - const ambiguous = resolveMemberCall('User', 'save', 'src/app.py', ctx); - expect(ambiguous).toBeNull(); - - // With alias narrowing active, D0 is skipped and D1-D4 picks auth.py's - // User.save because the alias block already narrowed filteredCandidates - // to auth.py (and the D2 widening step is gated on `!aliasNarrowed`). - const aliased = _resolveCallTargetForTesting( + const result = _resolveCallTargetForTesting( { calledName: 'save', callForm: 'member', receiverTypeName: 'User', - receiverName: 'auth', // triggers hasActiveModuleAlias → D0 skipped + receiverName: 'auth', }, 'src/app.py', ctx, ); - expect(aliased).not.toBeNull(); - expect(aliased!.nodeId).toBe('method:auth:User:save'); + // Module-alias narrowing picks auth.py's save, not other.py's. + expect(result).not.toBeNull(); + expect(result?.nodeId).toBe('method:auth:User:save'); }); - it('overloadHints present: D0 bypassed, D1-D4 handles resolution', () => { - // When overloadHints is supplied, the D0 fast path must be skipped - // because lookupMethodByOwner does not consider argument types and - // would pick an arbitrary overload for same-return-type overloads. - // - // This test verifies that the skip does not break resolution: passing - // a dummy overloadHints object should still yield the correct method - // via the D1-D4 path. + it('overloadHints ignored for member calls — resolveMemberCall resolves directly', () => { + // With the thin dispatcher, overloadHints are not passed to resolveMemberCall + // (it does not accept them). Single-candidate member calls still resolve. ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); ctx.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { returnType: 'void', @@ -1811,8 +1804,6 @@ describe('resolveCallTarget D0 skip conditions (SM-11)', () => { }); ctx.importMap.set('src/app.ts', new Set(['src/user.ts'])); - // Minimal stub; D1-D4 only calls tryOverloadDisambiguation when there are - // multiple candidates, so an empty object is fine for single-candidate cases. const dummyHints = {} as OverloadHints; const result = _resolveCallTargetForTesting( @@ -1830,10 +1821,10 @@ describe('resolveCallTarget D0 skip conditions (SM-11)', () => { expect(result!.nodeId).toBe('method:User:save'); }); - it('preComputedArgTypes present: D0 bypassed, D1-D4 handles resolution', () => { - // Analogous to the overloadHints case: when preComputedArgTypes is supplied - // (worker path), D0 must be skipped so that type-based overload - // disambiguation in D1-D4 is authoritative. + it('preComputedArgTypes ignored for member calls — resolveMemberCall resolves directly', () => { + // Analogous to the overloadHints case: thin dispatcher delegates to + // resolveMemberCall which resolves the single candidate without needing + // argument-type disambiguation. ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); ctx.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { returnType: 'void', From 08541e28573ab21d4cb04261767b4fe655778d5c Mon Sep 17 00:00:00 2001 From: "Mr. WorldwideBrown" Date: Sat, 11 Apr 2026 15:54:47 +0530 Subject: [PATCH 12/67] Fix HTTP client vs Express route detection and Spring interface attribution (#780) * fix: correctly identify HTTP client calls vs Express routes in receiver extraction * fix: skip Spring route extraction for Feign client interfaces * fix: address review feedback - receiver walk edge case, regex anchoring, add tests * style: fix prettier formatting in route extractor and test files --- .../group/extractors/http-route-extractor.ts | 12 ++ .../core/ingestion/workers/parse-worker.ts | 51 ++++++- .../unit/group/http-route-extractor.test.ts | 72 ++++++++++ .../test/unit/receiver-extraction.test.ts | 125 ++++++++++++++++++ 4 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 gitnexus/test/unit/receiver-extraction.test.ts diff --git a/gitnexus/src/core/group/extractors/http-route-extractor.ts b/gitnexus/src/core/group/extractors/http-route-extractor.ts index 8dfb242bf..ebb4c668d 100644 --- a/gitnexus/src/core/group/extractors/http-route-extractor.ts +++ b/gitnexus/src/core/group/extractors/http-route-extractor.ts @@ -257,6 +257,18 @@ export class HttpRouteExtractor implements ContractExtractor { private scanSpringProviders(content: string, filePath: string): ExtractedContract[] { const out: ExtractedContract[] = []; + + // Skip Feign/client interfaces — annotated methods in interfaces are + // consumers (Feign, JAX-RS proxies), not provider endpoints. + // Anchored to line start (with optional access modifier) so we do not + // match "interface" inside comments or string literals. + if ( + /^\s*(?:public\s+)?interface\s+\w+/m.test(content) && + !/@(?:Rest)?Controller\b/.test(content) + ) { + return out; + } + let classPrefix = ''; const classRm = content.match(/@RequestMapping\s*\(\s*"([^"]+)"/); if (classRm) classPrefix = classRm[1].replace(/\/+$/, ''); diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 7abff42b4..f229b4cad 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -853,6 +853,11 @@ const HTTP_CLIENT_RECEIVERS = new Set([ 'apiclient', 'client', 'httpclient', + 'api', + '$http', + 'session', + 'httpservice', + 'conn', ]); // Decorator names that indicate HTTP route handlers (NestJS, Flask, FastAPI, Spring) @@ -1588,7 +1593,35 @@ const processFileGroup = ( // as Express route registrations. const callNode = captureMap['express_route']; const funcNode = callNode.childForFieldName?.('function') ?? callNode.children?.[0]; - const receiverNode = funcNode?.childForFieldName?.('object') ?? funcNode?.children?.[0]; + // Walk through nested member_expressions and call_expressions to + // reach the innermost receiver identifier. Handles chains like: + // this.httpService.get('/path') -> member chain -> 'httpservice' + // getClient().get('/path') -> call_expression -> 'getclient' + // axios.get('/path') -> bare identifier -> 'axios' + let receiverNode = funcNode?.childForFieldName?.('object') ?? funcNode?.children?.[0]; + while ( + receiverNode?.type === 'member_expression' || + receiverNode?.type === 'call_expression' + ) { + if (receiverNode.type === 'member_expression') { + // Drill into the property (rightmost part) of the member expression + const propNode = receiverNode.childForFieldName?.('property'); + if (propNode) { + receiverNode = propNode; + } else { + break; + } + } else { + // call_expression: unwrap to the function being called + const innerFunc = + receiverNode.childForFieldName?.('function') ?? receiverNode.children?.[0]; + if (innerFunc && innerFunc !== receiverNode) { + receiverNode = innerFunc; + } else { + break; + } + } + } const receiverText = receiverNode?.text?.toLowerCase() ?? ''; if (HTTP_CLIENT_RECEIVERS.has(receiverText)) { @@ -1998,6 +2031,22 @@ const processFileGroup = ( ? detectFrameworkFromAST(language, (definitionNode.text || '').slice(0, 300)) : null; + // Suppress Spring framework hint for methods inside interfaces + // (Feign clients, JAX-RS proxies are consumers, not providers) + if (frameworkHint && definitionNode) { + let classCheck = definitionNode.parent; + while (classCheck) { + if (classCheck.type === 'interface_declaration') { + frameworkHint = null; + break; + } + if (classCheck.type === 'class_declaration' || classCheck.type === 'program') { + break; + } + classCheck = classCheck.parent; + } + } + // Decorators appear on lines immediately before their definition; allow up to // MAX_DECORATOR_SCAN_LINES gap for blank lines / multi-line decorator stacks. const MAX_DECORATOR_SCAN_LINES = 5; diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index 2290c806a..d4c0db3eb 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -326,6 +326,78 @@ async def create_user(user: UserCreate): }); }); + describe('interface regex anchoring', () => { + it('skips Feign client interfaces (no @Controller)', async () => { + const dir = path.join(tmpDir, 'feign-skip'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/UserClient.java'), + ` +package com.example; +@FeignClient(name = "user-service") +public interface UserClient { + @GetMapping("/users") + List getUsers(); +} +`, + ); + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + expect(contracts.filter((c) => c.role === 'provider')).toHaveLength(0); + }); + + it('does NOT skip when @RestController is present', async () => { + const dir = path.join(tmpDir, 'ctrl-iface'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/UserController.java'), + ` +@RestController +@RequestMapping("/api") +public class UserController { + @GetMapping("/users") + public List list() { return null; } +} +`, + ); + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + expect(contracts.filter((c) => c.role === 'provider').length).toBeGreaterThanOrEqual(1); + }); + + it('does NOT false-positive on interface in comments', async () => { + const dir = path.join(tmpDir, 'iface-comment'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/Api.java'), + ` +// implements the interface UserApi +public class Api { + @GetMapping("/health") + public String health() { return "ok"; } +} +`, + ); + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + expect(contracts.filter((c) => c.role === 'provider').length).toBeGreaterThanOrEqual(1); + }); + + it('does NOT false-positive on interface in a string', async () => { + const dir = path.join(tmpDir, 'iface-str'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/Svc.java'), + ` +public class Svc { + String desc = "implements interface Foo"; + @GetMapping("/status") + public String status() { return desc; } +} +`, + ); + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + expect(contracts.filter((c) => c.role === 'provider').length).toBeGreaterThanOrEqual(1); + }); + }); + describe('path normalization', () => { it('strips trailing slash', async () => { const dir = path.join(tmpDir, 'trailing'); diff --git a/gitnexus/test/unit/receiver-extraction.test.ts b/gitnexus/test/unit/receiver-extraction.test.ts new file mode 100644 index 000000000..1bd6f9c39 --- /dev/null +++ b/gitnexus/test/unit/receiver-extraction.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect } from 'vitest'; +import Parser from 'tree-sitter'; +import TypeScript from 'tree-sitter-typescript'; +import type { SyntaxNode } from '../../src/core/ingestion/utils/ast-helpers.js'; +import { getProvider } from '../../src/core/ingestion/languages/index.js'; +import { SupportedLanguages } from 'gitnexus-shared'; + +const HTTP_CLIENT_RECEIVERS = new Set([ + 'axios', + 'request', + 'fetch', + 'http', + 'https', + 'got', + 'ky', + 'superagent', + 'needle', + 'undici', + 'apiclient', + 'client', + 'httpclient', + 'api', + '$http', + 'session', + 'httpservice', + 'conn', +]); + +function extractReceiverText(callNode: SyntaxNode): string { + const funcNode = callNode.childForFieldName?.('function') ?? callNode.children?.[0]; + let receiverNode = funcNode?.childForFieldName?.('object') ?? funcNode?.children?.[0]; + while (receiverNode?.type === 'member_expression' || receiverNode?.type === 'call_expression') { + if (receiverNode.type === 'member_expression') { + const p = receiverNode.childForFieldName?.('property'); + if (p) { + receiverNode = p; + } else { + break; + } + } else { + const inner = receiverNode.childForFieldName?.('function') ?? receiverNode.children?.[0]; + if (inner && inner !== receiverNode) { + receiverNode = inner; + } else { + break; + } + } + } + return receiverNode?.text?.toLowerCase() ?? ''; +} + +function extractExpressRouteReceivers(parser: Parser, code: string) { + const provider = getProvider(SupportedLanguages.TypeScript); + const tree = parser.parse(code); + const query = new Parser.Query(parser.getLanguage(), provider.treeSitterQueries!); + const results: Array<{ method: string; path: string; receiverText: string }> = []; + for (const match of query.matches(tree.rootNode)) { + const cm: Record = {}; + for (const c of match.captures) cm[c.name] = c.node; + if (cm['express_route'] && cm['express_route.method'] && cm['express_route.path']) { + results.push({ + method: cm['express_route.method'].text, + path: cm['express_route.path'].text, + receiverText: extractReceiverText(cm['express_route']), + }); + } + } + return results; +} + +describe('receiver extraction (express_route walk)', () => { + const parser = new Parser(); + parser.setLanguage(TypeScript.typescript); + + it('bare identifier: app.get()', () => { + const r = extractExpressRouteReceivers(parser, 'app.get("/api/users", h);'); + expect(r[0]?.receiverText).toBe('app'); + expect(HTTP_CLIENT_RECEIVERS.has('app')).toBe(false); + }); + + it('bare identifier: axios.get() is HTTP client', () => { + const r = extractExpressRouteReceivers(parser, 'axios.get("/api/users");'); + expect(r[0]?.receiverText).toBe('axios'); + expect(HTTP_CLIENT_RECEIVERS.has('axios')).toBe(true); + }); + + it('member chain: this.httpService.get()', () => { + const r = extractExpressRouteReceivers( + parser, + 'class S { f() { this.httpService.get("/d"); } }', + ); + const hit = r.find((x) => x.path === '/d'); + expect(hit?.receiverText).toBe('httpservice'); + expect(HTTP_CLIENT_RECEIVERS.has('httpservice')).toBe(true); + }); + + it('member chain: this.client.post()', () => { + const r = extractExpressRouteReceivers(parser, 'class A { s() { this.client.post("/x"); } }'); + const hit = r.find((x) => x.path === '/x'); + expect(hit?.receiverText).toBe('client'); + expect(HTTP_CLIENT_RECEIVERS.has('client')).toBe(true); + }); + + it('call_expression: getClient().get()', () => { + const r = extractExpressRouteReceivers(parser, 'getClient().get("/api/data");'); + expect(r.find((x) => x.path === '/api/data')?.receiverText).toBe('getclient'); + }); + + it('call_expression: createHttpClient().post()', () => { + const r = extractExpressRouteReceivers(parser, 'createHttpClient().post("/s");'); + expect(r.find((x) => x.path === '/s')?.receiverText).toBe('createhttpclient'); + }); + + it('mixed: factory().api.get()', () => { + const r = extractExpressRouteReceivers(parser, 'factory().api.get("/items");'); + expect(r.find((x) => x.path === '/items')?.receiverText).toBe('api'); + expect(HTTP_CLIENT_RECEIVERS.has('api')).toBe(true); + }); + + it('router.post() is NOT an HTTP client', () => { + const r = extractExpressRouteReceivers(parser, 'router.post("/api/items", h);'); + expect(r[0]?.receiverText).toBe('router'); + expect(HTTP_CLIENT_RECEIVERS.has('router')).toBe(false); + }); +}); From 49112016645a2c1894cbb72a6e5ff4bafced5371 Mon Sep 17 00:00:00 2001 From: "Mr. WorldwideBrown" Date: Sat, 11 Apr 2026 15:59:52 +0530 Subject: [PATCH 13/67] fix: map diff hunks to symbol line ranges in detect_changes (#779) * fix: map diff hunks to symbol line ranges in detect_changes The detect_changes tool previously used `git diff --name-only` and picked the first 20 arbitrary symbols from each changed file. This produced false positives (unchanged symbols reported as modified) and false negatives (actually changed symbols dropped by the LIMIT). Now uses `git diff -U0` to get unified diff with hunk headers, parses the @@ line ranges, and queries for symbols whose [startLine, endLine] range overlaps the diff hunks. Only truly touched symbols are reported. Also fixed the CONTAINS path match to ENDS WITH to prevent cross-file false positives from substring matching. Fixes #758 * fix: address review feedback - variable shadowing, batch queries, tests - Rename `params` to `queryParams` in detectChanges hunk-mapping loop to avoid shadowing the outer method parameter - Replace N+1 per-symbol process lookup with a single batched query using WHERE n.id IN $ids (same pattern as impact BFS traversal) - Add unit tests for parseDiffHunks covering single/multi file, single/multi hunk, omitted count, pure-deletion, and empty input * style: fix prettier formatting in parse-diff-hunks test --- gitnexus/src/mcp/local/local-backend.ts | 90 ++++++++------- gitnexus/src/storage/git.ts | 35 ++++++ gitnexus/test/unit/parse-diff-hunks.test.ts | 115 ++++++++++++++++++++ 3 files changed, 203 insertions(+), 37 deletions(-) create mode 100644 gitnexus/test/unit/parse-diff-hunks.test.ts diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 91c3a5b69..041bd27eb 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -21,6 +21,7 @@ export { isWriteQuery }; // at MCP server startup — crashes on unsupported Node ABI versions (#89) // git utilities available if needed // import { isGitRepo, getCurrentCommit, getGitRoot } from '../../storage/git.js'; +import { parseDiffHunks, type FileDiff } from '../../storage/git.js'; import { listRegisteredRepos, cleanupOldKuzuFiles, @@ -1528,33 +1529,31 @@ export class LocalBackend { let diffArgs: string[]; switch (scope) { case 'staged': - diffArgs = ['diff', '--staged', '--name-only']; + diffArgs = ['diff', '--staged', '-U0']; break; case 'all': - diffArgs = ['diff', 'HEAD', '--name-only']; + diffArgs = ['diff', 'HEAD', '-U0']; break; case 'compare': if (!params.base_ref) return { error: 'base_ref is required for "compare" scope' }; - diffArgs = ['diff', params.base_ref, '--name-only']; + diffArgs = ['diff', params.base_ref, '-U0']; break; case 'unstaged': default: - diffArgs = ['diff', '--name-only']; + diffArgs = ['diff', '-U0']; break; } - let changedFiles: string[]; + let diffOutput: string; try { - const output = execFileSync('git', diffArgs, { cwd: repo.repoPath, encoding: 'utf-8' }); - changedFiles = output - .trim() - .split('\n') - .filter((f) => f.length > 0); + diffOutput = execFileSync('git', diffArgs, { cwd: repo.repoPath, encoding: 'utf-8' }); } catch (err: any) { return { error: `Git diff failed: ${err.message}` }; } - if (changedFiles.length === 0) { + const fileDiffs: FileDiff[] = parseDiffHunks(diffOutput); + + if (fileDiffs.length === 0) { return { summary: { changed_count: 0, @@ -1567,27 +1566,39 @@ export class LocalBackend { }; } - // Map changed files to indexed symbols + // Map diff hunks to indexed symbols via range overlap const changedSymbols: any[] = []; - for (const file of changedFiles) { - const normalizedFile = file.replace(/\\/g, '/'); + for (const fileDiff of fileDiffs) { + if (fileDiff.hunks.length === 0) continue; + + // Build range overlap conditions for all hunks in this file + const overlapConditions = fileDiff.hunks + .map((_, i) => `(n.startLine <= $hunkEnd${i} AND n.endLine >= $hunkStart${i})`) + .join(' OR '); + + const queryParams: Record = { filePath: fileDiff.filePath }; + fileDiff.hunks.forEach((hunk, i) => { + queryParams[`hunkStart${i}`] = hunk.startLine; + queryParams[`hunkEnd${i}`] = hunk.endLine; + }); + + const symbolQuery = ` + MATCH (n) WHERE n.filePath ENDS WITH $filePath + AND n.startLine IS NOT NULL AND n.endLine IS NOT NULL + AND (${overlapConditions}) + RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, + n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine + `; + try { - const symbols = await executeParameterized( - repo.id, - ` - MATCH (n) WHERE n.filePath CONTAINS $filePath - RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath - LIMIT 20 - `, - { filePath: normalizedFile }, - ); - for (const sym of symbols) { + const rows = await executeParameterized(repo.id, symbolQuery, queryParams); + for (const sym of rows) { changedSymbols.push({ id: sym.id || sym[0], name: sym.name || sym[1], type: sym.type || sym[2], filePath: sym.filePath || sym[3], - change_type: 'Modified', + change_type: 'touched', }); } } catch (e) { @@ -1595,32 +1606,37 @@ export class LocalBackend { } } - // Find affected processes + // Find affected processes -- single batched query instead of N+1 const affectedProcesses = new Map(); - for (const sym of changedSymbols) { + if (changedSymbols.length > 0) { + const symIds = changedSymbols.map((s) => s.id); + const symNameById = new Map(changedSymbols.map((s) => [s.id, s.name])); try { const procs = await executeParameterized( repo.id, ` - MATCH (n {id: $nodeId})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process) - RETURN p.id AS pid, p.heuristicLabel AS label, p.processType AS processType, p.stepCount AS stepCount, r.step AS step + MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process) + WHERE n.id IN $ids + RETURN n.id AS nodeId, p.id AS pid, p.heuristicLabel AS label, + p.processType AS processType, p.stepCount AS stepCount, r.step AS step `, - { nodeId: sym.id }, + { ids: symIds }, ); for (const proc of procs) { - const pid = proc.pid || proc[0]; + const nodeId = proc.nodeId || proc[0]; + const pid = proc.pid || proc[1]; if (!affectedProcesses.has(pid)) { affectedProcesses.set(pid, { id: pid, - name: proc.label || proc[1], - process_type: proc.processType || proc[2], - step_count: proc.stepCount || proc[3], + name: proc.label || proc[2], + process_type: proc.processType || proc[3], + step_count: proc.stepCount || proc[4], changed_steps: [], }); } affectedProcesses.get(pid)!.changed_steps.push({ - symbol: sym.name, - step: proc.step || proc[4], + symbol: symNameById.get(nodeId) ?? nodeId, + step: proc.step || proc[5], }); } } catch (e) { @@ -1642,7 +1658,7 @@ export class LocalBackend { summary: { changed_count: changedSymbols.length, affected_count: processCount, - changed_files: changedFiles.length, + changed_files: fileDiffs.length, risk_level: risk, }, changed_symbols: changedSymbols, diff --git a/gitnexus/src/storage/git.ts b/gitnexus/src/storage/git.ts index ebd3f2c55..b0e9e6d3e 100644 --- a/gitnexus/src/storage/git.ts +++ b/gitnexus/src/storage/git.ts @@ -52,3 +52,38 @@ export const hasGitDir = (dirPath: string): boolean => { return false; } }; + +export interface DiffHunk { + startLine: number; + endLine: number; +} + +export interface FileDiff { + filePath: string; + hunks: DiffHunk[]; +} + +/** + * Parse unified diff output (with -U0) into per-file hunk ranges. + * Extracts the new-file line ranges from @@ hunk headers. + */ +export function parseDiffHunks(diffOutput: string): FileDiff[] { + const files: FileDiff[] = []; + let current: FileDiff | null = null; + for (const line of diffOutput.split('\n')) { + if (line.startsWith('+++ b/')) { + current = { filePath: line.slice(6), hunks: [] }; + files.push(current); + } else if (line.startsWith('@@') && current) { + const match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/); + if (match) { + const start = parseInt(match[1], 10); + const count = match[2] !== undefined ? parseInt(match[2], 10) : 1; + if (count > 0) { + current.hunks.push({ startLine: start, endLine: start + count - 1 }); + } + } + } + } + return files; +} diff --git a/gitnexus/test/unit/parse-diff-hunks.test.ts b/gitnexus/test/unit/parse-diff-hunks.test.ts new file mode 100644 index 000000000..7b8c3d1a0 --- /dev/null +++ b/gitnexus/test/unit/parse-diff-hunks.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from 'vitest'; +import { parseDiffHunks } from '../../src/storage/git.js'; + +describe('parseDiffHunks', () => { + it('parses a single file with one hunk', () => { + const diff = [ + 'diff --git a/src/foo.ts b/src/foo.ts', + '--- a/src/foo.ts', + '+++ b/src/foo.ts', + '@@ -10,0 +11,3 @@ some context', + '+line1', + '+line2', + '+line3', + ].join('\n'); + const result = parseDiffHunks(diff); + expect(result).toHaveLength(1); + expect(result[0].filePath).toBe('src/foo.ts'); + expect(result[0].hunks).toEqual([{ startLine: 11, endLine: 13 }]); + }); + + it('parses multiple hunks in a single file', () => { + const diff = [ + 'diff --git a/src/bar.ts b/src/bar.ts', + '--- a/src/bar.ts', + '+++ b/src/bar.ts', + '@@ -5,2 +5,4 @@ context', + ' unchanged', + '+added', + '@@ -20,0 +22,1 @@ more context', + '+another line', + ].join('\n'); + const result = parseDiffHunks(diff); + expect(result).toHaveLength(1); + expect(result[0].hunks).toHaveLength(2); + expect(result[0].hunks[0]).toEqual({ startLine: 5, endLine: 8 }); + expect(result[0].hunks[1]).toEqual({ startLine: 22, endLine: 22 }); + }); + + it('parses multiple files', () => { + const diff = [ + 'diff --git a/a.ts b/a.ts', + '--- a/a.ts', + '+++ b/a.ts', + '@@ -1,0 +1,2 @@', + '+line', + 'diff --git a/b.ts b/b.ts', + '--- a/b.ts', + '+++ b/b.ts', + '@@ -10,3 +10,5 @@', + ' ctx', + ].join('\n'); + const result = parseDiffHunks(diff); + expect(result).toHaveLength(2); + expect(result[0].filePath).toBe('a.ts'); + expect(result[0].hunks).toEqual([{ startLine: 1, endLine: 2 }]); + expect(result[1].filePath).toBe('b.ts'); + expect(result[1].hunks).toEqual([{ startLine: 10, endLine: 14 }]); + }); + + it('handles single-line hunks without count', () => { + // When count is omitted from @@ header, it defaults to 1 + const diff = ['+++ b/src/single.ts', '@@ -5,0 +6 @@ context', '+one line'].join('\n'); + const result = parseDiffHunks(diff); + expect(result).toHaveLength(1); + expect(result[0].hunks).toEqual([{ startLine: 6, endLine: 6 }]); + }); + + it('skips pure-deletion hunks (count=0)', () => { + const diff = ['+++ b/src/del.ts', '@@ -10,3 +10,0 @@ context'].join('\n'); + const result = parseDiffHunks(diff); + expect(result).toHaveLength(1); + expect(result[0].hunks).toHaveLength(0); + }); + + it('returns empty array for empty diff output', () => { + expect(parseDiffHunks('')).toEqual([]); + }); + + it('returns empty array for diff with no file headers', () => { + expect(parseDiffHunks('nothing useful here\n')).toEqual([]); + }); + + it('assigns hunks to the correct file when files are interleaved', () => { + // Realistic multi-file diff with context lines between + const diff = [ + 'diff --git a/src/alpha.ts b/src/alpha.ts', + 'index abc..def 100644', + '--- a/src/alpha.ts', + '+++ b/src/alpha.ts', + '@@ -100,0 +101,2 @@ export function alpha() {', + '+ const x = 1;', + '+ return x;', + 'diff --git a/src/beta.ts b/src/beta.ts', + 'index 111..222 100644', + '--- a/src/beta.ts', + '+++ b/src/beta.ts', + '@@ -50,0 +51,1 @@ export class Beta {', + '+ private val = 0;', + '@@ -80,0 +82,3 @@ export class Beta {', + '+ doStuff() {', + '+ return this.val;', + '+ }', + ].join('\n'); + const result = parseDiffHunks(diff); + expect(result).toHaveLength(2); + + expect(result[0].filePath).toBe('src/alpha.ts'); + expect(result[0].hunks).toEqual([{ startLine: 101, endLine: 102 }]); + + expect(result[1].filePath).toBe('src/beta.ts'); + expect(result[1].hunks).toHaveLength(2); + expect(result[1].hunks[0]).toEqual({ startLine: 51, endLine: 51 }); + expect(result[1].hunks[1]).toEqual({ startLine: 82, endLine: 84 }); + }); +}); From 6d9ec1009e679da19a8942271d2b1c6a8d2633f5 Mon Sep 17 00:00:00 2001 From: "Mr. WorldwideBrown" Date: Sat, 11 Apr 2026 16:47:40 +0530 Subject: [PATCH 14/67] fix: load VECTOR extension during DB init for semantic search (#782) * fix: load VECTOR extension during DB init for semantic search The VECTOR extension was only loaded inside the embedding generation pipeline (createVectorIndex). On a fresh gitnexus serve session, semantic and hybrid search failed because QUERY_VECTOR_INDEX was unknown. Now loads the VECTOR extension alongside FTS during database initialization in both the single-connection and pool-based paths. Fixes #766 * fix: reset vectorExtensionLoaded on DB close and retry paths The vectorExtensionLoaded flag was not being reset in closeLbug() or the busy-retry cleanup path in withLbugDb(). This caused the VECTOR extension to not be re-loaded after a close+re-init cycle, breaking semantic search on reconnection. Also resets shared.ftsLoaded and shared.vectorLoaded in the pool adapter closeOne() for external DB entries, preventing stale extension state when the pool is re-opened. Adds integration tests covering vector extension loading, idempotency, and state reset on both close and busy-retry paths. * fix: set ftsLoaded flag in initLbugWithDb to avoid redundant extension reloads * fix: set shared.vectorLoaded flag in initLbugWithDb to avoid redundant reloads --- gitnexus/src/core/lbug/lbug-adapter.ts | 34 ++++++++- gitnexus/src/core/lbug/pool-adapter.ts | 40 ++++++++-- .../integration/lbug-vector-extension.test.ts | 75 +++++++++++++++++++ 3 files changed, 142 insertions(+), 7 deletions(-) create mode 100644 gitnexus/test/integration/lbug-vector-extension.test.ts diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index 90e663f40..067625edc 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -17,6 +17,7 @@ let db: lbug.Database | null = null; let conn: lbug.Connection | null = null; let currentDbPath: string | null = null; let ftsLoaded = false; +let vectorExtensionLoaded = false; /** Expose the current Database for pool adapter reuse in tests. */ export const getDatabase = (): lbug.Database | null => db; @@ -104,6 +105,7 @@ export const withLbugDb = async (dbPath: string, operation: () => Promise) db = null; currentDbPath = null; ftsLoaded = false; + vectorExtensionLoaded = false; }); // Sleep outside the lock — no need to block others while waiting await new Promise((resolve) => setTimeout(resolve, DB_LOCK_RETRY_DELAY_MS * attempt)); @@ -135,6 +137,7 @@ const doInitLbug = async (dbPath: string) => { db = null; currentDbPath = null; ftsLoaded = false; + vectorExtensionLoaded = false; } // LadybugDB stores the database as a single file (not a directory). @@ -182,6 +185,9 @@ const doInitLbug = async (dbPath: string) => { } } + // Load VECTOR extension for semantic search support + await loadVectorExtension(); + currentDbPath = dbPath; return { db, conn }; }; @@ -807,6 +813,7 @@ export const closeLbug = async (): Promise => { } currentDbPath = null; ftsLoaded = false; + vectorExtensionLoaded = false; }; export const isLbugReady = (): boolean => conn !== null && db !== null; @@ -932,7 +939,32 @@ export const loadFTSExtension = async (): Promise => { } } }; - +/** + * Load the VECTOR extension (required before using QUERY_VECTOR_INDEX). + * Safe to call multiple times -- tracks loaded state via module-level vectorExtensionLoaded. + */ +export const loadVectorExtension = async (): Promise => { + if (vectorExtensionLoaded) return; + if (!conn) { + throw new Error('LadybugDB not initialized. Call initLbug first.'); + } + try { + await conn.query('INSTALL VECTOR'); + await conn.query('LOAD EXTENSION VECTOR'); + vectorExtensionLoaded = true; + } catch (err: any) { + const msg = err?.message || ''; + if ( + msg.includes('already loaded') || + msg.includes('already installed') || + msg.includes('already exists') + ) { + vectorExtensionLoaded = true; + } else { + console.error('GitNexus: VECTOR extension load failed:', msg); + } + } +}; /** * Create a full-text search index on a table * @param tableName - The node table name (e.g., 'File', 'CodeSymbol') diff --git a/gitnexus/src/core/lbug/pool-adapter.ts b/gitnexus/src/core/lbug/pool-adapter.ts index 0bb001dc6..162ddbfa6 100644 --- a/gitnexus/src/core/lbug/pool-adapter.ts +++ b/gitnexus/src/core/lbug/pool-adapter.ts @@ -44,6 +44,7 @@ interface SharedDB { db: lbug.Database; refCount: number; ftsLoaded: boolean; + vectorLoaded: boolean; /** When true, closeOne skips db.close() — the Database is owned externally. */ external?: boolean; } @@ -148,6 +149,8 @@ function closeOne(repoId: string): void { // or remove from cache. Keep the entry so future initLbug() calls // for the same dbPath reuse it instead of hitting a file lock. shared.refCount = 0; + shared.ftsLoaded = false; + shared.vectorLoaded = false; } else { shared.db.close().catch(() => {}); dbCache.delete(entry.dbPath); @@ -276,7 +279,7 @@ async function doInitLbug(repoId: string, dbPath: string): Promise { true, // readOnly ); restoreStdout(); - shared = { db, refCount: 0, ftsLoaded: false }; + shared = { db, refCount: 0, ftsLoaded: false, vectorLoaded: false }; dbCache.set(dbPath, shared); break; } catch (err: any) { @@ -325,6 +328,17 @@ async function doInitLbug(repoId: string, dbPath: string): Promise { } } + // Load VECTOR extension once per shared Database for semantic search support. + if (!shared.vectorLoaded) { + try { + await available[0].query('INSTALL VECTOR'); + await available[0].query('LOAD EXTENSION VECTOR'); + shared.vectorLoaded = true; + } catch { + // VECTOR extension may not be available + } + } + // Register pool entry only after all connections are pre-warmed and FTS is // loaded. Concurrent executeQuery calls see either "not initialized" // (and throw cleanly) or a fully ready pool — never a half-built one. @@ -368,7 +382,7 @@ export async function initLbugWithDb( // closeOne() respects the external flag and skips db.close(). let shared = dbCache.get(dbPath); if (!shared) { - shared = { db: existingDb, refCount: 0, ftsLoaded: false, external: true }; + shared = { db: existingDb, refCount: 0, ftsLoaded: false, vectorLoaded: false, external: true }; dbCache.set(dbPath, shared); } shared.refCount++; @@ -384,10 +398,24 @@ export async function initLbugWithDb( } // Load FTS extension if not already loaded on this Database - try { - await available[0].query('LOAD EXTENSION fts'); - } catch { - // Extension may already be loaded or not installed + if (!shared.ftsLoaded) { + try { + await available[0].query('LOAD EXTENSION fts'); + shared.ftsLoaded = true; + } catch { + // Extension may already be loaded or not installed + } + } + + // Load VECTOR extension for semantic search support + if (!shared.vectorLoaded) { + try { + await available[0].query('INSTALL VECTOR'); + await available[0].query('LOAD EXTENSION VECTOR'); + shared.vectorLoaded = true; + } catch { + // VECTOR extension may not be available + } } pool.set(repoId, { diff --git a/gitnexus/test/integration/lbug-vector-extension.test.ts b/gitnexus/test/integration/lbug-vector-extension.test.ts new file mode 100644 index 000000000..feb51d08f --- /dev/null +++ b/gitnexus/test/integration/lbug-vector-extension.test.ts @@ -0,0 +1,75 @@ +/** + * Integration Tests: Vector extension loading and state reset + * + * Tests: loadVectorExtension idempotency, vectorExtensionLoaded reset + * on closeLbug and busy-retry cleanup paths. + * + * Follows existing lbug integration test patterns (lbug-core-adapter, + * lbug-lock-retry). + */ +import { describe, it, expect } from 'vitest'; +import { withTestLbugDB } from '../helpers/test-indexed-db.js'; + +withTestLbugDB('vector-extension', (handle) => { + describe('loadVectorExtension', () => { + it('loads the VECTOR extension without error', async () => { + const { loadVectorExtension } = await import('../../src/core/lbug/lbug-adapter.js'); + + // Should resolve without throwing -- idempotent if already loaded by doInitLbug + await expect(loadVectorExtension()).resolves.toBeUndefined(); + }); + + it('is idempotent -- calling twice does not throw', async () => { + const { loadVectorExtension } = await import('../../src/core/lbug/lbug-adapter.js'); + + await loadVectorExtension(); + await expect(loadVectorExtension()).resolves.toBeUndefined(); + }); + }); + + describe('vectorExtensionLoaded reset on closeLbug', () => { + it('re-initializes vector extension after close + re-init cycle', async () => { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + + // Ensure vector extension is loaded + await adapter.loadVectorExtension(); + + // Close the adapter -- should reset vectorExtensionLoaded + await adapter.closeLbug(); + expect(adapter.isLbugReady()).toBe(false); + + // Re-initialize -- doInitLbug calls loadVectorExtension internally + await adapter.initLbug(handle.dbPath); + expect(adapter.isLbugReady()).toBe(true); + + // loadVectorExtension should succeed (not skip due to stale flag) + await expect(adapter.loadVectorExtension()).resolves.toBeUndefined(); + }); + }); + + describe('vectorExtensionLoaded reset on busy-retry cleanup', () => { + it('withLbugDb resets vectorExtensionLoaded on BUSY retry', async () => { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + + // Ensure vector extension is loaded + await adapter.loadVectorExtension(); + + // Simulate a BUSY error on first attempt, success on second. + // The retry path should reset vectorExtensionLoaded so the + // re-initialized DB gets a fresh extension load. + let callCount = 0; + const result = await adapter.withLbugDb(handle.dbPath, async () => { + callCount++; + if (callCount === 1) throw new Error('database is BUSY'); + return 'recovered'; + }); + + expect(result).toBe('recovered'); + expect(callCount).toBe(2); + + // After recovery, vector extension should still be loadable + // (the flag was reset and re-loaded during re-init) + await expect(adapter.loadVectorExtension()).resolves.toBeUndefined(); + }); + }); +}); From a162f66254615cae83545d56dee5e4fa9688b665 Mon Sep 17 00:00:00 2001 From: Louis Chu Date: Sat, 11 Apr 2026 03:04:05 -0700 Subject: [PATCH 15/67] fix(web): keep chat pinned on async content growth --- gitnexus-web/src/components/RightPanel.tsx | 10 +- gitnexus-web/src/hooks/useAutoScroll.ts | 125 +++++++---- .../test/unit/use-auto-scroll.test.tsx | 202 +++++++++++++----- 3 files changed, 238 insertions(+), 99 deletions(-) diff --git a/gitnexus-web/src/components/RightPanel.tsx b/gitnexus-web/src/components/RightPanel.tsx index 35b3a2833..3e063a168 100644 --- a/gitnexus-web/src/components/RightPanel.tsx +++ b/gitnexus-web/src/components/RightPanel.tsx @@ -38,7 +38,7 @@ export const RightPanel = () => { const [activeTab, setActiveTab] = useState<'chat' | 'processes'>('chat'); const textareaRef = useRef(null); // Keep streamed replies pinned unless the user intentionally scrolls away from the bottom. - const { scrollContainerRef, messagesEndRef, isAtBottom, scrollToBottom } = useAutoScroll( + const { scrollContainerRef, messagesContainerRef, isAtBottom, scrollToBottom } = useAutoScroll( chatMessages, isChatLoading, ); @@ -314,7 +314,7 @@ export const RightPanel = () => {
) : ( -
+
{chatMessages.map((message) => (
{/* User message - compact label style */} @@ -390,14 +390,12 @@ export const RightPanel = () => { ))}
)} - {/* Scroll anchor */} -
{/* Scroll to bottom */} {/* Input */} diff --git a/gitnexus-web/src/hooks/useAutoScroll.ts b/gitnexus-web/src/hooks/useAutoScroll.ts index 075152b9e..58a0aedcc 100644 --- a/gitnexus-web/src/hooks/useAutoScroll.ts +++ b/gitnexus-web/src/hooks/useAutoScroll.ts @@ -1,45 +1,71 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; -const BOTTOM_THRESHOLD = 100; +const DEFAULT_BOTTOM_THRESHOLD = 100; +const USER_SCROLL_EPSILON = 5; export interface UseAutoScrollResult { scrollContainerRef: React.RefObject; - messagesEndRef: React.RefObject; + messagesContainerRef: React.RefObject; isAtBottom: boolean; - scrollToBottom: () => void; + scrollToBottom: (behavior?: ScrollBehavior) => void; } -function isNearBottom(element: HTMLDivElement): boolean { - return element.scrollHeight - element.scrollTop - element.clientHeight <= BOTTOM_THRESHOLD; +function isNearBottom(element: HTMLElement, threshold: number): boolean { + return element.scrollHeight - element.scrollTop - element.clientHeight <= threshold; } -export function useAutoScroll( - chatMessages: unknown[], +export function useAutoScroll( + chatMessages: T[], isChatLoading: boolean, + bottomThreshold = DEFAULT_BOTTOM_THRESHOLD, ): UseAutoScrollResult { const scrollContainerRef = useRef(null); - const messagesEndRef = useRef(null); + const messagesContainerRef = useRef(null); const [isAtBottom, setIsAtBottom] = useState(true); + const shouldStickToBottomRef = useRef(true); const lastScrollTopRef = useRef(0); - const frameIdRef = useRef(null); + const scrollFrameIdRef = useRef(null); const syncScrollState = useCallback(() => { const element = scrollContainerRef.current; if (!element) return; - const nearBottom = isNearBottom(element); + const currentScrollTop = element.scrollTop; + const nearBottom = isNearBottom(element, bottomThreshold); if (nearBottom) { shouldStickToBottomRef.current = true; - } else if (element.scrollTop < lastScrollTopRef.current) { + } else if (currentScrollTop < lastScrollTopRef.current - USER_SCROLL_EPSILON) { shouldStickToBottomRef.current = false; } - lastScrollTopRef.current = element.scrollTop; + lastScrollTopRef.current = currentScrollTop; setIsAtBottom(nearBottom); - }, []); + }, [bottomThreshold]); + + const scrollToBottom = useCallback( + (behavior: ScrollBehavior = 'smooth') => { + const element = scrollContainerRef.current; + if (!element) return; + + shouldStickToBottomRef.current = true; + + if (behavior === 'auto') { + element.scrollTop = element.scrollHeight; + lastScrollTopRef.current = element.scrollTop; + setIsAtBottom(isNearBottom(element, bottomThreshold)); + return; + } + + element.scrollTo({ + top: element.scrollHeight, + behavior, + }); + }, + [bottomThreshold], + ); useEffect(() => { const element = scrollContainerRef.current; @@ -48,12 +74,12 @@ export function useAutoScroll( lastScrollTopRef.current = element.scrollTop; const handleScroll = () => { - if (frameIdRef.current !== null) { - cancelAnimationFrame(frameIdRef.current); + if (scrollFrameIdRef.current !== null) { + cancelAnimationFrame(scrollFrameIdRef.current); } - frameIdRef.current = requestAnimationFrame(() => { - frameIdRef.current = null; + scrollFrameIdRef.current = requestAnimationFrame(() => { + scrollFrameIdRef.current = null; syncScrollState(); }); }; @@ -63,32 +89,57 @@ export function useAutoScroll( return () => { element.removeEventListener('scroll', handleScroll); - if (frameIdRef.current !== null) { - cancelAnimationFrame(frameIdRef.current); - frameIdRef.current = null; + + if (scrollFrameIdRef.current !== null) { + cancelAnimationFrame(scrollFrameIdRef.current); + scrollFrameIdRef.current = null; } }; }, [syncScrollState]); - const jumpToBottom = useCallback(() => { - const element = scrollContainerRef.current; - if (!element) return; + useEffect(() => { + const content = messagesContainerRef.current; + const scrollEl = scrollContainerRef.current; + if (!content || !scrollEl || typeof ResizeObserver === 'undefined') return; - element.scrollTop = element.scrollHeight; - lastScrollTopRef.current = element.scrollTop; - }, []); + let resizeFrameId: number | null = null; + + const observer = new ResizeObserver(() => { + if (shouldStickToBottomRef.current) { + if (resizeFrameId !== null) { + cancelAnimationFrame(resizeFrameId); + } + + resizeFrameId = requestAnimationFrame(() => { + resizeFrameId = null; + scrollToBottom('auto'); + }); + } else { + syncScrollState(); + } + }); + + observer.observe(content); + + return () => { + observer.disconnect(); + + if (resizeFrameId !== null) { + cancelAnimationFrame(resizeFrameId); + resizeFrameId = null; + } + }; + }, [chatMessages.length, scrollToBottom, syncScrollState]); useLayoutEffect(() => { if (!shouldStickToBottomRef.current) return; - jumpToBottom(); - setIsAtBottom(true); - }, [chatMessages, isChatLoading, jumpToBottom]); + scrollToBottom('auto'); + }, [chatMessages.length, isChatLoading, scrollToBottom]); - const scrollToBottom = useCallback(() => { - shouldStickToBottomRef.current = true; - setIsAtBottom(true); - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }); - }, []); - - return { scrollContainerRef, messagesEndRef, isAtBottom, scrollToBottom }; -} + return { + scrollContainerRef, + messagesContainerRef, + isAtBottom, + scrollToBottom, + }; +} \ No newline at end of file diff --git a/gitnexus-web/test/unit/use-auto-scroll.test.tsx b/gitnexus-web/test/unit/use-auto-scroll.test.tsx index 6d68dd823..a8bc1a795 100644 --- a/gitnexus-web/test/unit/use-auto-scroll.test.tsx +++ b/gitnexus-web/test/unit/use-auto-scroll.test.tsx @@ -8,7 +8,7 @@ interface HarnessProps { } function AutoScrollHarness({ messages, isChatLoading }: HarnessProps) { - const { scrollContainerRef, messagesEndRef, isAtBottom, scrollToBottom } = useAutoScroll( + const { scrollContainerRef, messagesContainerRef, isAtBottom, scrollToBottom } = useAutoScroll( messages, isChatLoading, ); @@ -17,9 +17,15 @@ function AutoScrollHarness({ messages, isChatLoading }: HarnessProps) { <>
{String(isAtBottom)}
-
+ {messages.length > 0 ? ( +
+ {messages.map((message, index) => ( +
{String(message)}
+ ))} +
+ ) : null}
- @@ -65,11 +71,34 @@ async function scrollContainer(element: HTMLDivElement, scrollTop: number) { await flushAnimationFrame(); } -describe('useAutoScroll', () => { - const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; +const resizeObserverInstances: ResizeObserverMock[] = []; +class ResizeObserverMock { + callback: ResizeObserverCallback; + observedElements: Element[] = []; + observe = vi.fn((element: Element) => { + this.observedElements.push(element); + }); + unobserve = vi.fn(); + disconnect = vi.fn(); + + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + resizeObserverInstances.push(this); + } +} + +async function triggerResize(instance: ResizeObserverMock) { + await act(async () => { + instance.callback([], instance as unknown as ResizeObserver); + }); + await flushAnimationFrame(); +} + +describe('useAutoScroll', () => { beforeEach(() => { vi.useFakeTimers(); + resizeObserverInstances.length = 0; vi.stubGlobal( 'requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => { @@ -82,30 +111,45 @@ describe('useAutoScroll', () => { clearTimeout(frameId); }), ); - Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + Object.defineProperty(HTMLElement.prototype, 'scrollTo', { configurable: true, - value: vi.fn(), + value: function (options: ScrollToOptions) { + if (options.top !== undefined) { + Object.defineProperty(this, 'scrollTop', { + configurable: true, + writable: true, + value: options.top, + }); + } + }, }); + vi.stubGlobal('ResizeObserver', ResizeObserverMock); }); afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); - Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { - configurable: true, - value: originalScrollIntoView, - }); + }); + + it('starts with isAtBottom true and auto-scrolls the very first message', () => { + const { rerender } = render(); + + expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('true'); + + const container = screen.getByTestId('container') as HTMLDivElement; + setScrollMetrics(container, { scrollTop: 0, scrollHeight: 500, clientHeight: 200 }); + + rerender(); + + expect(container.scrollTop).toBe(500); + expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('true'); }); it('follows streaming updates while the view stays pinned to the bottom', () => { const { rerender } = render(); const container = screen.getByTestId('container') as HTMLDivElement; - setScrollMetrics(container, { - scrollTop: 700, - scrollHeight: 1000, - clientHeight: 200, - }); + setScrollMetrics(container, { scrollTop: 700, scrollHeight: 1000, clientHeight: 200 }); rerender(); @@ -117,22 +161,13 @@ describe('useAutoScroll', () => { const { rerender } = render(); const container = screen.getByTestId('container') as HTMLDivElement; - setScrollMetrics(container, { - scrollTop: 700, - scrollHeight: 1000, - clientHeight: 200, - }); + setScrollMetrics(container, { scrollTop: 700, scrollHeight: 1000, clientHeight: 200 }); await scrollContainer(container, 700); - await scrollContainer(container, 250); expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('false'); - setScrollMetrics(container, { - scrollTop: 250, - scrollHeight: 1400, - clientHeight: 200, - }); + setScrollMetrics(container, { scrollTop: 250, scrollHeight: 1400, clientHeight: 200 }); rerender(); expect(container.scrollTop).toBe(250); @@ -142,58 +177,113 @@ describe('useAutoScroll', () => { const { rerender } = render(); const container = screen.getByTestId('container') as HTMLDivElement; - setScrollMetrics(container, { - scrollTop: 700, - scrollHeight: 1000, - clientHeight: 200, - }); + setScrollMetrics(container, { scrollTop: 700, scrollHeight: 1000, clientHeight: 200 }); await scrollContainer(container, 700); await scrollContainer(container, 250); - setScrollMetrics(container, { - scrollTop: 1120, - scrollHeight: 1400, - clientHeight: 200, - }); + setScrollMetrics(container, { scrollTop: 1120, scrollHeight: 1400, clientHeight: 200 }); await scrollContainer(container, 1120); expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('true'); - setScrollMetrics(container, { - scrollTop: 1120, - scrollHeight: 1800, - clientHeight: 200, - }); + setScrollMetrics(container, { scrollTop: 1120, scrollHeight: 1800, clientHeight: 200 }); rerender(); expect(container.scrollTop).toBe(1800); }); - it('scrollToBottom re-engages auto-scroll and uses the sentinel element', async () => { + it('scrollToBottom re-engages auto-scroll and scrolls to the container bottom', async () => { const { rerender } = render(); const container = screen.getByTestId('container') as HTMLDivElement; - const scrollIntoView = vi.mocked(HTMLElement.prototype.scrollIntoView); + const scrollTo = vi.spyOn(container, 'scrollTo'); - setScrollMetrics(container, { - scrollTop: 700, - scrollHeight: 1000, - clientHeight: 200, - }); + setScrollMetrics(container, { scrollTop: 700, scrollHeight: 1000, clientHeight: 200 }); await scrollContainer(container, 700); await scrollContainer(container, 250); fireEvent.click(screen.getByRole('button', { name: 'Scroll to bottom' })); - expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'end' }); - expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('true'); + expect(scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: 'smooth' }); + expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('false'); - setScrollMetrics(container, { - scrollTop: 250, - scrollHeight: 1600, - clientHeight: 200, - }); + setScrollMetrics(container, { scrollTop: 250, scrollHeight: 1600, clientHeight: 200 }); rerender(); expect(container.scrollTop).toBe(1600); + expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('true'); + }); + + it('re-pins to the latest bottom when inner content grows asynchronously', async () => { + render(); + const container = screen.getByTestId('container') as HTMLDivElement; + + setScrollMetrics(container, { scrollTop: 700, scrollHeight: 1000, clientHeight: 200 }); + await scrollContainer(container, 700); + + setScrollMetrics(container, { scrollTop: 1000, scrollHeight: 1450, clientHeight: 200 }); + await triggerResize(resizeObserverInstances[0]); + + expect(container.scrollTop).toBe(1450); + expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('true'); + }); + + it('does not auto-scroll on async growth after user intentionally scrolls away', async () => { + render(); + const container = screen.getByTestId('container') as HTMLDivElement; + + setScrollMetrics(container, { scrollTop: 700, scrollHeight: 1000, clientHeight: 200 }); + await scrollContainer(container, 700); + await scrollContainer(container, 250); + + setScrollMetrics(container, { scrollTop: 250, scrollHeight: 1400, clientHeight: 200 }); + await triggerResize(resizeObserverInstances[0]); + + expect(container.scrollTop).toBe(250); + expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('false'); + }); + + it('cancels the pending ResizeObserver rAF when the component unmounts', () => { + const cancelRAF = vi.mocked(cancelAnimationFrame); + + const { unmount } = render(); + const container = screen.getByTestId('container') as HTMLDivElement; + + setScrollMetrics(container, { scrollTop: 950, scrollHeight: 1000, clientHeight: 200 }); + + const callsBefore = cancelRAF.mock.calls.length; + + act(() => { + resizeObserverInstances[0].callback( + [], + resizeObserverInstances[0] as unknown as ResizeObserver, + ); + }); + + unmount(); + + expect(cancelRAF.mock.calls.length).toBeGreaterThan(callsBefore); + + expect(() => vi.runAllTimers()).not.toThrow(); + }); + + it('attaches the observer when the messages wrapper first appears and disconnects on unmount', () => { + const { rerender, unmount } = render( + , + ); + + expect(screen.queryByTestId('messages-container')).toBeNull(); + expect(resizeObserverInstances).toHaveLength(0); + + rerender(); + + const messagesContainer = screen.getByTestId('messages-container'); + const resizeObserver = resizeObserverInstances[0]; + + expect(resizeObserverInstances).toHaveLength(1); + expect(resizeObserver.observe).toHaveBeenCalledWith(messagesContainer); + + unmount(); + + expect(resizeObserver.disconnect).toHaveBeenCalledTimes(1); }); }); From 5be0537ce482a130806cfe20d6554aa800408826 Mon Sep 17 00:00:00 2001 From: JWWD | ModusOp Date: Sun, 12 Apr 2026 00:32:06 +1000 Subject: [PATCH 16/67] =?UTF-8?q?Fix=20stack=20overflow=20on=20large=20PHP?= =?UTF-8?q?=20files=20=E2=80=94=20iterative=20AST=20traversal=20(#783)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix: replace recursive AST traversal with iterative stack to prevent stack overflow on large files Fixes #752 Large PHP files (2000+ lines) with deeply nested AST structures (closures, array literals, chained method calls) cause "Maximum call stack size exceeded" during analysis. This converts three recursive tree traversal functions to iterative loops using explicit stacks: 1. `walk()` in type-env.ts — the main AST walker that processes every node. On a 2,462-line PHP controller, this recurses through 5,000-10,000+ nodes. 2. `findRelationCall()` in languages/php.ts — recursive search for Eloquent relationship calls within method bodies. 3. `findDescendant()` in utils/ast-helpers.ts — generic recursive utility used by PHP property extraction and other parsers. All three now use a while loop with an array-based stack instead of function call recursion, eliminating V8's ~10K frame call stack limit as a constraint. Tested against a production Laravel codebase with 373 PHP files (87,723 lines total, largest file 2,462 lines) — indexes successfully in 17.4s with zero errors, where the recursive version would crash with stack overflow. * Fix: reverse child push order in findRelationCall iterative traversal The iterative stack-based traversal pushed children in forward order, causing the last child to be processed first (LIFO). This reversed the original recursive left-to-right DFS order. Push children in reverse so the first child ends up on top of the stack. Co-Authored-By: Claude Opus 4.6 (1M context) * Fix: move stack declaration before processNode, rename walk to processNode Move the stack initialization above the function that pushes onto it, making the data-flow order match the code order. Rename walk to processNode since it now processes a single node rather than recursively traversing the tree. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- gitnexus/src/core/ingestion/languages/php.ts | 26 +++++++++++-------- gitnexus/src/core/ingestion/type-env.ts | 19 ++++++++++---- .../src/core/ingestion/utils/ast-helpers.ts | 15 +++++++---- 3 files changed, 39 insertions(+), 21 deletions(-) diff --git a/gitnexus/src/core/ingestion/languages/php.ts b/gitnexus/src/core/ingestion/languages/php.ts index e93010009..642c6bd83 100644 --- a/gitnexus/src/core/ingestion/languages/php.ts +++ b/gitnexus/src/core/ingestion/languages/php.ts @@ -160,18 +160,22 @@ function extractPhpPropertyDescription(propName: string, propDeclNode: SyntaxNod * Returns description like "hasMany(Post)" or null. */ function extractEloquentRelationDescription(methodNode: SyntaxNode): string | null { - function findRelationCall(node: SyntaxNode): SyntaxNode | null { - if (node.type === 'member_call_expression') { + function findRelationCall(root: SyntaxNode): SyntaxNode | null { + const stack: SyntaxNode[] = [root]; + while (stack.length > 0) { + const node = stack.pop()!; + if (node.type === 'member_call_expression') { + const children = node.children ?? []; + const objectNode = children.find( + (c: SyntaxNode) => c.type === 'variable_name' && c.text === '$this', + ); + const nameNode = children.find((c: SyntaxNode) => c.type === 'name'); + if (objectNode && nameNode && ELOQUENT_RELATIONS.has(nameNode.text)) return node; + } const children = node.children ?? []; - const objectNode = children.find( - (c: SyntaxNode) => c.type === 'variable_name' && c.text === '$this', - ); - const nameNode = children.find((c: SyntaxNode) => c.type === 'name'); - if (objectNode && nameNode && ELOQUENT_RELATIONS.has(nameNode.text)) return node; - } - for (const child of node.children ?? []) { - const found = findRelationCall(child); - if (found) return found; + for (let i = children.length - 1; i >= 0; i--) { + stack.push(children[i]); + } } return null; } diff --git a/gitnexus/src/core/ingestion/type-env.ts b/gitnexus/src/core/ingestion/type-env.ts index bac187b26..c8eba5819 100644 --- a/gitnexus/src/core/ingestion/type-env.ts +++ b/gitnexus/src/core/ingestion/type-env.ts @@ -1088,7 +1088,11 @@ export const buildTypeEnv = ( } }; - const walk = (node: SyntaxNode, currentScope: string): void => { + const stack: Array<{ node: SyntaxNode; scope: string }> = [ + { node: tree.rootNode, scope: FILE_SCOPE }, + ]; + + const processNode = (node: SyntaxNode, currentScope: string): void => { // Fast skip: subtrees that can never contain type-relevant nodes (leaf-like literals). if (SKIP_SUBTREE_TYPES.has(node.type)) return; @@ -1205,14 +1209,19 @@ export const buildTypeEnv = ( } } - // Recurse into children - for (let i = 0; i < node.childCount; i++) { + // Push children onto stack (reverse order so first child is processed first) + for (let i = node.childCount - 1; i >= 0; i--) { const child = node.child(i); - if (child) walk(child, scope); + if (child) stack.push({ node: child, scope }); } }; - walk(tree.rootNode, FILE_SCOPE); + // Iterative traversal using explicit stack instead of recursion + // to avoid "Maximum call stack size exceeded" on large files (2000+ lines) + while (stack.length > 0) { + const { node, scope } = stack.pop()!; + processNode(node, scope); + } // Phase 14: Seed cross-file bindings from upstream files AFTER walk // (local declarations from walk() take precedence — first-writer-wins) diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index 825600ddd..49e82938a 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -412,11 +412,16 @@ export const CALL_ARGUMENT_LIST_TYPES = new Set(['arguments', 'argument_list', ' // ============================================================================ /** Walk an AST node depth-first, returning the first descendant with the given type. */ -export function findDescendant(node: SyntaxNode, type: string): SyntaxNode | null { - if (node.type === type) return node; - for (const child of node.children ?? []) { - const found = findDescendant(child, type); - if (found) return found; +export function findDescendant(root: SyntaxNode, type: string): SyntaxNode | null { + const stack: SyntaxNode[] = [root]; + while (stack.length > 0) { + const node = stack.pop()!; + if (node.type === type) return node; + // Push in reverse order so left children are visited first (depth-first) + const children = node.children ?? []; + for (let i = children.length - 1; i >= 0; i--) { + stack.push(children[i]); + } } return null; } From 75635638b1183ea3e67acac5b88b34ef5d6bd19e Mon Sep 17 00:00:00 2001 From: smTheApex <61349745+Prota100@users.noreply.github.com> Date: Sun, 12 Apr 2026 01:41:51 +0900 Subject: [PATCH 17/67] feat(csharp): capture interface-to-interface heritage (#789) The C# tree-sitter query set only matched `base_list` on `class_declaration`, so interfaces extending other interfaces (`interface IFoo : IBar`) were never captured as heritage edges. This broke transitive interface implementation chains. For example, given: interface IBase { } interface IFoo : IBase { } class MyClass : IFoo { } only `MyClass -> IFoo` was emitted, and the `IFoo -> IBase` edge was silently dropped. Any analysis that relies on walking the full interface inheritance chain (e.g. "which classes implement IBase?") therefore returned incomplete results. This patch adds two new query patterns mirroring the existing class_declaration heritage patterns, but targeting `interface_declaration`: (interface_declaration name: (identifier) @heritage.class (base_list (identifier) @heritage.extends)) @heritage (interface_declaration name: (identifier) @heritage.class (base_list (generic_name (identifier) @heritage.extends))) @heritage The existing heritage-processor pipeline already handles these captures correctly once the query emits them, so no changes are needed outside of tree-sitter-queries.ts. Testing: - New fixture `csharp-interface-heritage/` covering: * interface : interface (single base) * interface : interface, interface (multiple bases) * class : interface (where that interface derives from others) - 6 new test cases in test/integration/resolvers/csharp.test.ts asserting exactly 4 IMPLEMENTS edges and 0 EXTENDS edges for the fixture. - Full C# resolver suite: 175/175 passing, no regressions. Co-authored-by: Prota100 --- .../src/core/ingestion/tree-sitter-queries.ts | 8 +++ .../src/IAuditableService.cs | 6 +++ .../src/IBarService.cs | 6 +++ .../src/IBaseInterface.cs | 6 +++ .../src/IFooService.cs | 6 +++ .../src/MyService.cs | 12 +++++ .../test/integration/resolvers/csharp.test.ts | 51 +++++++++++++++++++ 7 files changed, 95 insertions(+) create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IAuditableService.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IBarService.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IBaseInterface.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IFooService.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/MyService.cs diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index a6c8d74b4..7180806ae 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -623,6 +623,14 @@ export const CSHARP_QUERIES = ` (class_declaration name: (identifier) @heritage.class (base_list (generic_name (identifier) @heritage.extends))) @heritage +; Interface inheritance: interface IFoo : IBar / interface IFoo : IBar, IBaz +; Without these patterns, interface-to-interface relationships are never +; captured, so transitive "class X implements IBar" chains are broken. +(interface_declaration name: (identifier) @heritage.class + (base_list (identifier) @heritage.extends)) @heritage +(interface_declaration name: (identifier) @heritage.class + (base_list (generic_name (identifier) @heritage.extends))) @heritage + ; Write access: obj.field = value (assignment_expression left: (member_access_expression diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IAuditableService.cs b/gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IAuditableService.cs new file mode 100644 index 000000000..e468d4edc --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IAuditableService.cs @@ -0,0 +1,6 @@ +namespace Contracts; + +public interface IAuditableService : IFooService, IBarService +{ + string AuditTrail { get; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IBarService.cs b/gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IBarService.cs new file mode 100644 index 000000000..003bdc664 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IBarService.cs @@ -0,0 +1,6 @@ +namespace Contracts; + +public interface IBarService +{ + void BarMethod(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IBaseInterface.cs b/gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IBaseInterface.cs new file mode 100644 index 000000000..914c68be4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IBaseInterface.cs @@ -0,0 +1,6 @@ +namespace Contracts; + +public interface IBaseInterface +{ + void BaseMethod(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IFooService.cs b/gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IFooService.cs new file mode 100644 index 000000000..bda11e0d9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/IFooService.cs @@ -0,0 +1,6 @@ +namespace Contracts; + +public interface IFooService : IBaseInterface +{ + void FooMethod(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/MyService.cs b/gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/MyService.cs new file mode 100644 index 000000000..d0e41df1b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-interface-heritage/src/MyService.cs @@ -0,0 +1,12 @@ +namespace Services; + +using Contracts; + +public class MyService : IAuditableService +{ + public string AuditTrail => "audit"; + + public void BaseMethod() { } + public void FooMethod() { } + public void BarMethod() { } +} diff --git a/gitnexus/test/integration/resolvers/csharp.test.ts b/gitnexus/test/integration/resolvers/csharp.test.ts index af2dbb786..83ffd3585 100644 --- a/gitnexus/test/integration/resolvers/csharp.test.ts +++ b/gitnexus/test/integration/resolvers/csharp.test.ts @@ -1997,3 +1997,54 @@ describe('C# User implements IValidator — interface default method (SM-11)', ( expect(validateCall!.source).toBe('Run'); }); }); + +// --------------------------------------------------------------------------- +// Interface-to-interface heritage (single + multi base interface) +// --------------------------------------------------------------------------- + +describe('C# interface-to-interface heritage', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'csharp-interface-heritage'), () => {}); + }, 60000); + + it('detects 1 class and 4 interfaces', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['MyService']); + expect(getNodesByLabel(result, 'Interface')).toEqual([ + 'IAuditableService', + 'IBarService', + 'IBaseInterface', + 'IFooService', + ]); + }); + + it('emits no EXTENDS edges (fixture has no class inheritance)', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_.length).toBe(0); + }); + + it('emits IMPLEMENTS edge: IFooService → IBaseInterface (single base interface)', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + const targets = edgeSet(implements_); + expect(targets).toContain('IFooService → IBaseInterface'); + }); + + it('emits IMPLEMENTS edges: IAuditableService → IFooService, IBarService (multi base interfaces)', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + const targets = edgeSet(implements_); + expect(targets).toContain('IAuditableService → IFooService'); + expect(targets).toContain('IAuditableService → IBarService'); + }); + + it('emits IMPLEMENTS edge: MyService → IAuditableService (class implements derived interface)', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + const targets = edgeSet(implements_); + expect(targets).toContain('MyService → IAuditableService'); + }); + + it('emits exactly 4 IMPLEMENTS edges total', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + expect(implements_.length).toBe(4); + }); +}); From 9364739fb4b804c10c53d02adc6d764ee15f17a7 Mon Sep 17 00:00:00 2001 From: Dave Brophy Date: Sun, 12 Apr 2026 00:24:02 +0700 Subject: [PATCH 18/67] fix: restore tree-sitter-swift postinstall patch for macOS ARM64 (#788) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: restore tree-sitter-swift postinstall patch for macOS ARM64 PR #516 (77dcb06) deleted `scripts/patch-tree-sitter-swift.cjs` and the `postinstall` script entry when bumping to `tree-sitter-swift@0.7.1`, since 0.7.1 ships prebuilt darwin-arm64 binaries and no longer needs the patch. PR #538 (01ddc3e) then had to revert `tree-sitter-swift` back to `^0.6.0` (and `tree-sitter` back to `^0.21.1`) because `npm overrides` doesn't apply when gitnexus is installed via `npx -y` (gitnexus isn't the root project, so overrides are silently ignored, producing ERESOLVE errors). PR #538 reverted the grammar package changes but did not restore the patch script, leaving `tree-sitter-swift@0.6.0` unable to build its native binding on macOS ARM64. The symptom is `gitnexus analyze` printing "Skipping swift" or "swift parser not available". `Dockerfile.test` still references `node scripts/patch-tree-sitter-swift.cjs` (added in the same PR #516), confirming the regression — the test image build is also broken. This commit restores the patch script from commit `0c8ec95` (the last revision before it was deleted) and re-adds the `postinstall` entry to `package.json`. No logic changes — it is an exact restoration. The TODO comment in the script ("Remove this script when tree-sitter is upgraded to ^0.22.x") still applies. * style: run prettier on patch-tree-sitter-swift.cjs --- gitnexus/package.json | 1 + gitnexus/scripts/patch-tree-sitter-swift.cjs | 78 ++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 gitnexus/scripts/patch-tree-sitter-swift.cjs diff --git a/gitnexus/package.json b/gitnexus/package.json index 07723f3e3..871524702 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -46,6 +46,7 @@ "test:integration": "vitest run test/integration", "test:watch": "vitest", "test:coverage": "vitest run --coverage", + "postinstall": "node scripts/patch-tree-sitter-swift.cjs", "prepare": "node scripts/build.js", "prepack": "node scripts/build.js" }, diff --git a/gitnexus/scripts/patch-tree-sitter-swift.cjs b/gitnexus/scripts/patch-tree-sitter-swift.cjs new file mode 100644 index 000000000..6580b00e7 --- /dev/null +++ b/gitnexus/scripts/patch-tree-sitter-swift.cjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node +/** + * WORKAROUND: tree-sitter-swift@0.6.0 binding.gyp build failure + * + * Background: + * tree-sitter-swift@0.6.0's binding.gyp contains an "actions" array that + * invokes `tree-sitter generate` to regenerate parser.c from grammar.js. + * This is intended for grammar developers, but the published npm package + * already ships pre-generated parser files (parser.c, scanner.c), so the + * actions are unnecessary for consumers. Since consumers don't have + * tree-sitter-cli installed, the actions always fail during `npm install`. + * + * Why we can't just upgrade: + * tree-sitter-swift@0.7.1 fixes this (removes postinstall, ships prebuilds), + * but it requires tree-sitter@^0.22.1. The upstream project pins tree-sitter + * to ^0.21.0 and all other grammar packages depend on that version. + * Upgrading tree-sitter would be a separate breaking change. + * + * How this workaround works: + * 1. tree-sitter-swift's own postinstall fails (npm warns but continues) + * 2. This script runs as gitnexus's postinstall + * 3. It removes the "actions" array from binding.gyp + * 4. It rebuilds the native binding with the cleaned binding.gyp + * + * TODO: Remove this script when tree-sitter is upgraded to ^0.22.x, + * which allows using tree-sitter-swift@0.7.1+ directly. + */ +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +const swiftDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-swift'); +const bindingPath = path.join(swiftDir, 'binding.gyp'); + +try { + if (!fs.existsSync(bindingPath)) { + process.exit(0); + } + + const content = fs.readFileSync(bindingPath, 'utf8'); + let needsRebuild = false; + + if (content.includes('"actions"')) { + // Strip Python-style comments (#) and trailing commas before JSON parsing + const cleaned = content + .replace(/#[^\n]*/g, '') // Remove # comments + .replace(/,(\s*[\]}])/g, '$1'); // Remove trailing commas before ] or } + const gyp = JSON.parse(cleaned); + + if (gyp.targets && gyp.targets[0] && gyp.targets[0].actions) { + delete gyp.targets[0].actions; + fs.writeFileSync(bindingPath, JSON.stringify(gyp, null, 2) + '\n'); + console.log('[tree-sitter-swift] Patched binding.gyp (removed actions array)'); + needsRebuild = true; + } + } + + // Check if native binding exists + const bindingNode = path.join(swiftDir, 'build', 'Release', 'tree_sitter_swift_binding.node'); + if (!fs.existsSync(bindingNode)) { + needsRebuild = true; + } + + if (needsRebuild) { + console.log('[tree-sitter-swift] Rebuilding native binding...'); + execSync('npx node-gyp rebuild', { + cwd: swiftDir, + stdio: 'pipe', + timeout: 120000, + }); + console.log('[tree-sitter-swift] Native binding built successfully'); + } +} catch (err) { + console.warn('[tree-sitter-swift] Could not build native binding:', err.message); + console.warn( + '[tree-sitter-swift] You may need to manually run: cd node_modules/tree-sitter-swift && npx node-gyp rebuild', + ); +} From 1ff324ca16bfde5b0706a6869b32ea4e47932b20 Mon Sep 17 00:00:00 2001 From: ivkond Date: Sat, 11 Apr 2026 21:46:12 +0300 Subject: [PATCH 19/67] feat(group): bridge.lbug storage + contract matching expansion (1/4 of #606 split) (#795) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(group): bridge.lbug storage + contract matching expansion Part 1 of 4 in the split of #606 (ticket: #791, closes #790 with a revised plan per @magyargergo's request). ## What changed Adds the LadybugDB-backed bridge storage infrastructure and extends the contract matching algorithm with wildcard support. All changes are additive: storage.ts, sync.ts, service.ts, cli/group.ts, mcp/tools.ts are left on their upstream main versions and will migrate to the new bridge in follow-up PRs (#792, #793, #794). ### Files **New (844 LOC prod):** - `gitnexus/src/core/group/bridge-db.ts` — atomic write-to-temp with `retryRename` for Windows EBUSY/EPERM, per-item write tolerance via `WriteBridgeReport`, `findContractNode` with three-tier symbol lookup (uid → filePath+name → filePath) - `gitnexus/src/core/group/bridge-schema.ts` — schema DDL - `gitnexus/src/core/group/normalization.ts` — contract ID canonicalization + `dedupeContracts` / `dedupeCrossLinks` helpers used by both matching and bridge write **Modified (+137 LOC prod):** - `gitnexus/src/core/group/matching.ts` — adds `runWildcardMatch` for `grpc::Service/*` wildcard consumers, `buildProviderIndex` helper, and canonical gRPC ID handling in `normalizeContractId` - `gitnexus/src/core/group/types.ts` — `MatchType` gains `'wildcard'`; new `BridgeHandle` and `BridgeMeta` interfaces **New tests (658 LOC):** - `gitnexus/test/unit/group/bridge-db.test.ts` — core write/read round trip, `WriteBridgeReport` shape, dropped-links counter, retryRename behavior on EBUSY/ENOENT/EPERM/EACCES - `gitnexus/test/unit/group/bridge-db-edge.test.ts` — edge cases (malformed meta, missing contract nodes, concurrent access) **Modified tests (+225 LOC):** - `gitnexus/test/unit/group/matching.test.ts` — wildcard consumer matching, gRPC canonical ID handling, same-service guard ### Self-review fixes folded in Carried forward from the original #606 self-review: - `writeBridge` try/finally handle lifecycle + `handleClosed` sentinel - `openBridgeDbReadOnly` partial-handle cleanup - `writeBridgeMeta` uses `retryRename` for Windows consistency - `retryRename` unit tests (was zero coverage) - Per-item try/catch around every CREATE loop so one malformed contract doesn't abort the whole write - Dropped cross-link counter (`linksDroppedMissingNode`) ### Why now magyargergo asked for the #606 PR to be split so we can iterate with confidence (https://github.com/abhigyanpatwari/GitNexus/pull/606#issuecomment-4229612271). This is the foundational layer — pure infra, no user-facing surface, no callers of the new APIs in this PR. Later PRs wire it in. ### How to verify - `cd gitnexus && npx tsc --noEmit` - `cd gitnexus && npx vitest run test/unit/group/bridge-db.test.ts --pool=forks` - `cd gitnexus && npx vitest run test/unit/group/bridge-db-edge.test.ts --pool=forks` - `cd gitnexus && npx vitest run test/unit/group/matching.test.ts --pool=forks` - Pre-commit hook runs clean ### Risk / rollback **Low.** All new code sits under `src/core/group/` in new files plus a minimal `+16/-1` diff to `types.ts` and a `+136/-0` diff to `matching.ts` (both purely additive). No existing callers reference the new APIs (bridge-db, openBridgeOrFallback, runWildcardMatch) — the PRs that wire them in come later in the split chain. Rollback = `git revert` of the merge commit; no state introduced, no schema migration triggered. ### Scope discipline (per GUARDRAILS.md) - Only the 8 files listed above are touched; no drive-by refactors - No CI/release/security config changes - No secrets, tokens, or machine-specific paths - Content is lifted from the #606 branch which already passed CI 11/11 green on `d15b8cb` (before the split) ### Dependencies - **Base:** `main` (no dependencies on other split PRs) - **Blocks:** extractor expansion (#792), sync pipeline (#793), cross-impact feature (#794) - **Related ticket:** #791 Co-authored-by: Claude * fix(group): address @claude review on #795 Addresses the findings from the automated review on PR #795 (https://github.com/abhigyanpatwari/GitNexus/pull/795#issuecomment-4229770000 — posted by @magyargergo / claude-code Action run). ### Medium severity (reviewer flagged as blockers) - **bridge-db.ts `openBridgeDbReadOnly` bak recovery** — the `.bak` recovery path used bare `fsp.rename(bakPath, dbPath)`, which is exactly the scenario most likely to hit Windows EBUSY/EPERM (an interrupted writer still holding the handle for a few ms). Switched to `retryRename` for consistency with the rest of the file's Windows-safe rename path. - **bridge-db.ts `ensureBridgeSchema` error detection** — the inline `msg.includes('already exists')` substring match has been lifted into a named constant `LBUG_ALREADY_EXISTS_MSG` with a comment documenting the coupling to LadybugDB's error message wording and why we can't use `IF NOT EXISTS` (LadybugDB DDL doesn't support it) or typed errors (LadybugDB's JS driver doesn't expose error codes). Also tightened the `catch (err: any)` to `catch (err: unknown)`. - **bridge-db.ts `findContractNode` — extracted out of writeBridge** — the 35-line async closure living inside `writeBridge` has been lifted to three module-level functions: `createContractLookupIndex`, `indexContract`, and `findContractNode`. `findContractNode` is now a pure synchronous function taking a prebuilt index instead of doing its own DB queries. The `writeBridge` cross-link loop is now ~25 lines instead of ~100. - **bridge-db.ts `findContractNode` — N+1 query elimination** — the old inner-closure version issued up to 6 DB round-trips per cross-link (2 endpoints × up to 3 tiers of fallback queries). For a group with 1000 cross-links, that's up to 6000 DB queries just to resolve endpoints. The new version consults an in-memory `ContractLookupIndex` built incrementally as contracts are inserted (`indexContract` called AFTER each successful insert so failed inserts don't poison the index). Cross-link resolution is now O(1) per link instead of O(3) DB queries per link, with zero DB round-trips during the cross-link loop. ### Minor severity - **bridge-db.ts `queryBridge` empty-array guard** — if LadybugDB ever returns an empty `QueryResult[]` at the top level (shouldn't happen with single-statement calls, but driver contract isn't explicit), the old code would call `.getAll()` on `undefined` and crash with a confusing stack. Added an `unwrapQueryResult` helper that throws an explicit `'empty QueryResult array'` error instead, making a potential driver regression visible immediately. - **normalization.ts `contractRichness` weights** — added a block-level comment documenting the weight ordering (+3 for symbolUid, +2 for each symbol-identifying field, +1 for service tag or non-manifest origin) and explicitly noting that the absolute numbers don't matter, only the relative ordering. Matches the "comment for contributors" suggestion in the review. - **bridge-schema.ts `BRIDGE_SCHEMA_VERSION` migration comment** — added a 4-point contract explaining what bumping the constant means ("discard and re-sync" strategy for V1, no in-place migration yet, new migration logic should live in a separate `bridge-migrations.ts` module when it becomes necessary). - **test/unit/group/fixtures.ts** — extracted the `makeContract` helper previously copy-pasted between `bridge-db.test.ts` and `bridge-db-edge.test.ts` into a shared fixtures module. Both test files now import from `./fixtures.js`. Kept the scope minimal: fixtures is NOT a general-purpose factory module, just the shared baseline contract builder. ### New tests Added 9 pure-function unit tests for the now-extracted `findContractNode` in `bridge-db.test.ts`: - returns null on empty index - tier 1 (symbolUid) match, including repo-scope and role-scope isolation - tier 2 (filePath + symbolName) fallback when symbolUid is empty or mismatches - tier 3 (filePath only) when exactly one contract lives in the file, and refusal when multiple do - priority ordering when multiple tiers could resolve These are fully isolated — no DB, no temp directories, no native LadybugDB binding — so they run in <10ms total and are immediately trustworthy as a regression safety net. ### Deliberately deferred (reviewer marked as "fine for now") - `BridgeHandle._db` / `._conn` typing to `unknown` with casts in `bridge-db.ts` — reviewer's note: "The typing is fine for now." - Batch inserts via `UNWIND` — needs LadybugDB support confirmation, tracked as a follow-up; the per-item pattern remains. - `queryBridge` prepared-statement lifecycle — the current pattern (prepare → execute → GC) relies on LadybugDB's internals, worth verifying against their docs in a separate audit. ### Scope discipline (per `GUARDRAILS.md`) - Only files touched by this PR (`bridge-db.ts`, `bridge-schema.ts`, `normalization.ts`, both bridge test files, new `fixtures.ts`) — no drive-by refactors - No CI/release/security config changes - No secrets ### Test + typecheck status - `npx tsc --noEmit` clean - `bridge-db.test.ts`: added 9 `findContractNode` tests, all pass in isolation. The full-file run still hits the pre-existing native LadybugDB cleanup segfault that flakes the reported count — same as every prior commit on this branch, not a regression. - `bridge-db-edge.test.ts`: 4/4 pass - `matching.test.ts`: 28/28 pass - `types.test.ts`: 5/5 pass - `retryRename` tests (4/4) and `findContractNode` tests (9/9) verified in isolation via `-t` filter Co-authored-by: Claude --------- Co-authored-by: Claude --- gitnexus/src/core/group/bridge-db.ts | 588 ++++++++++++++++++ gitnexus/src/core/group/bridge-schema.ts | 60 ++ gitnexus/src/core/group/matching.ts | 136 +++- gitnexus/src/core/group/normalization.ts | 124 ++++ gitnexus/src/core/group/types.ts | 16 +- .../test/unit/group/bridge-db-edge.test.ts | 178 ++++++ gitnexus/test/unit/group/bridge-db.test.ts | 575 +++++++++++++++++ gitnexus/test/unit/group/fixtures.ts | 32 + gitnexus/test/unit/group/matching.test.ts | 225 ++++++- 9 files changed, 1919 insertions(+), 15 deletions(-) create mode 100644 gitnexus/src/core/group/bridge-db.ts create mode 100644 gitnexus/src/core/group/bridge-schema.ts create mode 100644 gitnexus/src/core/group/normalization.ts create mode 100644 gitnexus/test/unit/group/bridge-db-edge.test.ts create mode 100644 gitnexus/test/unit/group/bridge-db.test.ts create mode 100644 gitnexus/test/unit/group/fixtures.ts diff --git a/gitnexus/src/core/group/bridge-db.ts b/gitnexus/src/core/group/bridge-db.ts new file mode 100644 index 000000000..864a79599 --- /dev/null +++ b/gitnexus/src/core/group/bridge-db.ts @@ -0,0 +1,588 @@ +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import lbug from '@ladybugdb/core'; +import type { LbugValue } from '@ladybugdb/core'; +import type { BridgeHandle, BridgeMeta, StoredContract, CrossLink, RepoSnapshot } from './types.js'; +import { BRIDGE_SCHEMA_QUERIES, BRIDGE_SCHEMA_VERSION } from './bridge-schema.js'; +import { dedupeContracts, dedupeCrossLinks } from './normalization.js'; + +export function contractNodeId( + repo: string, + contractId: string, + role: string, + filePath: string, +): string { + return createHash('sha256').update(`${repo}\0${contractId}\0${role}\0${filePath}`).digest('hex'); +} + +/* ------------------------------------------------------------------ */ +/* ContractLookupIndex — in-memory lookup for findContractNode */ +/* ------------------------------------------------------------------ */ + +/** + * In-memory index of contract node IDs keyed three ways, mirroring the + * three-tier fallback lookup in {@link findContractNode}. Built once per + * `writeBridge` call after all contracts are successfully inserted, then + * consulted for every cross-link — which eliminates the former N+1 query + * pattern (up to `6 × cross-links` DB round-trips) and turns cross-link + * resolution into constant-time per link. + * + * Keys are deliberately flat strings (not tuples) so `Map` + * works; the separator `\0` can't occur in any legal repo path / file + * path / symbol identifier, which makes the encoding injection-safe. + */ +export interface ContractLookupIndex { + /** tier 1: `repo + role + symbolUid` → contract node id */ + byUid: Map; + /** tier 2: `repo + role + filePath + symbolName` → contract node id */ + byRef: Map; + /** tier 3: `repo + role + filePath` → list of contract node ids in that file */ + byFile: Map; +} + +export function createContractLookupIndex(): ContractLookupIndex { + return { + byUid: new Map(), + byRef: new Map(), + byFile: new Map(), + }; +} + +function uidKey(repo: string, role: string, symbolUid: string): string { + return `${repo}\0${role}\0${symbolUid}`; +} + +function refKey(repo: string, role: string, filePath: string, symbolName: string): string { + return `${repo}\0${role}\0${filePath}\0${symbolName}`; +} + +function fileKey(repo: string, role: string, filePath: string): string { + return `${repo}\0${role}\0${filePath}`; +} + +/** + * Add a successfully-inserted contract to the lookup index. Must be called + * AFTER the DB insert succeeds (not before) so failed inserts don't poison + * the index and cause cross-links to point at non-existent rows. + */ +export function indexContract( + index: ContractLookupIndex, + contract: StoredContract, + nodeId: string, +): void { + if (contract.symbolUid) { + index.byUid.set(uidKey(contract.repo, contract.role, contract.symbolUid), nodeId); + } + index.byRef.set( + refKey(contract.repo, contract.role, contract.symbolRef.filePath, contract.symbolRef.name), + nodeId, + ); + const fk = fileKey(contract.repo, contract.role, contract.symbolRef.filePath); + const existing = index.byFile.get(fk); + if (existing) { + existing.push(nodeId); + } else { + index.byFile.set(fk, [nodeId]); + } +} + +/** + * Resolve a cross-link endpoint (consumer or provider reference) to an + * already-inserted contract node id. Returns `null` if no match — the + * caller is expected to count that as a dropped link in `WriteBridgeReport`. + * + * The resolution order matches the pre-cache DB-query behavior: + * 1. exact `symbolUid` match in the same `(repo, role)` scope + * 2. exact `(filePath, symbolName)` match + * 3. if exactly one contract lives in the file → that one (fallback for + * legacy graph-assisted extractors that couldn't resolve a symbol name) + * + * This is a pure function — no I/O, no DB — so it's trivial to unit-test + * in isolation (which was the reviewer's main clean-code concern on the + * original 35-line inner closure in `writeBridge`). + */ +export function findContractNode( + index: ContractLookupIndex, + repo: string, + role: 'consumer' | 'provider', + symbolUid: string, + filePath: string, + symbolName: string, +): string | null { + if (symbolUid) { + const uidHit = index.byUid.get(uidKey(repo, role, symbolUid)); + if (uidHit !== undefined) return uidHit; + } + + const refHit = index.byRef.get(refKey(repo, role, filePath, symbolName)); + if (refHit !== undefined) return refHit; + + const fileCandidates = index.byFile.get(fileKey(repo, role, filePath)); + if (fileCandidates && fileCandidates.length === 1) return fileCandidates[0]; + + return null; +} + +export async function openBridgeDb(dbPath: string): Promise { + const parentDir = path.dirname(dbPath); + await fsp.mkdir(parentDir, { recursive: true }); + const db = new lbug.Database(dbPath, 0, false, false); // writable + const conn = new lbug.Connection(db); + return { _db: db, _conn: conn, groupDir: parentDir } as BridgeHandle; +} + +/** + * LadybugDB returns an error whose message contains this substring when a + * CREATE NODE TABLE or CREATE REL TABLE statement hits an already-existing + * table. LadybugDB DDL doesn't support IF NOT EXISTS, and its JS driver + * doesn't expose typed error codes, so we match on the message substring — + * the same pattern used by `core/lbug/lbug-adapter.ts`. If a future + * LadybugDB release changes the wording, update this constant. + */ +const LBUG_ALREADY_EXISTS_MSG = 'already exists'; + +export async function ensureBridgeSchema(handle: BridgeHandle): Promise { + const conn = handle._conn as lbug.Connection; + for (const q of BRIDGE_SCHEMA_QUERIES) { + try { + await conn.query(q); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + if (!msg.includes(LBUG_ALREADY_EXISTS_MSG)) throw err; + } + } +} + +export async function queryBridge( + handle: BridgeHandle, + cypher: string, + params?: Record, +): Promise { + const conn = handle._conn as lbug.Connection; + if (params && Object.keys(params).length > 0) { + const stmt = await conn.prepare(cypher); + if (!stmt.isSuccess()) { + const errMsg = await stmt.getErrorMessage(); + throw new Error(`Bridge query prepare failed: ${errMsg}`); + } + const queryResult = await conn.execute(stmt, params); + const result = unwrapQueryResult(queryResult); + return (await result.getAll()) as T[]; + } + const queryResult = await conn.query(cypher); + const result = unwrapQueryResult(queryResult); + return (await result.getAll()) as T[]; +} + +/** + * LadybugDB's `conn.query` / `conn.execute` can return either a single + * `QueryResult` (for a single statement) or an array of them (when a + * multi-statement script is dispatched). We always pass a single statement, + * so the array form is a wrapper we unwrap here — but an empty top-level + * array would cause `.getAll()` on `undefined` and crash with a confusing + * stack. Throwing an explicit error makes a driver-contract regression + * visible immediately instead of masking it. + */ +function unwrapQueryResult(queryResult: lbug.QueryResult | lbug.QueryResult[]): lbug.QueryResult { + if (Array.isArray(queryResult)) { + if (queryResult.length === 0) { + throw new Error('Bridge query returned an empty QueryResult array'); + } + return queryResult[0]; + } + return queryResult; +} + +export async function closeBridgeDb(handle: BridgeHandle): Promise { + try { + await (handle._conn as lbug.Connection).close(); + } catch { + /* ignore */ + } + try { + await (handle._db as lbug.Database).close(); + } catch { + /* ignore */ + } +} + +/* ------------------------------------------------------------------ */ +/* retryRename — handles transient EBUSY/EPERM/EACCES on Windows */ +/* ------------------------------------------------------------------ */ + +const RETRY_CODES = new Set(['EBUSY', 'EPERM', 'EACCES']); + +export async function retryRename(src: string, dst: string, attempts = 3): Promise { + for (let i = 1; i <= attempts; i++) { + try { + await fsp.rename(src, dst); + return; + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException).code; + if (!code || !RETRY_CODES.has(code) || i === attempts) throw err; + await new Promise((r) => setTimeout(r, 100 * Math.pow(2, i - 1))); + } + } +} + +/* ------------------------------------------------------------------ */ +/* writeBridgeMeta / readBridgeMeta */ +/* ------------------------------------------------------------------ */ + +export async function writeBridgeMeta(groupDir: string, meta: BridgeMeta): Promise { + const target = path.join(groupDir, 'meta.json'); + const tmp = `${target}.tmp.${Date.now()}`; + await fsp.writeFile(tmp, JSON.stringify(meta, null, 2), 'utf-8'); + // Use retryRename for consistency with writeBridge's atomic swap — on + // Windows a concurrent reader can cause EBUSY/EPERM even on a tiny + // meta.json, and we don't want meta write to be less robust than the + // bridge.lbug swap it accompanies. + await retryRename(tmp, target); +} + +export async function readBridgeMeta(groupDir: string): Promise { + try { + const content = await fsp.readFile(path.join(groupDir, 'meta.json'), 'utf-8'); + return JSON.parse(content) as BridgeMeta; + } catch { + return { version: 0, generatedAt: '', missingRepos: [] }; + } +} + +/* ------------------------------------------------------------------ */ +/* writeBridge — atomic write-to-temp-then-rename */ +/* ------------------------------------------------------------------ */ + +export interface WriteBridgeInput { + contracts: StoredContract[]; + crossLinks: CrossLink[]; + repoSnapshots: Record; + missingRepos: string[]; +} + +/** + * Non-fatal issues encountered during writeBridge. Callers can log these to + * surface partial-success state without aborting the whole sync. + * `sampleErrors` is capped at MAX_SAMPLE_ERRORS per category to bound memory. + */ +export interface WriteBridgeReport { + contractsInserted: number; + contractsFailed: number; + snapshotsInserted: number; + snapshotsFailed: number; + linksInserted: number; + linksFailed: number; + /** Cross-links skipped because their from/to contract nodes weren't found. */ + linksDroppedMissingNode: number; + sampleErrors: Array<{ + kind: 'contract' | 'snapshot' | 'link'; + id: string; + message: string; + }>; +} + +const MAX_SAMPLE_ERRORS = 10; + +function errMessage(err: unknown): string { + if (err instanceof Error) return err.message; + try { + return String(err); + } catch { + return 'unknown error'; + } +} + +export async function writeBridge( + groupDir: string, + input: WriteBridgeInput, +): Promise { + await fsp.mkdir(groupDir, { recursive: true }); + const contracts = dedupeContracts(input.contracts); + const crossLinks = dedupeCrossLinks(input.crossLinks); + + const finalPath = path.join(groupDir, 'bridge.lbug'); + const tmpPath = path.join(groupDir, 'bridge.lbug.tmp'); + const bakPath = path.join(groupDir, 'bridge.lbug.bak'); + + const report: WriteBridgeReport = { + contractsInserted: 0, + contractsFailed: 0, + snapshotsInserted: 0, + snapshotsFailed: 0, + linksInserted: 0, + linksFailed: 0, + linksDroppedMissingNode: 0, + sampleErrors: [], + }; + + const recordError = (kind: 'contract' | 'snapshot' | 'link', id: string, err: unknown) => { + if (report.sampleErrors.length < MAX_SAMPLE_ERRORS) { + report.sampleErrors.push({ kind, id, message: errMessage(err) }); + } + }; + + // Clean up any leftover tmp + try { + await fsp.rm(tmpPath, { recursive: true, force: true }); + } catch { + /* ignore */ + } + + // 1. Create temp DB, insert all data. + // + // Everything after `openBridgeDb` must run inside a try/finally so that + // if ANY step before the explicit `closeBridgeDb` throws — schema + // creation, a contract insert loop that rethrows, a snapshot write, the + // cross-link loop, or anything else — the handle is still released. A + // leaked handle holds the native LadybugDB file lock on tmpPath, which + // (a) leaks a FD and (b) prevents the next writeBridge call from + // reusing the same tmp slot. + const handle = await openBridgeDb(tmpPath); + let handleClosed = false; + try { + await ensureBridgeSchema(handle); + + // Build the lookup index incrementally as contracts are inserted, so + // failed inserts are never in the index (and therefore never resolved + // by the cross-link loop below). This replaces a previous N+1 query + // pattern where each link made up to 6 DB round-trips to find its + // endpoints — see ContractLookupIndex. + const lookupIndex = createContractLookupIndex(); + + // Insert contracts — tolerate individual failures (e.g., a corrupt meta + // that can't be serialized). The whole sync must not fail because one + // contract is broken. + for (const c of contracts) { + const id = contractNodeId(c.repo, c.contractId, c.role, c.symbolRef.filePath); + try { + await queryBridge( + handle, + `CREATE (n:Contract { + id: $id, + contractId: $contractId, + type: $type, + role: $role, + repo: $repo, + service: $service, + symbolUid: $symbolUid, + filePath: $filePath, + symbolName: $symbolName, + confidence: $confidence, + meta: $meta + })`, + { + id, + contractId: c.contractId, + type: c.type, + role: c.role, + repo: c.repo, + service: c.service ?? '', + symbolUid: c.symbolUid, + filePath: c.symbolRef.filePath, + symbolName: c.symbolName, + confidence: c.confidence, + meta: JSON.stringify(c.meta), + }, + ); + report.contractsInserted++; + // Only index on successful insert — the cross-link loop must never + // resolve to a row that isn't actually in the DB. + indexContract(lookupIndex, c, id); + } catch (err) { + report.contractsFailed++; + recordError('contract', id, err); + } + } + + // Insert repo snapshots + for (const [repoId, snap] of Object.entries(input.repoSnapshots)) { + try { + await queryBridge( + handle, + `CREATE (s:RepoSnapshot { + id: $id, + indexedAt: $indexedAt, + lastCommit: $lastCommit + })`, + { + id: repoId, + indexedAt: snap.indexedAt, + lastCommit: snap.lastCommit, + }, + ); + report.snapshotsInserted++; + } catch (err) { + report.snapshotsFailed++; + recordError('snapshot', repoId, err); + } + } + + // Insert cross-links (tolerating missing nodes). + // + // `findContractNode` consults the in-memory lookup index built above, + // not the DB — that's an O(1) pure-function lookup per endpoint instead + // of the previous 2-3 DB queries. For M cross-links, the previous code + // issued up to 6M round-trips; this version issues zero. + // + // `link.contractId` may differ between the consumer and provider sides + // (e.g. wildcard consumer `grpc::Service/*` → method-level provider + // `grpc::Service/Method`) — that's why we resolve each endpoint + // independently via its own `(repo, role, symbolUid, filePath, symbolName)` + // tuple rather than matching on contractId. + for (const link of crossLinks) { + const linkId = `${link.from.repo}::${link.contractId}->${link.to.repo}::${link.contractId}`; + try { + const fromId = findContractNode( + lookupIndex, + link.from.repo, + 'consumer', + link.from.symbolUid, + link.from.symbolRef.filePath, + link.from.symbolRef.name, + ); + const toId = findContractNode( + lookupIndex, + link.to.repo, + 'provider', + link.to.symbolUid, + link.to.symbolRef.filePath, + link.to.symbolRef.name, + ); + if (!fromId || !toId) { + report.linksDroppedMissingNode++; + continue; + } + await queryBridge( + handle, + ` + MATCH (a:Contract), (b:Contract) + WHERE a.id = $fromId AND b.id = $toId + CREATE (a)-[:ContractLink { + matchType: $matchType, + confidence: $confidence, + contractId: $contractId, + fromRepo: $fromRepo, + toRepo: $toRepo + }]->(b) + `, + { + fromId, + toId, + matchType: link.matchType, + confidence: link.confidence, + contractId: link.contractId, + fromRepo: link.from.repo, + toRepo: link.to.repo, + }, + ); + report.linksInserted++; + } catch (err) { + report.linksFailed++; + recordError('link', linkId, err); + } + } + + // 2. Close temp DB (happy path). The finally block also calls + // closeBridgeDb if we threw above; `handleClosed` prevents a + // double-close on the native handle. + await closeBridgeDb(handle); + handleClosed = true; + } finally { + if (!handleClosed) { + await closeBridgeDb(handle).catch(() => { + /* ignore: cleanup path, best effort */ + }); + } + } + + // 3. Atomic swap: old→.bak, tmp→final, rm .bak + try { + await fsp.access(finalPath); + await retryRename(finalPath, bakPath); + } catch { + /* no existing db */ + } + await retryRename(tmpPath, finalPath); + try { + await fsp.rm(bakPath, { recursive: true, force: true }); + } catch { + /* ignore */ + } + + // 4. Write meta.json + await writeBridgeMeta(groupDir, { + version: BRIDGE_SCHEMA_VERSION, + generatedAt: new Date().toISOString(), + missingRepos: input.missingRepos, + }); + + return report; +} + +/* ------------------------------------------------------------------ */ +/* openBridgeDbReadOnly */ +/* ------------------------------------------------------------------ */ + +export async function openBridgeDbReadOnly(groupDir: string): Promise { + const dbPath = path.join(groupDir, 'bridge.lbug'); + try { + await fsp.access(dbPath); + } catch { + // Check for .bak recovery. Use `retryRename` (not `fsp.rename`) for the + // exact same reason the rest of this file does: the scenario that + // triggers bak recovery is an interrupted writer, which on Windows may + // still be holding an open handle on `.bak` for a few milliseconds when + // a reader races in. EBUSY/EPERM retries recover that case silently. + const bakPath = path.join(groupDir, 'bridge.lbug.bak'); + try { + await fsp.access(bakPath); + await retryRename(bakPath, dbPath); + } catch { + return null; + } + } + // Version gate: check meta.json version compatibility + const meta = await readBridgeMeta(groupDir); + if (meta.version > 0 && meta.version !== BRIDGE_SCHEMA_VERSION) { + return null; // incompatible schema version — fallback to JSON or re-sync + } + + // Open the native handle. If Connection construction throws AFTER + // Database was successfully allocated, we'd leak the native Database + // object. Wrap each step separately and tear down the partial handle. + let db: lbug.Database | undefined; + let conn: lbug.Connection | undefined; + try { + db = new lbug.Database(dbPath, 0, false, true); // readOnly + conn = new lbug.Connection(db); + return { _db: db, _conn: conn, groupDir } as BridgeHandle; + } catch { + if (conn) { + try { + await conn.close(); + } catch { + /* ignore */ + } + } + if (db) { + try { + await db.close(); + } catch { + /* ignore */ + } + } + return null; + } +} + +/* ------------------------------------------------------------------ */ +/* bridgeExists */ +/* ------------------------------------------------------------------ */ + +export async function bridgeExists(groupDir: string): Promise { + const handle = await openBridgeDbReadOnly(groupDir); + if (!handle) return false; + await closeBridgeDb(handle); + return true; +} diff --git a/gitnexus/src/core/group/bridge-schema.ts b/gitnexus/src/core/group/bridge-schema.ts new file mode 100644 index 000000000..d61680390 --- /dev/null +++ b/gitnexus/src/core/group/bridge-schema.ts @@ -0,0 +1,60 @@ +/** + * Bridge LadybugDB schema for cross-repo Contract Registry. + * Separate from per-repo schema in lbug/schema.ts. + */ + +/** + * Version of the bridge.lbug schema below. `openBridgeDbReadOnly` compares + * this against `meta.json`'s version field and returns `null` on mismatch, + * which trips the caller into either the JSON fallback path or a fresh + * `group sync` that rebuilds `bridge.lbug` from scratch. + * + * Migration contract for contributors bumping this constant: + * 1. Bump the number (e.g. `1` → `2`). + * 2. Update the DDL below to match the new schema. + * 3. DO NOT attempt an online migration in this file — the version gate + * is intentionally a "discard and re-sync" strategy for V1. An old + * bridge.lbug whose version doesn't match is treated as opaque and + * rebuilt by the next `group sync`. + * 4. If online migration becomes necessary (e.g. when groups accumulate + * large amounts of embedding data), add a migration path as a + * separate `bridge-migrations.ts` module rather than bloating this + * file — keep schema and migration concerns separate. + */ +export const BRIDGE_SCHEMA_VERSION = 1; + +export const CONTRACT_SCHEMA = ` +CREATE NODE TABLE Contract ( + id STRING, + contractId STRING, + type STRING, + role STRING, + repo STRING, + service STRING DEFAULT '', + symbolUid STRING DEFAULT '', + filePath STRING DEFAULT '', + symbolName STRING DEFAULT '', + confidence DOUBLE DEFAULT 0.0, + meta STRING DEFAULT '{}', + PRIMARY KEY (id) +)`; + +export const REPO_SNAPSHOT_SCHEMA = ` +CREATE NODE TABLE RepoSnapshot ( + id STRING, + indexedAt STRING DEFAULT '', + lastCommit STRING DEFAULT '', + PRIMARY KEY (id) +)`; + +export const CONTRACT_LINK_SCHEMA = ` +CREATE REL TABLE ContractLink ( + FROM Contract TO Contract, + matchType STRING, + confidence DOUBLE, + contractId STRING, + fromRepo STRING, + toRepo STRING +)`; + +export const BRIDGE_SCHEMA_QUERIES = [CONTRACT_SCHEMA, REPO_SNAPSHOT_SCHEMA, CONTRACT_LINK_SCHEMA]; diff --git a/gitnexus/src/core/group/matching.ts b/gitnexus/src/core/group/matching.ts index 6d39f4ce4..ec793968b 100644 --- a/gitnexus/src/core/group/matching.ts +++ b/gitnexus/src/core/group/matching.ts @@ -5,6 +5,15 @@ export interface MatchResult { unmatched: StoredContract[]; } +export interface WildcardMatchResult { + matched: CrossLink[]; + remaining: StoredContract[]; +} + +function isGrpcWildcard(cid: string): boolean { + return cid.startsWith('grpc::') && cid.endsWith('/*'); +} + export function normalizeContractId(id: string): string { const colonIdx = id.indexOf('::'); if (colonIdx === -1) return id; @@ -24,6 +33,22 @@ export function normalizeContractId(id: string): string { return id; } case 'grpc': { + // Canonical form: `grpc::[/]`. + // + // The package/service segment is lowercased because gRPC package + // names are effectively case-insensitive across language bindings + // (`auth.AuthService`, `auth.authservice`, `AUTH.AUTHSERVICE` all + // describe the same wire protocol service). The RPC method segment + // is preserved as-is because the HTTP/2 path used on the wire is + // case-sensitive per the gRPC spec (`/Service/MethodName`), and + // method names in generated clients match the proto source exactly. + // + // A package-only id (no slash) and a package/method id are treated + // as DISTINCT canonical forms: `grpc::userservice` does not match + // `grpc::userservice/Login`. That's by design — callers that want + // service-level manifest matching against method-level providers + // should use the gRPC wildcard form `grpc::UserService/*` which is + // handled by runWildcardMatch below. const slashIdx = rest.indexOf('/'); if (slashIdx > 0) { const pkg = rest.substring(0, slashIdx).toLowerCase(); @@ -31,12 +56,12 @@ export function normalizeContractId(id: string): string { return `grpc::${pkg}${method}`; } if (slashIdx === 0) { - // Malformed "package/method" with leading slash — do not lowercase the whole string - // (method segment is case-sensitive per spec). + // Malformed "/method" with leading slash — keep as-is so two + // equally malformed ids can still match each other. return `grpc::${rest}`; } - // No slash: spec is ambiguous (package-only vs full service.method). MVP: lowercase - // the whole token; differs from pkg/method split above where RPC method keeps case. + // No slash: package/service only. Lowercase to match the package + // segment produced by the pkg/method branch above. return `grpc::${rest.toLowerCase()}`; } case 'topic': @@ -66,27 +91,36 @@ function findMatchingKeys(contractId: string, index: Map { const providers = contracts.filter((c) => c.role === 'provider'); - const consumers = contracts.filter((c) => c.role === 'consumer'); - - const providerIndex = new Map(); + const index = new Map(); for (const p of providers) { const key = normalizeContractId(p.contractId); - const list = providerIndex.get(key) || []; + const list = index.get(key) || []; list.push(p); - providerIndex.set(key, list); + index.set(key, list); } + return index; +} + +export function runExactMatch( + contracts: StoredContract[], + providerIndex?: Map, +): MatchResult { + const index = providerIndex ?? buildProviderIndex(contracts); + + // Skip gRPC wildcard consumers — they go to wildcard pass only + const consumers = contracts.filter((c) => c.role === 'consumer' && !isGrpcWildcard(c.contractId)); const matched: CrossLink[] = []; const matchedConsumerIds = new Set(); const matchedProviderIds = new Set(); for (const consumer of consumers) { - const matchingKeys = findMatchingKeys(consumer.contractId, providerIndex); + const matchingKeys = findMatchingKeys(consumer.contractId, index); if (matchingKeys.length === 0) continue; - const allMatchingProviders = matchingKeys.flatMap((k) => providerIndex.get(k) || []); + const allMatchingProviders = matchingKeys.flatMap((k) => index.get(k) || []); for (const provider of allMatchingProviders) { if (provider.repo === consumer.repo) { if (!provider.service || !consumer.service || provider.service === consumer.service) { @@ -118,10 +152,86 @@ export function runExactMatch(contracts: StoredContract[]): MatchResult { } } - const unmatched = contracts.filter((c) => { + // normalUnmatched: contracts that weren't matched in exact pass + const normalUnmatched = contracts.filter((c) => { + if (isGrpcWildcard(c.contractId)) return false; // excluded from exact, handled separately const id = `${c.repo}::${c.contractId}`; return c.role === 'provider' ? !matchedProviderIds.has(id) : !matchedConsumerIds.has(id); }); + // Re-add gRPC wildcard contracts — they were never in exact matching + const grpcWildcards = contracts.filter((c) => isGrpcWildcard(c.contractId)); + const unmatched = [...normalUnmatched, ...grpcWildcards]; + return { matched, unmatched }; } + +export function runWildcardMatch( + unmatched: StoredContract[], + providerIndex: Map, +): WildcardMatchResult { + const wildcardConsumers = unmatched.filter( + (c) => c.role === 'consumer' && isGrpcWildcard(c.contractId), + ); + const matched: CrossLink[] = []; + const matchedConsumerIds = new Set(); + + for (const consumer of wildcardConsumers) { + const normalized = normalizeContractId(consumer.contractId); + // "grpc::com.example.userservice/*" → "com.example.userservice" + // "grpc::userservice/*" → "userservice" + const fqService = normalized.slice(normalized.indexOf('::') + 2, -2); // strip "grpc::" and "/*" + + for (const [key, providers] of providerIndex) { + // Only match against non-wildcard gRPC providers (method-level IDs) + if (!key.startsWith('grpc::') || key.endsWith('/*')) continue; + const afterPrefix = key.slice(6); // strip "grpc::" + const slashIdx = afterPrefix.indexOf('/'); + if (slashIdx < 0) continue; + const providerFqService = afterPrefix.slice(0, slashIdx); + + // Match: exact FQ service, or bare-name match when consumer has no package + const isMatch = + providerFqService === fqService || + (!fqService.includes('.') && providerFqService.endsWith('.' + fqService)); + + if (!isMatch) continue; + + for (const provider of providers) { + // Skip same-repo same-service (same logic as runExactMatch) + if (provider.repo === consumer.repo) { + if (!provider.service || !consumer.service || provider.service === consumer.service) { + continue; + } + } + + matched.push({ + from: { + repo: consumer.repo, + service: consumer.service, + symbolUid: consumer.symbolUid, + symbolRef: consumer.symbolRef, + }, + to: { + repo: provider.repo, + service: provider.service, + symbolUid: provider.symbolUid, + symbolRef: provider.symbolRef, + }, + type: consumer.type, + contractId: consumer.contractId, // consumer's wildcard ID + matchType: 'wildcard', + confidence: Math.min(provider.confidence, consumer.confidence), + }); + matchedConsumerIds.add(`${consumer.repo}::${consumer.contractId}`); + } + } + } + + const remaining = unmatched.filter((c) => { + if (c.role !== 'consumer' || !isGrpcWildcard(c.contractId)) return true; + return !matchedConsumerIds.has(`${c.repo}::${c.contractId}`); + }); + + return { matched, remaining }; +} diff --git a/gitnexus/src/core/group/normalization.ts b/gitnexus/src/core/group/normalization.ts new file mode 100644 index 000000000..c99d36850 --- /dev/null +++ b/gitnexus/src/core/group/normalization.ts @@ -0,0 +1,124 @@ +import type { CrossLink, CrossLinkEndpoint, StoredContract } from './types.js'; + +function contractKey(contract: StoredContract): string { + return [contract.repo, contract.contractId, contract.role, contract.symbolRef.filePath].join( + '\0', + ); +} + +function endpointKey(endpoint: CrossLinkEndpoint): string { + return [ + endpoint.repo, + endpoint.service ?? '', + endpoint.symbolRef.filePath, + endpoint.symbolRef.name, + ].join('\0'); +} + +/** + * Score a contract by how much information it carries, so `dedupeContracts` + * can prefer the "richer" record when two contracts collide on the same + * `(repo, contractId, role, filePath)` key. + * + * Weights express a priority ordering, not calibrated probabilities: + * +3 — `symbolUid` resolved (tier 1 of the downstream lookup — highest + * signal because it's the strongest anchor for cross-impact traversal + * and the only one that's robust to renames) + * +2 — any of `filePath`, `symbolRef.name`, or `symbolName` that's more + * specific than the contractId itself (tier 2 signal — resolves + * uniquely in most cases and survives across syncs) + * +1 — `service` tag (monorepo attribution — useful but not sufficient + * on its own) or non-manifest origin (auto-extracted contracts are + * preferred over manifest-declared synthetic ones because the former + * are grounded in real source code) + * + * The absolute numbers don't matter, only their relative ordering. + */ +function contractRichness(contract: StoredContract): number { + let score = 0; + if (contract.symbolUid) score += 3; + if (contract.symbolRef.filePath) score += 2; + if (contract.symbolRef.name && contract.symbolRef.name !== contract.contractId) score += 2; + if (contract.symbolName && contract.symbolName !== contract.contractId) score += 2; + if (contract.service) score += 1; + if (contract.meta.source !== 'manifest') score += 1; + return score; +} + +function mergeContracts(existing: StoredContract, incoming: StoredContract): StoredContract { + const [primary, secondary] = + contractRichness(incoming) > contractRichness(existing) + ? [incoming, existing] + : [existing, incoming]; + const symbolRefName = primary.symbolRef.name || secondary.symbolRef.name; + return { + ...secondary, + ...primary, + symbolUid: primary.symbolUid || secondary.symbolUid, + symbolRef: { + filePath: primary.symbolRef.filePath || secondary.symbolRef.filePath, + name: symbolRefName, + }, + symbolName: primary.symbolName || secondary.symbolName || symbolRefName, + confidence: Math.max(existing.confidence, incoming.confidence), + service: primary.service ?? secondary.service, + meta: { ...secondary.meta, ...primary.meta }, + }; +} + +function mergeEndpoints( + existing: CrossLinkEndpoint, + incoming: CrossLinkEndpoint, +): CrossLinkEndpoint { + return { + repo: existing.repo, + service: existing.service ?? incoming.service, + symbolUid: existing.symbolUid || incoming.symbolUid, + symbolRef: { + filePath: existing.symbolRef.filePath || incoming.symbolRef.filePath, + name: existing.symbolRef.name || incoming.symbolRef.name, + }, + }; +} + +function crossLinkKey(link: CrossLink): string { + return [ + link.type, + link.contractId, + link.matchType, + endpointKey(link.from), + endpointKey(link.to), + ].join('\0'); +} + +export function dedupeContracts(items: StoredContract[]): StoredContract[] { + const deduped = new Map(); + for (const contract of items) { + const key = contractKey(contract); + const existing = deduped.get(key); + deduped.set(key, existing ? mergeContracts(existing, contract) : contract); + } + return [...deduped.values()]; +} + +export function dedupeCrossLinks(items: CrossLink[]): CrossLink[] { + const deduped = new Map(); + for (const link of items) { + const key = crossLinkKey(link); + const existing = deduped.get(key); + if (!existing) { + deduped.set(key, link); + continue; + } + const keepIncoming = link.confidence > existing.confidence; + const primary = keepIncoming ? link : existing; + const secondary = keepIncoming ? existing : link; + deduped.set(key, { + ...primary, + confidence: Math.max(existing.confidence, link.confidence), + from: mergeEndpoints(primary.from, secondary.from), + to: mergeEndpoints(primary.to, secondary.to), + }); + } + return [...deduped.values()]; +} diff --git a/gitnexus/src/core/group/types.ts b/gitnexus/src/core/group/types.ts index 7ab0f071a..b9ba97582 100644 --- a/gitnexus/src/core/group/types.ts +++ b/gitnexus/src/core/group/types.ts @@ -1,5 +1,5 @@ export type ContractType = 'http' | 'grpc' | 'topic' | 'lib' | 'custom'; -export type MatchType = 'exact' | 'manifest' | 'bm25' | 'embedding'; +export type MatchType = 'exact' | 'manifest' | 'wildcard' | 'bm25' | 'embedding'; export type ContractRole = 'provider' | 'consumer'; export interface GroupConfig { @@ -131,3 +131,17 @@ export interface OutOfScopeLink { contractId: string; confidence: number; } + +/** Opaque handle to an open bridge LadybugDB. */ +export interface BridgeHandle { + /** Internal — do not access directly. */ + readonly _db: unknown; + readonly _conn: unknown; + readonly groupDir: string; +} + +export interface BridgeMeta { + version: number; + generatedAt: string; + missingRepos: string[]; +} diff --git a/gitnexus/test/unit/group/bridge-db-edge.test.ts b/gitnexus/test/unit/group/bridge-db-edge.test.ts new file mode 100644 index 000000000..ca85468cc --- /dev/null +++ b/gitnexus/test/unit/group/bridge-db-edge.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { + writeBridge, + openBridgeDbReadOnly, + queryBridge, + closeBridgeDb, +} from '../../../src/core/group/bridge-db.js'; +import type { CrossLink } from '../../../src/core/group/types.js'; +import { makeContract } from './fixtures.js'; + +describe('bridge-db edge cases', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bridge-edge-')); + }); + + afterEach(async () => { + await fsp.rm(tmpDir, { recursive: true, force: true }); + }); + + it('test_openBridgeDbReadOnly_version_gate_returns_null_for_incompatible', async () => { + // Create a dummy bridge.lbug file so the access check passes + await fsp.writeFile(path.join(tmpDir, 'bridge.lbug'), 'dummy'); + // Write meta.json with an incompatible version (999) + await fsp.writeFile( + path.join(tmpDir, 'meta.json'), + JSON.stringify({ version: 999, generatedAt: '', missingRepos: [] }), + ); + + const handle = await openBridgeDbReadOnly(tmpDir); + expect(handle).toBeNull(); + }); + + it('test_openBridgeDbReadOnly_bak_recovery_restores_bridge', async () => { + // Write a valid bridge + await writeBridge(tmpDir, { + contracts: [makeContract()], + crossLinks: [], + repoSnapshots: {}, + missingRepos: [], + }); + // Move bridge.lbug → bridge.lbug.bak (simulating interrupted swap) + const dbPath = path.join(tmpDir, 'bridge.lbug'); + const bakPath = path.join(tmpDir, 'bridge.lbug.bak'); + await fsp.rename(dbPath, bakPath); + + // openBridgeDbReadOnly should auto-recover from .bak + const handle = await openBridgeDbReadOnly(tmpDir); + expect(handle).not.toBeNull(); + const rows = await queryBridge<{ repo: string }>( + handle!, + 'MATCH (c:Contract) RETURN c.repo AS repo', + ); + expect(rows).toHaveLength(1); + await closeBridgeDb(handle!); + }); + + it('test_writeBridge_crossLink_with_missing_to_node_silently_skipped', async () => { + const provider = makeContract({ repo: 'backend', role: 'provider' }); + const consumer = makeContract({ + repo: 'frontend', + role: 'consumer', + symbolRef: { filePath: 'src/api.ts', name: 'fetchUsers' }, + symbolName: 'fetchUsers', + }); + // CrossLink referencing a 'to' endpoint that doesn't match any contract node + const link: CrossLink = { + from: { + repo: 'frontend', + symbolUid: '', + symbolRef: { filePath: 'src/api.ts', name: 'fetchUsers' }, + }, + to: { + repo: 'nonexistent-repo', + symbolUid: 'uid-missing', + symbolRef: { filePath: 'src/missing.ts', name: 'missingFn' }, + }, + type: 'http', + contractId: 'http::GET::/api/users', + matchType: 'exact', + confidence: 1.0, + }; + + // Should not throw — the link is silently skipped + await writeBridge(tmpDir, { + contracts: [provider, consumer], + crossLinks: [link], + repoSnapshots: {}, + missingRepos: [], + }); + + const handle = await openBridgeDbReadOnly(tmpDir); + expect(handle).not.toBeNull(); + // No cross-links should exist since 'to' node was missing + const rows = await queryBridge<{ matchType: string }>( + handle!, + 'MATCH (a:Contract)-[l:ContractLink]->(b:Contract) RETURN l.matchType AS matchType', + ); + expect(rows).toHaveLength(0); + // But contracts should still be present + const contractRows = await queryBridge<{ repo: string }>( + handle!, + 'MATCH (c:Contract) RETURN c.repo AS repo', + ); + expect(contractRows).toHaveLength(2); + await closeBridgeDb(handle!); + }); + + it('test_writeBridge_manifest_grpc_link_with_symbol_uids_persists_queryable_contract_edge', async () => { + const provider = makeContract({ + contractId: 'grpc::auth.AuthService/Login', + type: 'grpc', + role: 'provider', + repo: 'platform/auth', + symbolUid: 'uid-auth-login', + symbolRef: { filePath: 'src/auth.proto', name: 'Login' }, + symbolName: 'auth.AuthService/Login', + }); + const consumer = makeContract({ + contractId: 'grpc::auth.AuthService/Login', + type: 'grpc', + role: 'consumer', + repo: 'platform/orders', + symbolUid: 'uid-orders-client', + symbolRef: { filePath: 'src/client.ts', name: 'AuthServiceClient' }, + symbolName: 'auth.AuthService/Login', + }); + const link: CrossLink = { + from: { + repo: 'platform/orders', + symbolUid: 'uid-orders-client', + symbolRef: { filePath: 'src/client.ts', name: 'AuthServiceClient' }, + }, + to: { + repo: 'platform/auth', + symbolUid: 'uid-auth-login', + symbolRef: { filePath: 'src/auth.proto', name: 'Login' }, + }, + type: 'grpc', + contractId: 'grpc::auth.AuthService/Login', + matchType: 'manifest', + confidence: 1.0, + }; + + await writeBridge(tmpDir, { + contracts: [provider, consumer], + crossLinks: [link], + repoSnapshots: {}, + missingRepos: [], + }); + + const handle = await openBridgeDbReadOnly(tmpDir); + expect(handle).not.toBeNull(); + const rows = await queryBridge<{ + contractId: string; + matchType: string; + fromRepo: string; + toRepo: string; + }>( + handle!, + `MATCH (a:Contract)-[l:ContractLink]->(b:Contract) + RETURN l.contractId AS contractId, l.matchType AS matchType, l.fromRepo AS fromRepo, l.toRepo AS toRepo`, + ); + expect(rows).toEqual([ + { + contractId: 'grpc::auth.AuthService/Login', + matchType: 'manifest', + fromRepo: 'platform/orders', + toRepo: 'platform/auth', + }, + ]); + await closeBridgeDb(handle!); + }); +}); diff --git a/gitnexus/test/unit/group/bridge-db.test.ts b/gitnexus/test/unit/group/bridge-db.test.ts new file mode 100644 index 000000000..e52b14c17 --- /dev/null +++ b/gitnexus/test/unit/group/bridge-db.test.ts @@ -0,0 +1,575 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { + openBridgeDb, + ensureBridgeSchema, + queryBridge, + closeBridgeDb, + contractNodeId, + retryRename, + writeBridge, + openBridgeDbReadOnly, + readBridgeMeta, + bridgeExists, + createContractLookupIndex, + indexContract, + findContractNode, +} from '../../../src/core/group/bridge-db.js'; +import type { CrossLink } from '../../../src/core/group/types.js'; +import { makeContract } from './fixtures.js'; + +describe('bridge-db core', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bridge-test-')); + }); + + afterEach(async () => { + await fsp.rm(tmpDir, { recursive: true, force: true }); + }); + + it('test_openBridgeDb_returns_handle_and_closes', async () => { + const dbPath = path.join(tmpDir, 'test.lbug'); + const handle = await openBridgeDb(dbPath); + expect(handle).toBeDefined(); + expect(handle._db).toBeDefined(); + expect(handle._conn).toBeDefined(); + expect(handle.groupDir).toBe(tmpDir); + // Close should not throw + await closeBridgeDb(handle); + }); + + it('test_ensureBridgeSchema_creates_tables_idempotent', async () => { + const dbPath = path.join(tmpDir, 'test.lbug'); + const handle = await openBridgeDb(dbPath); + await ensureBridgeSchema(handle); + // Run again — should not throw + await ensureBridgeSchema(handle); + const rows = await queryBridge<{ cnt: number }>( + handle, + 'MATCH (c:Contract) RETURN count(c) AS cnt', + ); + expect(rows[0].cnt).toBe(0); + await closeBridgeDb(handle); + }); + + it('test_queryBridge_returns_inserted_data', async () => { + const dbPath = path.join(tmpDir, 'test.lbug'); + const handle = await openBridgeDb(dbPath); + await ensureBridgeSchema(handle); + await queryBridge( + handle, + `CREATE (c:Contract { + id: 'abc123', contractId: 'http::GET::/api', type: 'http', role: 'provider', + repo: 'backend', confidence: 0.9 + })`, + ); + const rows = await queryBridge<{ repo: string; confidence: number }>( + handle, + 'MATCH (c:Contract) RETURN c.repo AS repo, c.confidence AS confidence', + ); + expect(rows).toHaveLength(1); + expect(rows[0].repo).toBe('backend'); + expect(rows[0].confidence).toBe(0.9); + await closeBridgeDb(handle); + }); + + it('test_queryBridge_parameterized', async () => { + const dbPath = path.join(tmpDir, 'test.lbug'); + const handle = await openBridgeDb(dbPath); + await ensureBridgeSchema(handle); + await queryBridge( + handle, + `CREATE (c:Contract { + id: 'p1', contractId: 'http::GET::/api', type: 'http', role: 'provider', + repo: 'backend', confidence: 0.9 + })`, + ); + const rows = await queryBridge<{ repo: string }>( + handle, + 'MATCH (c:Contract) WHERE c.repo = $r RETURN c.repo AS repo', + { r: 'backend' }, + ); + expect(rows).toHaveLength(1); + expect(rows[0].repo).toBe('backend'); + await closeBridgeDb(handle); + }); + + it('test_contractNodeId_full_sha256', () => { + const id = contractNodeId('backend', 'http::GET::/api', 'provider', 'src/routes.ts'); + expect(id).toHaveLength(64); // full SHA-256 hex + // Same inputs → same hash + const id2 = contractNodeId('backend', 'http::GET::/api', 'provider', 'src/routes.ts'); + expect(id).toBe(id2); + // Different filePath → different hash + const id3 = contractNodeId('backend', 'http::GET::/api', 'provider', 'src/other.ts'); + expect(id).not.toBe(id3); + }); +}); + +describe('writeBridge + read', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bridge-write-')); + }); + + afterEach(async () => { + await fsp.rm(tmpDir, { recursive: true, force: true }); + }); + + it('test_writeBridge_creates_bridge_lbug_file', async () => { + await writeBridge(tmpDir, { + contracts: [makeContract()], + crossLinks: [], + repoSnapshots: { backend: { indexedAt: '2026-01-01', lastCommit: 'abc' } }, + missingRepos: ['missing-repo'], + }); + const exists = await bridgeExists(tmpDir); + expect(exists).toBe(true); + }); + + it('test_writeBridge_returns_report_with_insert_counts', async () => { + const report = await writeBridge(tmpDir, { + contracts: [makeContract(), makeContract({ repo: 'frontend', role: 'consumer' })], + crossLinks: [], + repoSnapshots: { backend: { indexedAt: '2026-01-01', lastCommit: 'abc' } }, + missingRepos: [], + }); + expect(report.contractsInserted).toBe(2); + expect(report.contractsFailed).toBe(0); + expect(report.snapshotsInserted).toBe(1); + expect(report.snapshotsFailed).toBe(0); + expect(report.linksInserted).toBe(0); + expect(report.linksFailed).toBe(0); + expect(report.linksDroppedMissingNode).toBe(0); + expect(report.sampleErrors).toHaveLength(0); + }); + + it('test_writeBridge_counts_dropped_links_with_missing_nodes', async () => { + // Provider + cross-link that references a non-existent consumer node → + // findContractNode returns null for `from`, link gets dropped. + const provider = makeContract({ role: 'provider' }); + const report = await writeBridge(tmpDir, { + contracts: [provider], + crossLinks: [ + { + from: { + repo: 'ghost', + symbolUid: '', + symbolRef: { filePath: 'nowhere.ts', name: 'ghostFn' }, + }, + to: { + repo: provider.repo, + symbolUid: provider.symbolUid, + symbolRef: provider.symbolRef, + }, + type: 'http', + contractId: provider.contractId, + matchType: 'exact', + confidence: 1.0, + }, + ], + repoSnapshots: {}, + missingRepos: [], + }); + expect(report.linksInserted).toBe(0); + expect(report.linksDroppedMissingNode).toBe(1); + expect(report.linksFailed).toBe(0); + expect(report.contractsInserted).toBe(1); + }); + + it('test_writeBridge_contracts_queryable', async () => { + await writeBridge(tmpDir, { + contracts: [makeContract(), makeContract({ repo: 'frontend', role: 'consumer' })], + crossLinks: [], + repoSnapshots: {}, + missingRepos: [], + }); + const handle = await openBridgeDbReadOnly(tmpDir); + expect(handle).not.toBeNull(); + const rows = await queryBridge<{ repo: string }>( + handle!, + 'MATCH (c:Contract) RETURN c.repo AS repo', + ); + expect(rows).toHaveLength(2); + await closeBridgeDb(handle!); + }); + + it('test_writeBridge_meta_json_persists_missingRepos', async () => { + await writeBridge(tmpDir, { + contracts: [], + crossLinks: [], + repoSnapshots: {}, + missingRepos: ['repo-a', 'repo-b'], + }); + const meta = await readBridgeMeta(tmpDir); + expect(meta.missingRepos).toEqual(['repo-a', 'repo-b']); + expect(meta.version).toBeGreaterThan(0); + expect(meta.generatedAt).toBeTruthy(); + }); + + it('test_writeBridge_repoSnapshots_queryable', async () => { + await writeBridge(tmpDir, { + contracts: [], + crossLinks: [], + repoSnapshots: { 'hr/backend': { indexedAt: '2026-01-01', lastCommit: 'abc' } }, + missingRepos: [], + }); + const handle = await openBridgeDbReadOnly(tmpDir); + const rows = await queryBridge<{ id: string; indexedAt: string }>( + handle!, + 'MATCH (s:RepoSnapshot) RETURN s.id AS id, s.indexedAt AS indexedAt', + ); + expect(rows).toHaveLength(1); + expect(rows[0].id).toBe('hr/backend'); + expect(rows[0].indexedAt).toBe('2026-01-01'); + await closeBridgeDb(handle!); + }); + + it('test_writeBridge_crossLinks_queryable', async () => { + const provider = makeContract({ repo: 'backend', role: 'provider' }); + const consumer = makeContract({ + repo: 'frontend', + role: 'consumer', + symbolRef: { filePath: 'src/api.ts', name: 'fetchUsers' }, + symbolName: 'fetchUsers', + }); + const link: CrossLink = { + from: { + repo: 'frontend', + symbolUid: '', + symbolRef: { filePath: 'src/api.ts', name: 'fetchUsers' }, + }, + to: { + repo: 'backend', + symbolUid: 'uid-1', + symbolRef: { filePath: 'src/routes.ts', name: 'getUsers' }, + }, + type: 'http', + contractId: 'http::GET::/api/users', + matchType: 'exact', + confidence: 1.0, + }; + await writeBridge(tmpDir, { + contracts: [provider, consumer], + crossLinks: [link], + repoSnapshots: {}, + missingRepos: [], + }); + const handle = await openBridgeDbReadOnly(tmpDir); + const rows = await queryBridge<{ fromRepo: string; toRepo: string; matchType: string }>( + handle!, + 'MATCH (a:Contract)-[l:ContractLink]->(b:Contract) RETURN l.fromRepo AS fromRepo, l.toRepo AS toRepo, l.matchType AS matchType', + ); + expect(rows).toHaveLength(1); + expect(rows[0].fromRepo).toBe('frontend'); + expect(rows[0].toRepo).toBe('backend'); + expect(rows[0].matchType).toBe('exact'); + await closeBridgeDb(handle!); + }); + + it('test_writeBridge_duplicate_contracts_and_links_are_deduped', async () => { + const provider = makeContract({ + repo: 'backend', + role: 'provider', + symbolUid: '', + symbolName: 'auth.AuthService/Login', + symbolRef: { filePath: 'src/auth.proto', name: 'Login' }, + contractId: 'grpc::auth.AuthService/Login', + type: 'grpc', + meta: { source: 'manifest' }, + }); + const concreteProvider = makeContract({ + ...provider, + symbolUid: 'uid-auth-login', + symbolName: 'Login', + confidence: 0.85, + meta: { source: 'analyze' }, + }); + const consumer = makeContract({ + repo: 'frontend', + role: 'consumer', + symbolUid: '', + symbolName: 'auth.AuthService/Login', + symbolRef: { filePath: 'src/client.ts', name: 'AuthServiceClient' }, + contractId: 'grpc::auth.AuthService/Login', + type: 'grpc', + meta: { source: 'manifest' }, + }); + const link: CrossLink = { + from: { + repo: 'frontend', + symbolUid: '', + symbolRef: { filePath: 'src/client.ts', name: 'AuthServiceClient' }, + }, + to: { + repo: 'backend', + symbolUid: '', + symbolRef: { filePath: 'src/auth.proto', name: 'Login' }, + }, + type: 'grpc', + contractId: 'grpc::auth.AuthService/Login', + matchType: 'manifest', + confidence: 1, + }; + + await writeBridge(tmpDir, { + contracts: [provider, concreteProvider, consumer], + crossLinks: [link, { ...link }], + repoSnapshots: {}, + missingRepos: [], + }); + + const handle = await openBridgeDbReadOnly(tmpDir); + const contracts = await queryBridge<{ repo: string; symbolUid: string; symbolName: string }>( + handle!, + 'MATCH (c:Contract) RETURN c.repo AS repo, c.symbolUid AS symbolUid, c.symbolName AS symbolName ORDER BY c.repo', + ); + const links = await queryBridge<{ fromRepo: string; toRepo: string }>( + handle!, + 'MATCH (a:Contract)-[l:ContractLink]->(b:Contract) RETURN l.fromRepo AS fromRepo, l.toRepo AS toRepo', + ); + + expect(contracts).toHaveLength(2); + expect(contracts[0]).toEqual({ + repo: 'backend', + symbolUid: 'uid-auth-login', + symbolName: 'Login', + }); + expect(links).toHaveLength(1); + await closeBridgeDb(handle!); + }); + + it('test_openBridgeDbReadOnly_returns_null_for_missing', async () => { + const handle = await openBridgeDbReadOnly(path.join(tmpDir, 'nonexistent')); + expect(handle).toBeNull(); + }); + + it('test_bridgeExists_false_for_missing', async () => { + expect(await bridgeExists(path.join(tmpDir, 'nonexistent'))).toBe(false); + }); + + it('test_writeBridge_overwrites_previous', async () => { + await writeBridge(tmpDir, { + contracts: [makeContract()], + crossLinks: [], + repoSnapshots: {}, + missingRepos: [], + }); + await writeBridge(tmpDir, { + contracts: [makeContract({ repo: 'new-repo' })], + crossLinks: [], + repoSnapshots: {}, + missingRepos: [], + }); + const handle = await openBridgeDbReadOnly(tmpDir); + const rows = await queryBridge<{ repo: string }>( + handle!, + 'MATCH (c:Contract) RETURN c.repo AS repo', + ); + expect(rows).toHaveLength(1); + expect(rows[0].repo).toBe('new-repo'); + await closeBridgeDb(handle!); + }); + + it('test_readBridgeMeta_returns_defaults_for_missing', async () => { + const meta = await readBridgeMeta(path.join(tmpDir, 'nonexistent')); + expect(meta.version).toBe(0); + expect(meta.generatedAt).toBe(''); + expect(meta.missingRepos).toEqual([]); + }); +}); + +describe('retryRename', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('retries on EBUSY and eventually succeeds', async () => { + // Spy on fs.promises.rename and make the first two attempts fail with + // EBUSY, then succeed on the third. Verifies that Windows-style + // transient rename failures don't immediately bubble up. + const attempts: Array<[string, string]> = []; + let calls = 0; + const spy = vi.spyOn(fsp, 'rename').mockImplementation(async (src, dst) => { + attempts.push([String(src), String(dst)]); + calls++; + if (calls < 3) { + const err = new Error('resource busy or locked') as NodeJS.ErrnoException; + err.code = 'EBUSY'; + throw err; + } + // Third attempt: pretend the rename worked. + return undefined; + }); + + await retryRename('/src/a', '/dst/b', 3); + + expect(spy).toHaveBeenCalledTimes(3); + expect(attempts.every(([s, d]) => s === '/src/a' && d === '/dst/b')).toBe(true); + }); + + it('rethrows non-retryable errors immediately', async () => { + // A non-retryable code (e.g. ENOENT) should NOT be swallowed into a + // retry loop — that would mask real bugs and waste time. + let calls = 0; + vi.spyOn(fsp, 'rename').mockImplementation(async () => { + calls++; + const err = new Error('no such file') as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + }); + + await expect(retryRename('/src/a', '/dst/b', 5)).rejects.toMatchObject({ code: 'ENOENT' }); + expect(calls).toBe(1); + }); + + it('gives up after the configured number of attempts', async () => { + let calls = 0; + vi.spyOn(fsp, 'rename').mockImplementation(async () => { + calls++; + const err = new Error('locked') as NodeJS.ErrnoException; + err.code = 'EPERM'; + throw err; + }); + + await expect(retryRename('/src/a', '/dst/b', 3)).rejects.toMatchObject({ code: 'EPERM' }); + expect(calls).toBe(3); + }); + + it('retries on EACCES as well', async () => { + let calls = 0; + vi.spyOn(fsp, 'rename').mockImplementation(async () => { + calls++; + if (calls < 2) { + const err = new Error('permission denied') as NodeJS.ErrnoException; + err.code = 'EACCES'; + throw err; + } + return undefined; + }); + + await retryRename('/src/a', '/dst/b', 3); + expect(calls).toBe(2); + }); +}); + +describe('findContractNode', () => { + // Pure-function tests for the lookup index + three-tier resolver that + // were previously an inner closure of `writeBridge` and therefore + // untestable in isolation. Every test here builds its own index and + // never touches the DB. + + it('returns null on empty index', () => { + const index = createContractLookupIndex(); + expect(findContractNode(index, 'backend', 'provider', 'uid-1', 'src/a.ts', 'foo')).toBeNull(); + }); + + it('tier 1: returns contract matched by symbolUid', () => { + const index = createContractLookupIndex(); + const c = makeContract({ symbolUid: 'uid-42', repo: 'backend', role: 'provider' }); + indexContract(index, c, 'node-A'); + expect(findContractNode(index, 'backend', 'provider', 'uid-42', 'anywhere.ts', 'anyName')).toBe( + 'node-A', + ); + }); + + it('tier 1 is repo-scoped: same uid in a different repo does not match', () => { + const index = createContractLookupIndex(); + const c = makeContract({ symbolUid: 'uid-42', repo: 'backend' }); + indexContract(index, c, 'node-A'); + expect( + findContractNode(index, 'frontend', 'provider', 'uid-42', 'src/routes.ts', 'getUsers'), + ).toBeNull(); + }); + + it('tier 1 is role-scoped: provider uid match does not resolve consumer query', () => { + const index = createContractLookupIndex(); + const c = makeContract({ symbolUid: 'uid-42', role: 'provider', repo: 'backend' }); + indexContract(index, c, 'node-A'); + expect( + findContractNode(index, 'backend', 'consumer', 'uid-42', 'src/routes.ts', 'getUsers'), + ).toBeNull(); + }); + + it('tier 2: falls through to filePath + symbolName when symbolUid is empty', () => { + const index = createContractLookupIndex(); + const c = makeContract({ + symbolUid: '', + symbolRef: { filePath: 'src/ctrl.ts', name: 'handler' }, + symbolName: 'handler', + }); + indexContract(index, c, 'node-B'); + expect(findContractNode(index, 'backend', 'provider', '', 'src/ctrl.ts', 'handler')).toBe( + 'node-B', + ); + }); + + it('tier 2: falls through when the given symbolUid does not match anything', () => { + const index = createContractLookupIndex(); + const c = makeContract({ + symbolUid: 'uid-real', + symbolRef: { filePath: 'src/ctrl.ts', name: 'handler' }, + }); + indexContract(index, c, 'node-B'); + // Wrong uid; but filePath+name still resolves. + expect( + findContractNode(index, 'backend', 'provider', 'uid-wrong', 'src/ctrl.ts', 'handler'), + ).toBe('node-B'); + }); + + it('tier 3: resolves by filePath alone when exactly one contract lives there', () => { + const index = createContractLookupIndex(); + const c = makeContract({ + symbolUid: '', + symbolRef: { filePath: 'src/solo.ts', name: 'actualName' }, + }); + indexContract(index, c, 'node-C'); + // filePath+name miss (name is wrong), but tier 3 picks the sole entry. + expect(findContractNode(index, 'backend', 'provider', '', 'src/solo.ts', 'wrongName')).toBe( + 'node-C', + ); + }); + + it('tier 3: does NOT resolve when multiple contracts live in the same file', () => { + const index = createContractLookupIndex(); + const a = makeContract({ + symbolUid: '', + symbolRef: { filePath: 'src/multi.ts', name: 'handlerA' }, + }); + const b = makeContract({ + symbolUid: '', + symbolRef: { filePath: 'src/multi.ts', name: 'handlerB' }, + contractId: 'http::GET::/api/b', + }); + indexContract(index, a, 'node-MA'); + indexContract(index, b, 'node-MB'); + // Wrong symbolName → no tier 2 match. Two contracts in the same file + // → tier 3 must refuse to guess. + expect( + findContractNode(index, 'backend', 'provider', '', 'src/multi.ts', 'unknown'), + ).toBeNull(); + }); + + it('prefers tier 1 over tier 2 when both could resolve', () => { + const index = createContractLookupIndex(); + const tier1Contract = makeContract({ + symbolUid: 'uid-1', + symbolRef: { filePath: 'src/a.ts', name: 'first' }, + }); + const tier2Contract = makeContract({ + symbolUid: '', + symbolRef: { filePath: 'src/a.ts', name: 'first' }, + contractId: 'http::POST::/api/x', + }); + indexContract(index, tier1Contract, 'tier1-id'); + indexContract(index, tier2Contract, 'tier2-id'); + expect(findContractNode(index, 'backend', 'provider', 'uid-1', 'src/a.ts', 'first')).toBe( + 'tier1-id', + ); + }); +}); diff --git a/gitnexus/test/unit/group/fixtures.ts b/gitnexus/test/unit/group/fixtures.ts new file mode 100644 index 000000000..a3d63d6b1 --- /dev/null +++ b/gitnexus/test/unit/group/fixtures.ts @@ -0,0 +1,32 @@ +/** + * Shared test fixtures for `test/unit/group/*` test files. Keep this small + * and purpose-built — it's NOT a general-purpose factory. If a builder here + * grows complex enough to need its own module, move it next to the code + * under test (e.g. `bridge-db.fixtures.ts`) instead of ballooning this file. + */ + +import type { StoredContract } from '../../../src/core/group/types.js'; + +/** + * Canonical baseline contract used by bridge-db and related tests. Every + * field is populated so callers get a valid `StoredContract` with zero args, + * and any field can be overridden via the partial — e.g. + * `makeContract({ role: 'consumer', repo: 'frontend' })`. + * + * Prefer passing a `Partial` override for the specific + * field you care about rather than mutating the returned object in place. + */ +export function makeContract(overrides: Partial = {}): StoredContract { + return { + contractId: 'http::GET::/api/users', + type: 'http', + role: 'provider', + symbolUid: 'uid-1', + symbolRef: { filePath: 'src/routes.ts', name: 'getUsers' }, + symbolName: 'getUsers', + confidence: 0.85, + meta: {}, + repo: 'backend', + ...overrides, + }; +} diff --git a/gitnexus/test/unit/group/matching.test.ts b/gitnexus/test/unit/group/matching.test.ts index bbbe5f664..c5713d909 100644 --- a/gitnexus/test/unit/group/matching.test.ts +++ b/gitnexus/test/unit/group/matching.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { runExactMatch, normalizeContractId } from '../../../src/core/group/matching.js'; +import { + runExactMatch, + normalizeContractId, + buildProviderIndex, + runWildcardMatch, +} from '../../../src/core/group/matching.js'; import type { StoredContract } from '../../../src/core/group/types.js'; describe('normalizeContractId', () => { @@ -21,6 +26,16 @@ describe('normalizeContractId', () => { expect(normalizeContractId('grpc::/MyPkg/DoThing')).toBe('grpc::/MyPkg/DoThing'); }); + it('handles malformed grpc with leading slash and no package', () => { + // grpc::/Method — leading slash, no package + expect(normalizeContractId('grpc::/Method')).toBe('grpc::/Method'); + }); + + it('handles grpc with no slash at all', () => { + // grpc::ServiceName — no slash, ambiguous; MVP: lowercase entire token + expect(normalizeContractId('grpc::ServiceName')).toBe('grpc::servicename'); + }); + it('trims and lowercases topic', () => { expect(normalizeContractId('topic:: Employee.Hired ')).toBe('topic::employee.hired'); }); @@ -180,3 +195,211 @@ describe('runExactMatch', () => { expect(unmatched).toHaveLength(0); }); }); + +// --------------------------------------------------------------------------- +// Helpers for Task 6 tests +// --------------------------------------------------------------------------- +function makeGrpcContract( + id: string, + role: 'provider' | 'consumer', + repo: string, + overrides: Partial = {}, +): StoredContract { + return { + contractId: id, + type: 'grpc', + role, + symbolUid: `uid-${repo}-${id}`, + symbolRef: { filePath: `src/${repo}.ts`, name: `fn-${id}` }, + symbolName: `fn-${id}`, + confidence: 0.9, + meta: {}, + repo, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// buildProviderIndex +// --------------------------------------------------------------------------- +describe('buildProviderIndex', () => { + it('test_buildProviderIndex_creates_normalized_keys', () => { + const contracts: StoredContract[] = [ + makeGrpcContract('grpc::Com.Example.UserService/GetUser', 'provider', 'backend'), + makeGrpcContract('grpc::Com.Example.UserService/GetUser', 'consumer', 'frontend'), + ]; + + const index = buildProviderIndex(contracts); + + // Only providers should be in the index + expect(index.size).toBe(1); + // Key should be normalized (lowercased package) + expect(index.has('grpc::com.example.userservice/GetUser')).toBe(true); + expect(index.get('grpc::com.example.userservice/GetUser')).toHaveLength(1); + expect(index.get('grpc::com.example.userservice/GetUser')![0].role).toBe('provider'); + }); +}); + +// --------------------------------------------------------------------------- +// runExactMatch — gRPC wildcard skip +// --------------------------------------------------------------------------- +describe('runExactMatch — gRPC wildcard handling', () => { + it('test_runExactMatch_skips_grpc_wildcard_contracts', () => { + const contracts: StoredContract[] = [ + makeGrpcContract('grpc::com.example.UserService/*', 'consumer', 'frontend'), + makeGrpcContract('grpc::com.example.UserService/*', 'provider', 'backend'), + ]; + + const { matched, unmatched } = runExactMatch(contracts); + + // gRPC wildcards should NOT be matched in exact pass + expect(matched).toHaveLength(0); + // Both should appear in unmatched + expect(unmatched).toHaveLength(2); + }); + + it('test_runExactMatch_does_not_skip_http_wildcards', () => { + const contracts: StoredContract[] = [ + { + contractId: 'http::GET::/api/users', + type: 'http', + role: 'provider', + symbolUid: 'uid-backend-http', + symbolRef: { filePath: 'src/backend.ts', name: 'fn-http' }, + symbolName: 'fn-http', + confidence: 0.9, + meta: {}, + repo: 'backend', + }, + { + contractId: 'http::*::/api/users', + type: 'http', + role: 'consumer', + symbolUid: 'uid-frontend-http', + symbolRef: { filePath: 'src/frontend.ts', name: 'fn-http' }, + symbolName: 'fn-http', + confidence: 0.9, + meta: {}, + repo: 'frontend', + }, + ]; + + const { matched } = runExactMatch(contracts); + // HTTP wildcard should still match via findMatchingKeys + expect(matched).toHaveLength(1); + expect(matched[0].contractId).toBe('http::*::/api/users'); + }); +}); + +// --------------------------------------------------------------------------- +// runWildcardMatch +// --------------------------------------------------------------------------- +describe('runWildcardMatch', () => { + it('test_runWildcardMatch_fq_service_match', () => { + const consumer = makeGrpcContract('grpc::com.example.UserService/*', 'consumer', 'frontend'); + const provider = makeGrpcContract( + 'grpc::com.example.UserService/GetUser', + 'provider', + 'backend', + ); + + const providerIndex = buildProviderIndex([provider]); + const { matched } = runWildcardMatch([consumer], providerIndex); + + expect(matched).toHaveLength(1); + expect(matched[0].from.repo).toBe('frontend'); + expect(matched[0].to.repo).toBe('backend'); + }); + + it('test_runWildcardMatch_bare_name_match', () => { + const consumer = makeGrpcContract('grpc::UserService/*', 'consumer', 'frontend'); + const provider = makeGrpcContract( + 'grpc::com.example.UserService/GetUser', + 'provider', + 'backend', + ); + + const providerIndex = buildProviderIndex([provider]); + const { matched } = runWildcardMatch([consumer], providerIndex); + + expect(matched).toHaveLength(1); + expect(matched[0].from.repo).toBe('frontend'); + expect(matched[0].to.repo).toBe('backend'); + }); + + it('test_runWildcardMatch_no_match_different_service', () => { + const consumer = makeGrpcContract('grpc::UserService/*', 'consumer', 'frontend'); + const provider = makeGrpcContract( + 'grpc::com.example.OtherService/GetUser', + 'provider', + 'backend', + ); + + const providerIndex = buildProviderIndex([provider]); + const { matched, remaining } = runWildcardMatch([consumer], providerIndex); + + expect(matched).toHaveLength(0); + expect(remaining).toContainEqual(consumer); + }); + + it('test_runWildcardMatch_skips_wildcard_providers', () => { + const consumer = makeGrpcContract('grpc::com.example.UserService/*', 'consumer', 'frontend'); + const provider = makeGrpcContract('grpc::com.example.UserService/*', 'provider', 'backend'); + + const providerIndex = buildProviderIndex([provider]); + const { matched } = runWildcardMatch([consumer], providerIndex); + + // Wildcard provider key ends with /*, so it should be skipped + expect(matched).toHaveLength(0); + }); + + it('test_runWildcardMatch_confidence_min', () => { + const consumer = makeGrpcContract('grpc::com.example.UserService/*', 'consumer', 'frontend', { + confidence: 0.7, + }); + const provider = makeGrpcContract( + 'grpc::com.example.UserService/GetUser', + 'provider', + 'backend', + { + confidence: 0.5, + }, + ); + + const providerIndex = buildProviderIndex([provider]); + const { matched } = runWildcardMatch([consumer], providerIndex); + + expect(matched).toHaveLength(1); + expect(matched[0].confidence).toBe(0.5); + }); + + it('test_runWildcardMatch_matchType_wildcard', () => { + const consumer = makeGrpcContract('grpc::com.example.UserService/*', 'consumer', 'frontend'); + const provider = makeGrpcContract( + 'grpc::com.example.UserService/GetUser', + 'provider', + 'backend', + ); + + const providerIndex = buildProviderIndex([provider]); + const { matched } = runWildcardMatch([consumer], providerIndex); + + expect(matched).toHaveLength(1); + expect(matched[0].matchType).toBe('wildcard'); + }); + + it('test_runWildcardMatch_contractId_is_consumers', () => { + const consumer = makeGrpcContract('grpc::com.example.UserService/*', 'consumer', 'frontend'); + const provider = makeGrpcContract( + 'grpc::com.example.UserService/GetUser', + 'provider', + 'backend', + ); + + const providerIndex = buildProviderIndex([provider]); + const { matched } = runWildcardMatch([consumer], providerIndex); + + expect(matched).toHaveLength(1); + expect(matched[0].contractId).toBe('grpc::com.example.UserService/*'); + }); +}); From a94d6ef80b1d91dac02634a55b98e7a12c3f9b91 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 01:06:55 +0100 Subject: [PATCH 20/67] Extract registries into `model/` module with SemanticModel interface (#786) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * feat(SM-20): extract registries into model/ module with SemanticModel interface - Create model/type-registry.ts — TypeRegistry interface + factory - Create model/method-registry.ts — MethodRegistry interface + factory - Create model/field-registry.ts — FieldRegistry interface + factory - Create model/semantic-model.ts — SemanticModel interface + factory - Create model/heritage-map.ts — re-export HeritageMap types - Create model/binding-accumulator.ts — re-export BindingAccumulator types - Create model/resolve.ts — move lookupMethodByOwnerWithMRO from call-processor - Update symbol-table.ts — delegate to SemanticModel for registry ops - Update call-processor.ts — re-export lookupMethodByOwnerWithMRO from model/resolve No circular dependencies: model/resolve.ts does NOT import resolution-context.ts. All 775 related unit tests pass with no regressions. Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/27ad2975-1a31-4f50-815b-178ee8a95277 * fix: clarify re-export comment per code review feedback Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/27ad2975-1a31-4f50-815b-178ee8a95277 * refactor(SM-20): wire up SemanticModel as first-class resolution input PR #786 extracted TypeRegistry/MethodRegistry/FieldRegistry into model/ behind SemanticModel, but consumers still routed through SymbolTable delegates. This change completes Phase 6 of the fuzzy-lookup elimination roadmap by making call-processor, resolution-context, type-env, and heritage-map query the model directly via `table.model.{types,methods,fields}`. Also absorbs the open PR #786 review findings so the branch lands clean: - Removed duplicate JSDoc block on lookupMethodByOwner (symbol-table.ts) - Added model/index.ts barrel for the public model/ surface - Fixed O(n) buildParentMapFromHeritage BFS via head-pointer queue - Clarified re-export facade framing on binding-accumulator.ts and heritage-map.ts inside model/ - Refined @internal JSDoc on lookupMethodByOwnerWithMRO Changes: - symbol-table.ts: expose `readonly model: SemanticModel` on the SymbolTable interface. SymbolTable delegate wrappers (lookupClassByName etc.) stay as thin pass-throughs for backward compat; deletion is a follow-up once all internal callers are migrated. - model/resolve.ts: lookupMethodByOwnerWithMRO now takes SemanticModel instead of SymbolTable, removing the last SymbolTable import from the model/ module. Preserves circular-dependency firewall. - call-processor.ts: 6 call sites in D0 member resolution, field resolution, ctor override, and ctor disambiguation migrated to model.types/methods/fields. - resolution-context.ts: tier 3 class+impl lookup migrated. - type-env.ts: 5 sites across lookupClassDefsByName, resolveFieldType, and resolveMethodReturnType migrated. - heritage-map.ts: parent/child class-name resolution migrated. Tests: - symbol-table.test.ts: +10 parity and feeding-audit tests covering every model.{types,methods,fields} path (Class, Method, Property, Impl, Function-with-ownerId, Property-without-ownerId skip, arity filtering, clear cascade). - call-processor.test.ts: classLookupSpy now targets ctx.symbols.model.types since the wrapper is bypassed. - type-env.test.ts: createMockSymbolTable and the destructured-call makeSymbolTable helpers gained a model shim that forwards to the (possibly overridden) top-level lookup stubs. Validation: full suite 5603 passed / 159 skipped, resolver integration suite (19 files, 1766 tests) clean, tsc --noEmit clean. * refactor(SM-21): invert ownership — SemanticModel contains SymbolTable Follow-up to SM-20. Previously SymbolTable owned a `model` subfield; this commit turns the ownership direction around so the SemanticModel is the top-level container and SymbolTable is nested as `.symbols`: SemanticModel (top-level, passed everywhere) ├── types (TypeRegistry) ├── methods (MethodRegistry) ├── fields (FieldRegistry) └── symbols (SymbolTable — file-indexed + callable-name index) The owner-scoped registries live directly on the model; file and callable-name lookups go through `.symbols`. Consumers receive a `SemanticModel` and reach into the appropriate field — no more `table.model.types.X` double-hop. Core changes: - symbol-table.ts: createSymbolTable now takes injected TypeRegistry/MethodRegistry/FieldRegistry via a SymbolTableDeps argument. When omitted (test fallback), it creates standalone registries locally and clears them in clear() — production callers always inject. The five registry convenience delegates (lookupClassByName, lookupMethodByOwner, lookupFieldByOwner, lookupClassByQualifiedName, lookupImplByName) remain as thin forwards to the injected registries so standalone SymbolTable use (chiefly tests) stays ergonomic. - model/semantic-model.ts: createSemanticModel() now creates the three registries AND a SymbolTable wired to them, exposing the SymbolTable as `.symbols`. clear() cascades through all four. - resolution-context.ts: `readonly symbols: SymbolTable` field is replaced with `readonly model: SemanticModel`. Internal factory builds a SemanticModel and keeps a local `symbols` alias for backward-compatible inner body. Consumer migrations (src/): - call-processor.ts: ctx.symbols.add/.lookupExactAll/ .lookupCallableByName → ctx.model.symbols.*; ctx.symbols.model.X → ctx.model.X. buildTypeEnv option key renamed symbolTable → model. - type-env.ts: symbolTable parameter renamed model (type SemanticModel), all internal call sites rewritten to use model.types.*, model.methods.*, model.fields.*, model.symbols.lookupExactAll / .lookupCallableByName. - heritage-map.ts: 2 class-lookup sites migrated. - pipeline.ts: ctx.symbols → ctx.model.symbols throughout. Test migrations: - symbol-table.test.ts: parity tests (which validated the old table.model.X hop) replaced with direct SemanticModel coverage via createSemanticModel(). New tests exercise types/methods/fields/ symbols feeding end-to-end. - type-env.test.ts: createMockSymbolTable rebuilt as a SemanticModel-shaped mock that still accepts the legacy flat override bag for backward compat; inline `makeSymbolTable` helpers for destructured-call and importedReturnTypes suites rewritten to match the new shape; buildTypeEnv options `symbolTable: X` and `{ symbolTable }` shorthand renamed to `model:`; one real createSymbolTable-based test rewritten to use createSemanticModel. - call-processor.test.ts, heritage-map.test.ts, heritage-processor.test.ts, symbol-resolver.test.ts: bulk sed `ctx.symbols.` → `ctx.model.symbols.`. call-processor.test.ts spy updated to target `ctx.model.types.lookupClassByName`. Validation: full test suite 5589 passed / 169 skipped / 0 failed; tsc --noEmit clean; pre-commit eslint + prettier + typecheck all green. CLAUDE.md / AGENTS.md stats bumped from an earlier `npx gitnexus analyze` refresh (3965 symbols / 10012 edges / 243 flows). * refactor(SM-22/SM-23): dispatch table + DAG rearchitecture SM-22: Extract registration dispatch table into model/registration-table.ts. Replaces the if/else ladder inside SymbolTable.add() with an O(1) Map fan-out. SemanticModel wires the table per-instance so hooks close over the correct registries. SM-23: DAG rearchitecture. symbol-table.ts is now a pure 2-index leaf (fileIndex + callableByName) with zero imports from model/. All type/method/field routing lives in the model/ layer. Tests migrated to createSemanticModel() + model.symbols access pattern. Tests: 5632 passed, 0 failures. * refactor: delete dead code (skipCallableIndex + model/ facades) Removes the unused skipCallableIndex flag from the registration dispatch table and deletes two facade files that had zero consumers. skipCallableIndex was declared on RoutingDecision and populated for all 10 entries but never read at runtime — semantic-model.ts explicitly documented that the flag was NOT consulted. The callable-index gate lives inside SymbolTable.add() via CALLABLE_TYPES.has(type), which is the single source of truth. Deleting the flag keeps SymbolTable as the sole decision point and removes documentation-as-data. model/binding-accumulator.ts and model/heritage-map.ts were facade pass-throughs of their parent-directory counterparts. Grep confirms no consumer imports either from the model/ path — all usage goes through ../binding-accumulator.js and ../heritage-map.js directly. model/index.ts was the only "user" and re-exported them with a note about unifying the import boundary, but that boundary has no actual consumers today. Resolves review findings M-01 and M-03 from .context/compound-engineering/ce-review/20260411-144641-59605d93/maintainability.json Tests: 5631 passed, 0 failures (1 less than pre-Unit-1: the skipCallableIndex-specific assertion was removed). * refactor: remove lookupMethodByOwnerWithMRO backward-compat shim call-processor.ts re-exported lookupMethodByOwnerWithMRO from ./model/resolve.js as a backward-compat shim for symbol-table.test.ts. The function already lives in model/resolve.ts and is re-exported properly from model/index.ts (the barrel) — the call-processor shim was a duplicate export path with no durable reason to exist. Migrated the test import from call-processor.js to model/index.js (the canonical barrel). Deleted the re-export statement and the stale "re-exported for backward compatibility" comment block. Hoisted the remaining import to the top of the file with the other imports; the bottom-of-file position was a relic of the shim pattern. Resolves review finding M-02 from .context/compound-engineering/ce-review/20260411-144641-59605d93/maintainability.json Tests: 5631 passed, 0 failures. * refactor: harden registration dispatch runtime safety Two hardening changes in semantic-model.ts, both closing silent-failure paths in the SM-series dispatcher-bypass failure mode. 1. model.symbols.clear() now cascades to the owner-scoped registries. Previously, the SymbolTable facade exposed rawSymbols.clear directly, which only emptied fileIndex + callableByName — the types/methods/ fields registries stayed populated. Any caller holding a SymbolTable reference that invoked .clear() left the model in a split state where subsequent .add() calls double-registered in the registries. No current caller exercises this path, but it was a latent phantom- resolution risk that didn't belong in a public API. Extracted the cascade into a single cascadeClear closure wired into both model.clear() and the facade's clear field. 2. runExhaustivenessGuard now throws instead of console.warn on drift. The production short-circuit via NODE_ENV === 'production' is preserved, so real users never see the throw — but CI and dev runs now fail loudly if a NodeLabel is added to gitnexus-shared without being placed in one of the three registration-table allowlists. The previous warn-only behavior was silent in test output volume; SM-19 already documented dispatcher-bypass as the dominant silent-failure mode in this codebase. Test-first: added test/unit/model/semantic-model.test.ts covering model.symbols.clear() cascade (4 registries × clear = 4 tests), the existing model.clear() cascade (regression guard), and a happy-path construction test that verifies the current allowlists have zero drift. Resolves correctness P2 finding (symbols.clear() partial clear), correctness P3 (exhaustiveness warn-only), and kieran-typescript KT-03 (same exhaustiveness finding, agreement boost). Tests: 5638 passed (+7 new), 0 failures. * docs: fix stale JSDoc references in resolveStaticCall call-processor.ts:2215-2216 referenced SymbolTable.lookupClassByName and SymbolTable.lookupMethodByOwner via {@link}. Both methods were removed from SymbolTable during SM-20 — they now live on TypeRegistry and MethodRegistry respectively, accessible via model.types and model.methods. Other SymbolTable.* references in the codebase (lookupExactFull, add, lookupCallableByName in call-processor.ts:593, symbol-table.ts:86, type-extractors/types.ts:57) target methods that are still on SymbolTable and remain valid. Resolves correctness P3 and kieran-typescript KT-02 (same finding, agreement boost). * refactor: deduplicate ALL_NODE_LABELS constant ALL_NODE_LABELS was private in semantic-model.ts and duplicated verbatim in registration-table.test.ts. Two hardcoded lists meant a new NodeLabel added to gitnexus-shared could land in one copy but not the other, silently drifting the exhaustiveness invariant. Exported ALL_NODE_LABELS from semantic-model.ts, re-exported through model/index.ts for barrel consistency, and switched the test to import it instead of redeclaring. The explanatory comment now describes the single-source-of-truth contract. Resolves maintainability M-04. Tests: 5638 passed, 0 failures. * refactor: add compile-time NodeLabel exhaustiveness check The runtime exhaustiveness guard in semantic-model.ts caught drift at test time. Added a type-level check in registration-table.ts that catches drift at BUILD time — if a new NodeLabel is added to gitnexus-shared without being classified into one of the three allowlists, TypeScript fails the _exhaustiveCheck assignment and names the missing label. The runtime guard stays as belt-and-suspenders: if a future contributor bypasses the type check with @ts-ignore, the runtime guard still fires in dev/test. Implementation: converted the three allowlist Set initializers to use `as const` tuples, then derived a union type from the tuples and asserted `Exclude extends never`. Zero runtime impact — the exported Sets are unchanged, Map.get hot-path performance is unchanged, the test API is unchanged. Resolves kieran-typescript KT-04. Tests: 21/21 registration-table tests pass with zero modifications. * refactor(test): restore type safety to createMockSymbolTable createMockSymbolTable was widened to (overrides: any = {}): any with an eslint-disable-next-line, and every buildTypeEnv call site passed the mock as `model: mockSymbolTable as any`. The widening masked silent false-green tests: buildTypeEnv accesses model.types/methods/fields, and a flat any-typed override could silently return undefined from a path that TypeScript should have caught at compile time. Defined LegacyMockOverrides interface with typed stubs for each method the mock can override (SymbolTable reads + TypeRegistry/MethodRegistry/ FieldRegistry lookups). Return type is now SemanticModel, so the mock object is compile-checked against the real interface — a missing registry method is a type error, not a silent runtime undefined. Removed the eslint-disable and all 9 `as any` casts at call sites (lines 1287, 1300, 1307, 2124, 2138, 5823, 5835, 5850, 5870). The mock's return value now flows through buildTypeEnv's typed `model` option without coercion. Resolves kieran-typescript KT-01 and testing gap TG-02. This was the highest-value cleanup in the plan — the only finding representing real hidden test weakness. Tests: 360 passed | 7 skipped (type-env.test.ts), typecheck clean. * test: close coverage gaps in model/ registries Added direct unit tests for the three owner-scoped registries that previously had only transitive coverage via symbol-table.test.ts and registration-table.test.ts. These new tests pin behaviors that were flagged by the testing reviewer as untested or undertested. method-registry.test.ts (14 tests): - T-01: arity-fallback branch — when argCount matches no overload, fall back to the full pool so fuzzy resolution still has candidates. Previously untested and would have returned undefined instead of a valid candidate if the branch regressed. - T-02: requiredParameterCount range filtering — methods with default parameters accept any argCount in [requiredParameterCount, parameterCount]. Previously untested at the registry level. - Variadic fallback (parameterCount=undefined is retained during arity narrowing, bypassing range check). - Return-type dedup paths: shared returnType → first wins, differing returnTypes → undefined, firstReturnType=undefined → undefined, single-overload skips dedup entirely. type-registry.test.ts (9 tests): - classByName homonym accumulation (two User classes in different packages both returned). - classByQualifiedName disambiguation — same simple name, different FQNs resolve independently. - Partial classes with identical simple + qualified name accumulate in both indexes. - registerImpl stores Rust impl blocks separately from classes. - Multiple impl blocks per type accumulate. field-registry.test.ts (6 tests): - register/lookup round-trip, owner-scope isolation, last-wins on duplicate key (flat map, not overload list). - clear + re-register round-trip. Extended symbol-table.test.ts cascade test (renamed from "both registries" to "all three registries and the nested symbol table") to also assert model.methods and model.fields are cleared — the test name previously implied full coverage but only asserted types + symbols. Resolves testing findings T-01, T-02, T-03, T-05. Tests: 5667 passed (+29 new), 0 failures. * refactor(test): replace brittle reference-equality tests + add intent comments Two cleanups flagged as low-severity P3 by the testing reviewer: 1. registration-table.test.ts: Replaced three reference-equality tests (hook identity via toBe) with behavioral tests that survive a future refactor to per-label closures. The new "class-like behavior group" describe iterates Class/Struct/Interface/Enum/Record/Trait and verifies each one writes to types.registerClass. Same pattern for Method/Constructor. A separate "behavior group isolation" describe verifies class-like hooks don't leak into methods/fields and Impl never pollutes registerClass. Strictly more coverage than the reference-equality tests provided and implementation-independent. 2. symbol-resolver.test.ts: Added a comment above the lookupExactFull and SM-16: getFiles() describes explaining why they intentionally use createSymbolTable() directly instead of createSemanticModel(). The DAG leaf-only behaviors they test do not involve registries, so testing the bare SymbolTable keeps the unit isolated. Prevents a future reader from "fixing" the inconsistency. 3. qualified-class-lookups.test.ts: Added a comment above `const symbolTable = model.symbols` explaining that processParsing writes still reach the owner-scoped registries via SemanticModel's fan-out — the alias is convenience, not a leaf in isolation. Resolves testing T-04, kieran-typescript KT-05, kieran-typescript KT-06. Tests: affected files all green (112 passed in registration-table + symbol-resolver + qualified-class-lookups). * refactor(model): collapse RoutingDecision wrapper and trim barrel surface Two cleanups against the advanced-review findings on post-Unit-9 state: S2 (cross-reviewer agreement — architecture-strategist + code-simplicity): Delete the RoutingDecision single-field wrapper interface. Post-Unit-1 it held exactly one field (hook: RegistrationHook) and added pure ceremony at every call site — `dispatchTable.get(key)!.hook(name, def)` vs the now-direct `dispatchTable.get(key)!(name, def)`. Change the Map type from Map to Map, drop the interface, and update 17 test call sites. A3 (architecture-strategist): Trim model/index.ts barrel surface. createRegistrationTable, RegistrationHook, and RegistrationTableDeps were re-exported from the barrel despite having zero legitimate consumers outside model/ itself. The only callers (semantic-model.ts and registration-table.test.ts) import directly from ./registration-table.js. Barrel exposure invited external callers to construct orphan dispatch tables with independent registries, weakening the SM-21 ownership inversion where SemanticModel is the composition root. Kept CALLABLE_ONLY_LABELS, INERT_LABELS, DISPATCH_LABELS exported since those remain useful for downstream resolution logic and have no construction risk. Resolves review findings: - S2 (code-simplicity P3, 0.85) + architecture-strategist residual - A3 (architecture-strategist P3, 0.82) Tests: 5674 passed, 0 failures. Typecheck clean. * refactor(model): replace runtime exhaustiveness guard with compile-time bijection Replace the three-layer drift protection (hardcoded ALL_NODE_LABELS array + 3 tuple consts + _ExhaustiveLabelCheck type + runExhaustivenessGuard runtime + CI taxonomy test) with a single Record map that structurally proves every invariant at compile time. ## Before - ALL_NODE_LABELS hardcoded in semantic-model.ts (36 entries, could drift) - DISPATCH_LABELS_TUPLE / CALLABLE_ONLY_LABELS_TUPLE / INERT_LABELS_TUPLE private tuples (36 more entries total, could overlap or miss) - _ClassifiedLabel / _UncoveredLabel type-level check (caught missing labels but NOT duplicates across tuples) - runExhaustivenessGuard runtime throw (only defense against duplicates) - NodeLabel taxonomy coverage test in CI (same check as runtime guard) Four defenses for invariants that the type system can express directly. ## After ```ts type LabelBehavior = 'dispatch' | 'callable-only' | 'inert'; const LABEL_BEHAVIOR = { Class: 'dispatch', // ...36 entries... Tool: 'inert', } as const satisfies Record; ``` The `as const satisfies Record` combo enforces: 1. **Every NodeLabel must be a key** — Record requires all K keys. Adding a NodeLabel to gitnexus-shared without classifying it here fails with "Property 'X' is missing in type ..." naming the drifted label. 2. **No non-NodeLabel keys allowed** — `satisfies` with object literals triggers excess-property checking. A typo'd key fails to compile. 3. **No duplicate classification** — impossible by construction; object keys are unique at the source level. 4. **Valid category** — LabelBehavior is a narrow union, typos caught. `ALL_NODE_LABELS`, `DISPATCH_LABELS`, `CALLABLE_ONLY_LABELS`, and `INERT_LABELS` are now derived via `Object.keys(LABEL_BEHAVIOR)` and `filter(l => LABEL_BEHAVIOR[l] === ...)` — single source of truth, structurally impossible to drift. ## Deleted - runExhaustivenessGuard() function in semantic-model.ts (~18 lines) - ALL_NODE_LABELS hardcoded array in semantic-model.ts (~38 lines) - DISPATCH_LABELS_TUPLE / CALLABLE_ONLY_LABELS_TUPLE / INERT_LABELS_TUPLE private consts in registration-table.ts (~30 lines) - _ClassifiedLabel / _UncoveredLabel / _exhaustiveCheck type machinery (~20 lines) ## Kept named proofs: none The `as const satisfies` on the object literal already catches all four drift modes. Named type-level proofs (_MissingFromMap / _ExtraKeysInMap) are pure duplication and were removed per review. ## Also in this commit - S6: trim wrappedAdd narration comments in semantic-model.ts (Step 1/2/3 block comments removed; kept the Function+ownerId WHY note) - A3: tighten model/index.ts barrel — createRegistrationTable, RegistrationHook, RegistrationTableDeps remain direct-imports only; ALL_NODE_LABELS and LabelBehavior re-exported from the new home in registration-table.ts ## Resolves - Advanced-review S4 (runtime guard per-call cost) — guard no longer exists - Advanced-review S1 (tuple three-defenses indirection) — single Record replaces all tuples - Correctness P3 (exhaustiveness warns-only) — structurally impossible to drift - Unit 6 type-level check — subsumed by the Record type - Unit 3 runtime throw — no longer needed Tests: 5674 passed, 0 failures. Typecheck clean. * test(model): delete duplicate closure-isolation spy tests S5 (code-simplicity P3): The 'closure isolation — each hook can only write to its registry' describe block duplicated the 'behavior group isolation' block's coverage via a different mechanism. Behavioral tests (lines 151-174, kept): table.get('Class')!('User', def); expect(deps.methods.lookupMethodByOwner('unrelated', 'User')).toBeUndefined(); expect(deps.fields.lookupFieldByOwner('unrelated', 'User')).toBeUndefined(); Spy tests (deleted, ~55 lines): vi.spyOn(deps.methods, 'register') table.get('Class')!('User', def); expect(methodsSpy).not.toHaveBeenCalled(); Both assert the same invariant — classHook does not touch the methods or fields registries. The behavioral form observes the END STATE of the registry (lookup returns undefined), which is the actual contract. The spy form asserts the IMPLEMENTATION (a specific method was not called), which couples to internal wiring — a refactor to a different register function name would break the spy test while the behavioral test would still pass. Also dropped the now-unused `vi` import from vitest. Tests: 24/24 registration-table.test.ts pass (-4 from spy deletion). * refactor(model): compile-time cross-invariant between CLASS_TYPES and dispatch classHook A1 (architecture-strategist P2, 0.90): CLASS_TYPES in symbol-table.ts and the class-like entries of the dispatch table were two independent hardcoded sets. Adding a new class-like label (e.g. Swift 'Extension') to one but not the other would silently degrade qualifiedName population — the symptom is subtle (partial qualified-name lookups) and no test asserted the co-extensive invariant. Fixed with a single source of truth and a two-layer compile-time enforcement: ## symbol-table.ts - Add `CLASS_TYPES_TUPLE` as `readonly [...] as const satisfies readonly NodeLabel[]`. The `satisfies` forces every tuple entry to be a valid NodeLabel at compile time. - Export derived type `ClassLikeLabel = typeof CLASS_TYPES_TUPLE[number]`. - Derive `CLASS_TYPES` Set from the tuple — same runtime shape as before, now typed `ReadonlySet`. ## registration-table.ts - Import `CLASS_TYPES_TUPLE` and `ClassLikeLabel` from symbol-table.ts. - Narrow the `satisfies` on `LABEL_BEHAVIOR` via intersection: Record & Record This forces every class-like label to have value 'dispatch' at compile time. Adding a label to CLASS_TYPES_TUPLE without classifying it as dispatch in LABEL_BEHAVIOR fails to compile with a type error naming the drifted label. - Build the class-like entries of the dispatch Map by iterating `CLASS_TYPES_TUPLE` at factory time. Adding a label to the tuple automatically wires it to classHook — no second place to update. ## What the design prevents 1. Drift scenario A (A1 original): 'Extension' added to CLASS_TYPES_TUPLE but not to LABEL_BEHAVIOR → compile error on LABEL_BEHAVIOR's satisfies. 2. Drift scenario B: 'Extension' added to CLASS_TYPES_TUPLE but not wired to classHook → impossible because the Map is derived from the tuple. 3. Drift scenario C: class-like label classified as something other than 'dispatch' in LABEL_BEHAVIOR → compile error on the narrowed intersection. Runtime behavior unchanged: same 6 labels in CLASS_TYPES, same 6 class-like entries in the dispatch Map. Tests pin the behavior via the existing behavior-group tests in registration-table.test.ts. DAG unchanged: registration-table.ts already imported from symbol-table.ts (the allowed upward direction). symbol-table.ts still imports nothing from model/. Tests: 5670 passed, 0 failures. Typecheck clean. * test(field-extraction): use SemanticModel facade instead of raw SymbolTable A6 (architecture-strategist P3, 0.85): field-extraction.test.ts created its FieldExtractorContext fixture with `symbolTable: createSymbolTable()` — a raw SymbolTable leaf, not the facade. In production, the context's symbolTable field is always `model.symbols` (the SemanticModel-wrapped facade where .add() dispatches through the owner-scoped registries). The current field extractors don't call symbolTable.add() at all, so this change is behavior-neutral today. The value is architectural consistency — matching the test fixture to the production shape prevents silent drift if a future field extractor starts registering dynamically-discovered properties via the context. Without the fix, such writes would hit the raw leaf and skip the fan-out, and tests would pass even though the symptom (empty FieldRegistry) would manifest in production. Tests: 50/50 field-extraction.test.ts pass. Production tsc --noEmit clean. Test-tsconfig error count unchanged (634 pre-existing errors in unrelated test files, out of scope). * refactor(A5): decouple model/resolve.ts from language registry Move the MroStrategy type into gitnexus-shared and replace the language: SupportedLanguages parameter on lookupMethodByOwnerWithMRO with a direct mroStrategy: MroStrategy literal. Callers derive the strategy from their language provider before invoking the resolver. model/resolve.ts no longer imports from ../languages/index.js, so the model/ layer is free of cross-layer coupling with the language registry — this closes finding A5 from the SM-20/21/22/23 advanced review (plan 006). * feat(A4): add MethodRegistry.lookupMethodByName flat-by-name index Add a secondary `methodsByName: Map` index on MethodRegistry that returns every method with a given unqualified name, accumulated across owners and overloads. The new index shares SymbolDefinition references with methodByOwner — no duplication. This is step 1 of the A4 double-index removal (plan 006). Tier 3 global resolution will switch to this index in Unit 3 so Method and Constructor can be removed from CALLABLE_TYPES in Unit 4. * refactor(A4): extend Tier 3 + memberCallByFile to consult method registry Add model.methods.lookupMethodByName to Tier 3 global resolution in resolution-context.ts and to the callable-pool build in call-processor.ts (resolveMemberCallByFile + D2 widen path). Intentionally behavior-preserving: Method and Constructor are still in CALLABLE_TYPES so the new lookup returns identical candidates that already reach Tier 3 through callableByName. Both paths dedup by nodeId during this intermediate state — Unit 4 shrinks CALLABLE_TYPES and the dedup is removed. Part of plan 006 A4 step 2. * refactor(A4): shrink CALLABLE_TYPES to free callables only CALLABLE_TYPES = {Function, Macro, Delegate}. Method and Constructor are no longer double-indexed in callableByName — they reach resolvers through model.methods.lookupMethodByName instead. Companion changes: - Introduce CALL_TARGET_TYPES = CALLABLE_TYPES ∪ {Method, Constructor} for the resolver's kind filter (filterCallableCandidates, countCallableCandidates). Separates registration semantics (narrow) from the resolver's acceptable-target set (wide). - type-env.ts for-loop return-type inference consults both indexes, treating the union as the authoritative call pool. - resolveMemberCallByFile + D2 widen path keep the nodeId dedup in place: Python/Rust/Kotlin class methods emitted as Function+ownerId still land in both indexes until Unit 5 unblocks the normalization. - Tier 3 global resolution (resolution-context.ts) keeps the same dedup for the same reason. Test updates reflect the new contract: Method/Constructor live in methodsByName, not callableByName. Orphan Method-without-ownerId now lives only in the file index (no registry coverage). Part of plan 006 — closes A4 for strictly-labeled methods. Python/ Rust/Kotlin Function+ownerId normalization is tracked as Unit 5 (blocked). * refactor: rename CALLABLE_TYPES → FREE_CALLABLE_TYPES Pure rename. The constant's meaning changed in Unit 4 (free callables only — no methods, no constructors) so the name now reflects that scope: "callables that have no owner scope". Updates the constant declaration and every consumer in src/ and test/. Closes plan 006 Unit 6. * refactor(A2): strict SymbolTableReader (pure reads) + SymbolTableWriter (+add) Split the SymbolTable interface into three strictly layered surfaces: - SymbolTableReader: lookups + iteration. NO add, NO clear. Holders cannot mutate the table in any way. - SymbolTableWriter extends Reader: + add. NO clear. Holders can register new symbols but cannot trigger a leaf-index reset. - InternalSymbolTable (private, not exported): + clear. The cascading reset capability is reachable only through createSymbolTable's return type, held exclusively by SemanticModel.rawSymbols. SemanticModel.symbols is now typed as SymbolTableWriter — external consumers (workers, processors, pipelines) can register symbols and query them, but cannot reach .clear(). The A2 LSP fix holds: callers holding any public reference cannot desync the leaf indexes from the owner-scoped registries. Delete the transitional `type SymbolTable = SymbolTableReader` alias and migrate every consumer (src + test) to the explicit names: - Field and parameter annotations use SymbolTableReader by default; only code that calls .add() uses SymbolTableWriter. - parsing-processor (workers + sequential paths) takes SymbolTableWriter so it can register extracted symbols. - field-types, call-processor, named-binding-processor, workers/parse-worker: use SymbolTableReader (query-only). - Tests: drop the stale `clear` fields from mock factories and migrate the semantic-model cascade tests from the removed model.symbols.clear() path to model.clear(). Closes plan 006 Unit 7. Industry sources: TypeScript compiler API builder pattern, Salsa ParallelDatabase, .NET IReadOnlyList. See the a2-lsp-clear-contract-research artifact for full citations. * feat(A2): add SemanticModel.resetFileIndex() partial-reset entry point Add a named method that clears only the leaf file and callable indexes without cascading to the three owner-scoped registries (types, methods, fields). Replaces the rare partial-reset use case that was previously reachable via the now-removed symbols.clear() path from A2 (plan 006 Unit 7). JSDoc makes the semantic difference with model.clear() explicit so future readers don't have to guess which method to call for a given reingestion scenario. Test-first: three scenarios cover the partial-vs-full semantics, re-add after reset, and idempotency. Closes plan 006 Unit 8. * docs(S7): trim registration-table module JSDoc Remove the ~24 lines of design-provenance citations from the module JSDoc. The rust-analyzer, TypeScript-compiler, and Fowler references are preserved in git history via the original SM-22 commits and in plan 006 Unit 9. Keep the ownership diagram, behavior-group table, and the 'How to add a new NodeLabel' checklist — those are load-bearing for future contributors. Closes plan 006 Unit 9 (S7 advanced-review finding). * test(S3): migrate type-env.test.ts off LegacyMockOverrides Replace the createMockSymbolTable bridge and LegacyMockOverrides interface with real createSemanticModel() + add() calls across all 14 call sites. Where a test needs a specific registry lookup that can't be pre-populated cleanly, use vi.spyOn on the real registry instead. Pattern breakdown: - Pattern A (pre-populate via model.symbols.add): 13 sites - Pattern B (vi.spyOn on registry lookup): 1 site Deletes LegacyMockOverrides + createMockSymbolTable entirely. The real MethodRegistry arity/returnType semantics match the hand-rolled mock behavior in every migrated case, and no 'as any' casts remain in the file. Closes plan 006 Unit 10 (S3 advanced-review finding). * refactor: remove unused MroStrategy type exports from language-provider and resolve modules * refactor: relocate symbol-table, heritage-map, resolution-context into model/ Use git mv so blame and history follow each file: - gitnexus/src/core/ingestion/symbol-table.ts → model/symbol-table.ts - gitnexus/src/core/ingestion/heritage-map.ts → model/heritage-map.ts - gitnexus/src/core/ingestion/resolution-context.ts → model/resolution-context.ts These three files are part of the SemanticModel layer (file/callable indexes, heritage parent map, tiered resolver) and now sit alongside the registries they collaborate with. Updates every consumer import path across src/ and test/ to the new locations. * refactor(model): enforce pure-leaf DAG + delete legacy re-exports model/ is now a pure leaf: zero upward imports and zero compat shims in its parent processors. Completes the DAG cleanup started in the previous commit. 1. walkBindingChain — moved into model/resolution-context.ts; named-binding-processor.ts deleted. 2. NamedImportMap + NamedImportBinding + isFileInPackageDir — moved into model/resolution-context.ts. Every consumer now imports from the canonical location directly. Legacy re-exports in import-processor.ts deleted. 3. c3Linearize + gatherAncestors — moved into model/resolve.ts. mro-processor.ts imports them back for computeMRO. Legacy c3Linearize re-export from mro-processor.ts deleted. 4. ExtractedHeritage type — moved into model/heritage-map.ts. call-processor.ts, parsing-processor.ts, pipeline.ts, heritage-processor.ts, and the test files now import it from the canonical location. Legacy re-exports in parse-worker.ts and heritage-processor.ts deleted. 5. resolveExtendsType — rewritten in model/heritage-map.ts to take an explicit HeritageResolutionStrategy (A5-style DI). buildHeritageMap accepts an optional getHeritageStrategy callback; production uses getHeritageStrategyForLanguage from heritage-processor.ts. Legacy resolveExtendsType re-export from heritage-processor.ts deleted. Verified: - grep 'from "..' gitnexus/src/core/ingestion/model → empty - grep 'Re-export for legacy' gitnexus/src/core/ingestion → empty - npx tsc --noEmit → clean - npx vitest run → 5686 passing * docs(model): strip phase/plan references from module comments Remove SM-20/21/22/23, A2/A4/A5, plan 006, Unit N labels and historical phrasing ("previously", "legacy", "model-leaf DAG cleanup") from all 10 files in src/core/ingestion/model/. Preserve domain vocabulary (Tier 1/2/3), invariants, and caveats — only the plan archaeology is gone. * refactor(model): tighten interface segregation + compile-time invariants Apply four gated findings from branch-wide code review: - SemanticModel.symbols now typed as SymbolTableReader; MutableSemanticModel widens it back to SymbolTableWriter. ResolutionContext.model is typed as MutableSemanticModel since it owns the lifecycle. Resolvers that only query symbols can annotate their own fields as SemanticModel to drop write access at the type level. - Lookup methods (lookupExactAll, lookupCallableByName, lookupClassByName, lookupClassByQualifiedName, lookupImplByName) now return readonly SymbolDefinition[]. The returned arrays are live views into the internal indexes; the readonly marker prevents accidental caller mutation. walkBindingChain return type narrowed to match. - FREE_CALLABLE_TUPLE + FreeCallableLabel exported from symbol-table.ts as the single source of truth for free-callable labels. LABEL_BEHAVIOR now satisfies Record as a second cross-invariant alongside Record. Adding a label to the tuple without classifying it as 'callable-only' fails at build time. CALLABLE_ONLY_LABELS is now a re-export alias of FREE_CALLABLE_TYPES so the two sets cannot drift. - walkBindingChain fast-exits before allocating its cycle-detection Set when the caller's file has no named bindings. Skips ~200k transient Set allocations per large-repo resolution pass. Also fixes five stale comments flagged by the review: duplicate JSDoc block on RegistrationHook merged; resolve.ts "delegates to mro-processor" direction corrected; RegistrationTableDeps JSDoc names createRegistrationTable (not createSymbolTable); mro-processor.ts "re-exported at top" stale comment removed; gatherAncestors export comment matches reality. tsc --noEmit clean, full test suite green (5786 tests). * refactor(model): resolve four deferred P2 review findings Address the four gated items from the branch-wide review that needed design decisions before applying: F#3 — Method/Constructor without ownerId fallback to callable index. The dispatch hook silently skips owner-scoped labels that lack an owner (an extractor contract violation — AST-degraded parse, or a buggy language extractor). Pre-dispatch-table code let such defs fall through to callableByName and stay reachable at Tier 3 global resolution. This restores that fallback in SymbolTable.add so orphaned Methods and Constructors don't silently vanish. Property deliberately does NOT participate in the fallback to avoid polluting common names like id / name / type. F#4 — Delete MutableSemanticModel.resetFileIndex. The method had zero production callers (only three tests), documented a "rare partial- reingestion flow" that was never implemented, and contained the adversarial-reviewer's double-populate trap: calling resetFileIndex followed by re-adding the same class symbol would push a duplicate SymbolDefinition into TypeRegistry.classByName without ever clearing the first one. If incremental reingestion is ever needed, it can be designed properly with per-file TypeRegistry invalidation. For now, deleting the footgun is safer than documenting it. F#5 — Compile-time dispatch-table completeness check. `LABEL_BEHAVIOR` already enforces "every NodeLabel is classified" via `Record`, but the dispatch-table factory populated its Map with manual `table.set(...)` calls that TypeScript could not correlate back to the `'dispatch'` classification. Add a type-level `DispatchLabel` extracted from `LABEL_BEHAVIOR` via a conditional mapped type, and build the table from an object literal that satisfies `Record`. Adding a new dispatch-classified label without wiring it to a hook now fails the build with a named-key error — no more silent no-op hooks. F#7 — Tier 3 dedup fast-path via MethodRegistry.hasFunctionMethods. The Set-based dedup between callableDefs and methodDefs is only needed when a Python/Rust/Kotlin class method (emitted as Function+ownerId by the worker) lands in both indexes. For TS/Java/C#/C++/Ruby-only repos — where the two indexes are disjoint by construction — the dedup was pure overhead on every global-tier hit. MethodRegistry now tracks whether any Function-typed def was ever registered, and resolution- context branches Tier 3 into a concat-only fast path when that flag is false. Slow path with dedup survives unchanged for mixed-language repos. New tests pin the invariants: hasFunctionMethods flag transitions, Method/Constructor orphan fallback, Property non-fallback, and the MethodRegistry clear() reset. Full test suite green (5756 tests). * refactor(model): close remaining P3 review findings + coverage gaps Address the remaining review items in one batch. Production refactors: - Rename classHook → classLikeHook (M05). The hook handles Class / Struct / Interface / Enum / Record / Trait; the vocabulary used in surrounding docs and the behavior-group table is "class-like". The rename makes the code match the taxonomy without forcing readers through a mental glossary. - Extract MAX_BINDING_CHAIN_DEPTH constant in resolution-context.ts and document it as a known silent false-negative source (ADV-003). Five hops cover the common TypeScript monorepo pattern; raising the cap is a one-line change if a real repo exceeds it. walkBindingChain consumes the constant so the 5 magic number no longer floats free. - Replace defs.filter() allocation in MethodRegistry.lookupMethodByOwner with a two-pass streaming count + conditional materialization (PERF-04). Pure-match and pure-reject arity paths now skip the filtered-array allocation entirely; only the discriminating case (at least one match AND at least one rejection) pays it. - Rewrite NOOP_SYMBOL_TABLE in parse-worker.ts and NOOP_SYMBOL_TABLE_SEQ in parsing-processor.ts to implement all six SymbolTableReader methods (ADV-005). The `as unknown as SymbolTableReader` cast is removed in favor of a direct SymbolTableReader annotation, so future additions to the interface surface as compile errors on the stubs instead of silently falling through. - type-env.ts getCallableUnionCount and getFirstCallable now take `model: SemanticModel` as an explicit argument instead of reaching into the enclosing `model!` non-null assertion (KT-003). Callers enter via an `if (model)` guard and pass the narrowed reference, so the non-null precondition is visible at the type level and the closures cannot be accidentally extracted into a context without the guard. - Tier 3 dedup in resolution-context.ts now covers all four index reads (classDefs, implDefs, callableDefs, methodDefs) via a pushUnique helper (C-03). Previously classDefs and implDefs were spread directly without dedup; any theoretical nodeId collision would have produced duplicates in globalDefs. Test infrastructure: - Extract makeDef / makeMethod factory helpers into test/unit/model/helpers.ts (T-07). The four registry/table test files now import the shared helper and specialize with overrides, removing ~25 lines of duplicated boilerplate and creating a single point of maintenance. New test coverage: - T-01: c3 BFS fallback — cyclic Python hierarchy that fails c3 linearization and must fall back to heritageMap.getAncestors() BFS order. Added to the lookupMethodByOwnerWithMRO describe block. - T-02: Tier 2a-named precedence — verifies the binding chain walker fires before Tier 2a import-scoped when an aliased import `import { User as U } from B` competes with a raw same-name Tier 2a hit. Also pins Tier 1 same-file precedence over Tier 2a-named. - T-03: Tier 3 Function+ownerId dedup — end-to-end test that a Python class method emitted as `Function + ownerId` yields exactly ONE Tier 3 candidate (not two). Companion test pins the fast-path branch for hasFunctionMethods === false repos. - T-06: walkBindingChain guards — circular re-export detection, depth-cap exceeded drop, and boundary case at exactly MAX_BINDING_CHAIN_DEPTH hops resolving successfully. All tests added to a new test/unit/model/resolution-context.test.ts dedicated to ResolutionContext.resolve() tier-precedence invariants. Full suite: 5708 passing (minus the known Windows LBUG lock flake that passes in isolation). --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Gergo Magyar --- AGENTS.md | 2 +- CLAUDE.md | 2 +- gitnexus-shared/src/index.ts | 1 + gitnexus-shared/src/mro-strategy.ts | 23 + gitnexus/src/core/ingestion/call-processor.ts | 243 ++-- gitnexus/src/core/ingestion/field-types.ts | 4 +- .../src/core/ingestion/heritage-processor.ts | 59 +- .../src/core/ingestion/import-processor.ts | 30 +- .../src/core/ingestion/language-provider.ts | 13 +- .../core/ingestion/model/field-registry.ts | 53 + .../ingestion/{ => model}/heritage-map.ts | 93 +- gitnexus/src/core/ingestion/model/index.ts | 88 ++ .../core/ingestion/model/method-registry.ts | 204 ++++ .../ingestion/model/registration-table.ts | 333 ++++++ .../{ => model}/resolution-context.ts | 205 +++- gitnexus/src/core/ingestion/model/resolve.ts | 284 +++++ .../core/ingestion/model/semantic-model.ts | 193 ++++ .../src/core/ingestion/model/symbol-table.ts | 381 ++++++ .../src/core/ingestion/model/type-registry.ts | 113 ++ gitnexus/src/core/ingestion/mro-processor.ts | 113 +- .../core/ingestion/named-binding-processor.ts | 47 - .../src/core/ingestion/parsing-processor.ts | 28 +- gitnexus/src/core/ingestion/pipeline.ts | 29 +- gitnexus/src/core/ingestion/symbol-table.ts | 439 ------- gitnexus/src/core/ingestion/type-env.ts | 97 +- .../core/ingestion/workers/parse-worker.ts | 28 +- .../integration/ignore-and-skip-e2e.test.ts | 2 +- .../qualified-class-lookups.test.ts | 30 +- gitnexus/test/unit/call-form.test.ts | 10 +- gitnexus/test/unit/call-processor.test.ts | 446 ++++--- gitnexus/test/unit/field-extraction.test.ts | 12 +- gitnexus/test/unit/heritage-map.test.ts | 124 +- gitnexus/test/unit/heritage-processor.test.ts | 51 +- gitnexus/test/unit/import-processor.test.ts | 2 +- .../test/unit/model/field-registry.test.ts | 74 ++ gitnexus/test/unit/model/helpers.ts | 27 + .../test/unit/model/method-registry.test.ts | 374 ++++++ .../unit/model/registration-table.test.ts | 267 +++++ .../unit/model/resolution-context.test.ts | 173 +++ .../test/unit/model/semantic-model.test.ts | 124 ++ .../test/unit/model/type-registry.test.ts | 146 +++ .../sequential-language-availability.test.ts | 2 +- gitnexus/test/unit/symbol-resolver.test.ts | 274 +++-- gitnexus/test/unit/symbol-table.test.ts | 1024 +++++++++++------ gitnexus/test/unit/type-env.test.ts | 550 +++------ 45 files changed, 4780 insertions(+), 2037 deletions(-) create mode 100644 gitnexus-shared/src/mro-strategy.ts create mode 100644 gitnexus/src/core/ingestion/model/field-registry.ts rename gitnexus/src/core/ingestion/{ => model}/heritage-map.ts (60%) create mode 100644 gitnexus/src/core/ingestion/model/index.ts create mode 100644 gitnexus/src/core/ingestion/model/method-registry.ts create mode 100644 gitnexus/src/core/ingestion/model/registration-table.ts rename gitnexus/src/core/ingestion/{ => model}/resolution-context.ts (55%) create mode 100644 gitnexus/src/core/ingestion/model/resolve.ts create mode 100644 gitnexus/src/core/ingestion/model/semantic-model.ts create mode 100644 gitnexus/src/core/ingestion/model/symbol-table.ts create mode 100644 gitnexus/src/core/ingestion/model/type-registry.ts delete mode 100644 gitnexus/src/core/ingestion/named-binding-processor.ts delete mode 100644 gitnexus/src/core/ingestion/symbol-table.ts create mode 100644 gitnexus/test/unit/model/field-registry.test.ts create mode 100644 gitnexus/test/unit/model/helpers.ts create mode 100644 gitnexus/test/unit/model/method-registry.test.ts create mode 100644 gitnexus/test/unit/model/registration-table.test.ts create mode 100644 gitnexus/test/unit/model/resolution-context.test.ts create mode 100644 gitnexus/test/unit/model/semantic-model.test.ts create mode 100644 gitnexus/test/unit/model/type-registry.test.ts diff --git a/AGENTS.md b/AGENTS.md index e6cefed11..c9e2158c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,7 +63,7 @@ Generic “core standards” playbooks are often long and stack-specific. For th # GitNexus — Code Intelligence -This project is indexed by GitNexus as **GitNexus** (3883 symbols, 9861 relationships, 225 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **GitNexus** (3975 symbols, 10043 relationships, 245 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/CLAUDE.md b/CLAUDE.md index 7b0f175b1..fc0abde9a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ If always-on instructions grow, load deep conventions via conditional reads (e.g GitNexus MCP rules are in the ` # GitNexus — Code Intelligence -This project is indexed by GitNexus as **GitNexus** (3883 symbols, 9861 relationships, 225 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **GitNexus** (3975 symbols, 10043 relationships, 245 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index bd89dfc62..4024bf070 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -19,6 +19,7 @@ export type { NodeTableName, RelType } from './lbug/schema-constants.js'; // Language support export { SupportedLanguages } from './languages.js'; export { getLanguageFromFilename, getSyntaxLanguageFromFilename } from './language-detection.js'; +export type { MroStrategy } from './mro-strategy.js'; // Pipeline progress export type { PipelinePhase, PipelineProgress } from './pipeline.js'; diff --git a/gitnexus-shared/src/mro-strategy.ts b/gitnexus-shared/src/mro-strategy.ts new file mode 100644 index 000000000..6168c67c0 --- /dev/null +++ b/gitnexus-shared/src/mro-strategy.ts @@ -0,0 +1,23 @@ +/** + * MRO (Method Resolution Order) strategy — shared between CLI and any + * future consumer that reasons about multiple-inheritance semantics. + * + * Lives in `gitnexus-shared` so the low-level resolution module + * (`core/ingestion/model/resolve.ts`) does not need to import from + * `languages/` — keeping the `model/` layer free of language-registry + * coupling. + * + * Strategy semantics: + * - `first-wins`: BFS ancestor walk, first match wins (default). + * - `leftmost-base`: BFS ancestor walk, leftmost base wins (C++). + * - `c3`: C3-linearized ancestor order, first match wins (Python). + * - `implements-split`: BFS walk, first match wins (Java/C#/Kotlin) — full + * interface-default ambiguity is handled at graph level. + * - `qualified-syntax`: No auto-resolution (Rust — requires `::m`). + */ +export type MroStrategy = + | 'first-wins' + | 'c3' + | 'leftmost-base' + | 'implements-split' + | 'qualified-syntax'; diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 5edd72737..ce0364a4d 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -1,11 +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, CALLABLE_TYPES } from './symbol-table.js'; +import type { SymbolDefinition, SymbolTableReader } from './model/symbol-table.js'; +import { CLASS_TYPES, CALL_TARGET_TYPES } from './model/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 type { ResolutionContext } from './model/resolution-context.js'; +import { TIER_CONFIDENCE, type ResolutionTier } from './model/resolution-context.js'; +import type { TieredCandidates } from './model/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'; @@ -32,24 +32,24 @@ import { } from './utils/call-analysis.js'; import { buildTypeEnv, isSubclassOf } from './type-env.js'; import type { ConstructorBinding, TypeEnvironment } from './type-env.js'; -import type { HeritageMap } from './heritage-map.js'; -import { c3Linearize } from './mro-processor.js'; +import type { HeritageMap } from './model/heritage-map.js'; import type { BindingAccumulator } from './binding-accumulator.js'; import { getTreeSitterBufferSize } from './constants.js'; import type { ExtractedCall, ExtractedAssignment, - ExtractedHeritage, ExtractedRoute, ExtractedFetchCall, FileConstructorBindings, } from './workers/parse-worker.js'; +import type { ExtractedHeritage } from './model/heritage-map.js'; import { normalizeFetchURL, routeMatches } from './route-extractors/nextjs.js'; import { extractTemplateComponents } from './vue-sfc-extractor.js'; import { extractReturnTypeName, stripNullable } from './type-extractors/shared.js'; import type { LiteralTypeInferrer } from './type-extractors/types.js'; import type { SyntaxNode } from './utils/ast-helpers.js'; import { extractParsedCallSite } from './call-sites/extract-language-call-site.js'; +import { lookupMethodByOwnerWithMRO } from './model/resolve.js'; /** Per-file resolved type bindings for exported symbols. * Populated during call processing, consumed by Phase 14 re-resolution pass. */ @@ -204,7 +204,7 @@ function collectExportedBindings( * exported symbols that have callables with known return types. */ export function buildExportedTypeMapFromGraph( graph: KnowledgeGraph, - symbolTable: SymbolTable, + symbolTable: SymbolTableReader, ): ExportedTypeMap { const result: ExportedTypeMap = new Map(); graph.forEachNode((node) => { @@ -652,7 +652,7 @@ function findInterfaceDispatchTargets( const results: ResolveResult[] = []; for (const implFile of implFiles) { - const methods = ctx.symbols.lookupExactAll(implFile, calledName); + const methods = ctx.model.symbols.lookupExactAll(implFile, calledName); for (const method of methods) { if (method.nodeId !== primaryNodeId) { results.push({ @@ -808,7 +808,7 @@ export const processCalls = async ( const importedReturnTypes = importedReturnTypesMap?.get(file.path); const importedRawReturnTypes = importedRawReturnTypesMap?.get(file.path); const typeEnv = buildTypeEnv(tree, language, { - symbolTable: ctx.symbols, + model: ctx.model, parentMap, importedBindings, importedReturnTypes, @@ -817,7 +817,7 @@ export const processCalls = async ( extractFunctionName: provider?.methodExtractor?.extractFunctionName, }); if (typeEnv && exportedTypeMap) { - const fileExports = collectExportedBindings(typeEnv, file.path, ctx.symbols, graph); + const fileExports = collectExportedBindings(typeEnv, file.path, ctx.model.symbols, graph); if (fileExports) exportedTypeMap.set(file.path, fileExports); } if (bindingAccumulator) { @@ -1021,7 +1021,7 @@ export const processCalls = async ( description: item.accessorType, }, }); - ctx.symbols.add(file.path, item.propName, nodeId, 'Property', { + ctx.model.symbols.add(file.path, item.propName, nodeId, 'Property', { ...(propEnclosingClassId ? { ownerId: propEnclosingClassId } : {}), ...(item.declaredType ? { declaredType: item.declaredType } : {}), }); @@ -1093,8 +1093,8 @@ export const processCalls = async ( if ( isSubclassOf(ctorType, receiverTypeName, parentMap) || isSubclassOf(ctorType, receiverTypeName, globalParentMap) || - (ctx.symbols.lookupClassByName(ctorType).length > 0 && - ctx.symbols.lookupClassByName(receiverTypeName).length > 0) + (ctx.model.types.lookupClassByName(ctorType).length > 0 && + ctx.model.types.lookupClassByName(receiverTypeName).length > 0) ) { receiverTypeName = ctorType; } @@ -1299,7 +1299,7 @@ export const processCalls = async ( return collectedHeritage; }; -// CALLABLE_TYPES imported from symbol-table.ts — single source of truth. +// FREE_CALLABLE_TYPES imported from symbol-table.ts — single source of truth. const CONSTRUCTOR_TARGET_TYPES = new Set(['Constructor', 'Class', 'Struct', 'Record']); @@ -1320,10 +1320,14 @@ const filterCallableCandidates = ( } else { const types = candidates.filter((c) => CONSTRUCTOR_TARGET_TYPES.has(c.type)); kindFiltered = - types.length > 0 ? types : candidates.filter((c) => CALLABLE_TYPES.has(c.type)); + types.length > 0 ? types : candidates.filter((c) => CALL_TARGET_TYPES.has(c.type)); } } else { - kindFiltered = candidates.filter((c) => CALLABLE_TYPES.has(c.type)); + // CALL_TARGET_TYPES (not FREE_CALLABLE_TYPES) — the post-A4 filter must + // also admit Method and Constructor candidates, which are now unioned + // into the pool from `model.methods.lookupMethodByName` rather than + // `symbols.lookupCallableByName`. + kindFiltered = candidates.filter((c) => CALL_TARGET_TYPES.has(c.type)); } if (kindFiltered.length === 0) return []; @@ -1360,7 +1364,7 @@ const countCallableCandidates = ( const typeOk = callForm === 'constructor' ? CONSTRUCTOR_TARGET_TYPES.has(c.type) - : CALLABLE_TYPES.has(c.type); + : CALL_TARGET_TYPES.has(c.type); if (!typeOk) continue; // Arity filter if ( @@ -1573,11 +1577,27 @@ const resolveModuleAliasedCall = ( ); } if (filtered.length === 0) { - // Widen to global callable index scoped to the aliased module file. + // Widen to global callable+method indexes scoped to the aliased module + // file. Function+ownerId (Python/Rust/Kotlin) is still routed to both + // indexes until Unit 5 unblocks, so dedup by nodeId. const cacheKey = `${call.calledName}\0${moduleFile}`; let defs = widenCache?.get(cacheKey); if (!defs) { - defs = ctx.symbols.lookupCallableByName(call.calledName); + const rawCallable = ctx.model.symbols.lookupCallableByName(call.calledName); + const rawMethods = ctx.model.methods.lookupMethodByName(call.calledName); + const widenCombined: SymbolDefinition[] = []; + const widenSeen = new Set(); + for (const d of rawCallable) { + if (widenSeen.has(d.nodeId)) continue; + widenSeen.add(d.nodeId); + widenCombined.push(d); + } + for (const d of rawMethods) { + if (widenSeen.has(d.nodeId)) continue; + widenSeen.add(d.nodeId); + widenCombined.push(d); + } + defs = widenCombined; widenCache?.set(cacheKey, defs); } filtered = filterCallableCandidates(defs, call.argCount, call.callForm).filter( @@ -1618,11 +1638,26 @@ const resolveMemberCallByFile = ( const typeNodeIds = new Set(typeResolved.candidates.map((d) => d.nodeId)); const typeFiles = new Set(typeResolved.candidates.map((d) => d.filePath)); - const methodPool = filterCallableCandidates( - ctx.symbols.lookupCallableByName(calledName), - argCount, - callForm, - ); + // A4 (plan 006, Unit 4): consult both indexes. Strictly-labeled + // Method/Constructor are disjoint, but Function+ownerId (Python/Rust/ + // Kotlin) is routed into BOTH indexes by `wrappedAdd` until Unit 5 + // unblocks — dedup by nodeId so overload disambiguation doesn't see + // phantom duplicates. + const rawCallablePool = ctx.model.symbols.lookupCallableByName(calledName); + const rawMethodPool = ctx.model.methods.lookupMethodByName(calledName); + const combinedPool: SymbolDefinition[] = []; + const combinedSeen = new Set(); + for (const def of rawCallablePool) { + if (combinedSeen.has(def.nodeId)) continue; + combinedSeen.add(def.nodeId); + combinedPool.push(def); + } + for (const def of rawMethodPool) { + if (combinedSeen.has(def.nodeId)) continue; + combinedSeen.add(def.nodeId); + combinedPool.push(def); + } + const methodPool = filterCallableCandidates(combinedPool, argCount, callForm); const fileFiltered = methodPool.filter((c) => typeFiles.has(c.filePath)); if (fileFiltered.length === 1) { return toResolveResult(fileFiltered[0], typeResolved.tier); @@ -1951,7 +1986,7 @@ const resolveFieldOwnership = ( const classDef = typeResolved.candidates.find((d) => CLASS_LIKE_TYPES.has(d.type)); if (!classDef) return undefined; - return ctx.symbols.lookupFieldByOwner(classDef.nodeId, fieldName) ?? undefined; + return ctx.model.fields.lookupFieldByOwner(classDef.nodeId, fieldName) ?? undefined; }; /** @@ -1987,10 +2022,12 @@ const resolveMethodByOwner = ( const typeResolved = ctx.resolve(receiverTypeName, filePath); if (!typeResolved) return undefined; - // 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). + // MRO walking needs a language hint so we can derive the per-language + // strategy; compute it 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; + const mroStrategy = language != null ? getProvider(language).mroStrategy : null; + const canWalkMRO = heritageMap != null && mroStrategy != null; // Iterate all class-like candidates tracking the first unambiguous hit. // Zero-allocation fast path: the common case is exactly one class candidate, @@ -2014,11 +2051,11 @@ const resolveMethodByOwner = ( candidate.nodeId, methodName, heritageMap, - ctx.symbols, - language, + ctx.model, + mroStrategy, argCount, ) - : ctx.symbols.lookupMethodByOwner(candidate.nodeId, methodName, argCount); + : ctx.model.methods.lookupMethodByOwner(candidate.nodeId, methodName, argCount); if (!def) continue; if (!firstDef) { firstDef = def; @@ -2212,8 +2249,8 @@ export const resolveFreeCall = ( * 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. + * Uses {@link TypeRegistry.lookupClassByName} for O(1) class lookup and + * {@link MethodRegistry.lookupMethodByOwner} for constructor resolution. * {@link resolveCallTarget} delegates here for constructor and free-form calls * that target a class. * @@ -2265,7 +2302,7 @@ export const resolveStaticCall = ( // 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); + const allClasses = ctx.model.types.lookupClassByName(className); if (allClasses.length === 0) return null; // 2. Scope via ctx.resolve for import-tier information. Reuse the caller's @@ -2296,7 +2333,7 @@ export const resolveStaticCall = ( let firstDef: SymbolDefinition | undefined; let ambiguous = false; for (const candidate of classCandidates) { - const def = ctx.symbols.lookupMethodByOwner(candidate.nodeId, className, argCount); + const def = ctx.model.methods.lookupMethodByOwner(candidate.nodeId, className, argCount); if (!def || def.type !== 'Constructor') continue; if (!firstDef) { firstDef = def; @@ -2372,138 +2409,6 @@ export const resolveStaticCall = ( return null; }; -// --------------------------------------------------------------------------- -// MRO-aware method resolution via HeritageMap (SM-9) -// --------------------------------------------------------------------------- - -/** - * Per-HeritageMap cache of C3 linearization results keyed by owner nodeId. - * - * HeritageMap instances are immutable after construction, so C3 output is - * stable for the lifetime of a HeritageMap. WeakMap lets the cache auto-drain - * when the HeritageMap is garbage collected (end of ingestion run), so we - * never need to manually invalidate it. - * - * `null` is a sentinel for "C3 failed for this owner" (cyclic or inconsistent - * hierarchy) so we don't re-run the expensive linearization repeatedly. - */ -const c3LinearizationCache = new WeakMap>(); - -const getCachedC3Linearization = ( - ownerNodeId: string, - heritageMap: HeritageMap, -): readonly string[] | null => { - let perHmCache = c3LinearizationCache.get(heritageMap); - if (!perHmCache) { - perHmCache = new Map(); - c3LinearizationCache.set(heritageMap, perHmCache); - } - const cached = perHmCache.get(ownerNodeId); - if (cached !== undefined) return cached; - const parentMap = buildParentMapFromHeritage(ownerNodeId, heritageMap); - const result = c3Linearize(ownerNodeId, parentMap, new Map()) ?? null; - perHmCache.set(ownerNodeId, result); - return result; -}; - -/** - * Build a parentMap from HeritageMap for use with c3Linearize. - * Traverses the parent chain starting from startNodeId, collecting all - * parent→children relationships into a Map. - */ -const buildParentMapFromHeritage = ( - startNodeId: string, - heritageMap: HeritageMap, -): Map => { - const parentMap = new Map(); - const visited = new Set(); - const queue = [startNodeId]; - - while (queue.length > 0) { - const nodeId = queue.shift()!; - if (visited.has(nodeId)) continue; - visited.add(nodeId); - const parents = heritageMap.getParents(nodeId); - if (parents.length > 0) { - parentMap.set(nodeId, parents); - for (const p of parents) { - if (!visited.has(p)) queue.push(p); - } - } - } - - return parentMap; -}; - -/** - * Look up a method on an owner class, walking the parent chain via HeritageMap - * when the method isn't found on the direct owner. - * - * Respects the 5 per-language MRO strategies: - * - `first-wins`: BFS ancestor walk, first match wins (default) - * - `leftmost-base`: BFS ancestor walk, leftmost base in declaration order wins (C++); - * HeritageMap preserves insertion order matching source declaration, - * so BFS order is equivalent to leftmost-base semantics - * - `c3`: C3-linearized ancestor order, first match wins (Python) - * - `implements-split`: BFS ancestor walk, first match wins (Java/C#) — - * full ambiguity detection for multiple interface defaults - * is handled by computeMRO at graph level - * - `qualified-syntax`: No auto-resolution (Rust) — returns undefined - * - * Delegates to mro-processor.ts c3Linearize for C3 strategy. - * - * @internal Exported only to enable unit testing in isolation. The proper - * entry point for callers outside this module is {@link resolveMethodByOwner}, - * which handles receiver-type resolution before delegating here. - */ -export const lookupMethodByOwnerWithMRO = ( - ownerNodeId: string, - methodName: string, - heritageMap: HeritageMap, - symbols: SymbolTable, - language: SupportedLanguages, - argCount?: number, -): SymbolDefinition | undefined => { - // 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; - - // Rust: requires qualified syntax (::method), no auto-resolution - if (strategy === 'qualified-syntax') return undefined; - - // Determine ancestor walk order based on MRO strategy. - // readonly to accept the cached (frozen) c3 linearization without copying. - let ancestors: readonly string[]; - if (strategy === 'c3') { - // Delegate to mro-processor.ts C3 linearization (memoized per HeritageMap - // so repeated calls for the same owner within an ingestion run reuse the - // linearization instead of rebuilding the parent map and re-running C3). - // c3Linearize returns ancestors only (excludes the owner itself), - // matching heritageMap.getAncestors() semantics. - const c3Result = getCachedC3Linearization(ownerNodeId, heritageMap); - // Fall back to BFS order if C3 fails (cyclic or inconsistent hierarchy). - // Note: BFS order may not preserve Python MRO semantics in these edge - // cases, but cyclic/inconsistent hierarchies are invalid in Python anyway. - ancestors = c3Result ?? heritageMap.getAncestors(ownerNodeId); - } else { - // first-wins, leftmost-base, implements-split: BFS order via HeritageMap - ancestors = heritageMap.getAncestors(ownerNodeId); - } - - // 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, argCount); - if (method) return method; - } - - return undefined; -}; - /** * Create a deduplicated ACCESSES edge emitter for a single source node. * Each (sourceId, fieldNodeId) pair is emitted at most once per source. diff --git a/gitnexus/src/core/ingestion/field-types.ts b/gitnexus/src/core/ingestion/field-types.ts index 5e89d34bb..8dd1a9f6c 100644 --- a/gitnexus/src/core/ingestion/field-types.ts +++ b/gitnexus/src/core/ingestion/field-types.ts @@ -1,7 +1,7 @@ // gitnexus/src/core/ingestion/field-types.ts import type { TypeEnvironment } from './type-env.js'; -import type { SymbolTable } from './symbol-table.js'; +import type { SymbolTableReader } from './model/symbol-table.js'; import { SupportedLanguages } from 'gitnexus-shared'; /** @@ -57,7 +57,7 @@ export interface FieldExtractorContext { /** Type environment for resolution */ typeEnv: TypeEnvironment; /** Symbol table for FQN lookups */ - symbolTable: SymbolTable; + symbolTable: SymbolTableReader; /** Current file path */ filePath: string; /** Language ID */ diff --git a/gitnexus/src/core/ingestion/heritage-processor.ts b/gitnexus/src/core/ingestion/heritage-processor.ts index 37c3653a8..bc8628fa5 100644 --- a/gitnexus/src/core/ingestion/heritage-processor.ts +++ b/gitnexus/src/core/ingestion/heritage-processor.ts @@ -19,47 +19,34 @@ import { ASTCache } from './ast-cache.js'; import Parser from 'tree-sitter'; import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/parser-loader.js'; import { generateId } from '../../lib/utils.js'; -import { getLanguageFromFilename } from 'gitnexus-shared'; +import { getLanguageFromFilename, type SupportedLanguages } from 'gitnexus-shared'; import { isVerboseIngestionEnabled } from './utils/verbose.js'; import { yieldToEventLoop } from './utils/event-loop.js'; -import { SupportedLanguages } from 'gitnexus-shared'; import { getProvider } from './languages/index.js'; import { getTreeSitterBufferSize } from './constants.js'; -import type { ExtractedHeritage } from './workers/parse-worker.js'; -import type { ResolutionContext } from './resolution-context.js'; -import { TIER_CONFIDENCE } from './resolution-context.js'; +import type { + ExtractedHeritage, + HeritageResolutionStrategy, + HeritageStrategyLookup, +} from './model/heritage-map.js'; +import { resolveExtendsType } from './model/heritage-map.js'; +import type { ResolutionContext } from './model/resolution-context.js'; +import { TIER_CONFIDENCE } from './model/resolution-context.js'; /** - * Determine whether a heritage.extends capture is actually an IMPLEMENTS relationship. - * Uses the symbol table first (authoritative — Tier 1); falls back to provider-defined - * heuristics for external symbols not present in the graph: - * - interfaceNamePattern: matched against parent name (e.g., /^I[A-Z]/ for C#/Java) - * - heritageDefaultEdge: 'IMPLEMENTS' causes all unresolved parents to map to IMPLEMENTS - * - All others: default EXTENDS + * Derive the heritage-resolution strategy for a language from its + * `LanguageProvider`. This is the production wiring that `buildHeritageMap` + * and the standalone `resolveExtendsType` call site use — the model layer + * itself stays unaware of the provider registry. */ -/** Exported for implementor-map construction (C#/Java: `extends` rows in base_list may be interfaces). */ -export const resolveExtendsType = ( - parentName: string, - currentFilePath: string, - ctx: ResolutionContext, - language: SupportedLanguages, -): { type: 'EXTENDS' | 'IMPLEMENTS'; idPrefix: string } => { - const resolved = ctx.resolve(parentName, currentFilePath); - if (resolved && resolved.candidates.length > 0) { - const isInterface = resolved.candidates[0].type === 'Interface'; - return isInterface - ? { type: 'IMPLEMENTS', idPrefix: 'Interface' } - : { type: 'EXTENDS', idPrefix: 'Class' }; - } - // Unresolved symbol — fall back to provider-defined heuristics - const provider = getProvider(language); - if (provider.interfaceNamePattern?.test(parentName)) { - return { type: 'IMPLEMENTS', idPrefix: 'Interface' }; - } - if (provider.heritageDefaultEdge === 'IMPLEMENTS') { - return { type: 'IMPLEMENTS', idPrefix: 'Interface' }; - } - return { type: 'EXTENDS', idPrefix: 'Class' }; +export const getHeritageStrategyForLanguage: HeritageStrategyLookup = ( + lang: SupportedLanguages, +): HeritageResolutionStrategy => { + const provider = getProvider(lang); + return { + interfaceNamePattern: provider.interfaceNamePattern, + defaultEdge: provider.heritageDefaultEdge ?? 'EXTENDS', + }; }; /** @@ -180,7 +167,7 @@ export const processHeritage = async ( parentClassName, file.path, ctx, - language, + getHeritageStrategyForLanguage(language), ); const child = resolveHeritageId( @@ -296,7 +283,7 @@ export const processHeritageFromExtracted = async ( h.parentName, h.filePath, ctx, - fileLanguage, + getHeritageStrategyForLanguage(fileLanguage), ); const child = resolveHeritageId( diff --git a/gitnexus/src/core/ingestion/import-processor.ts b/gitnexus/src/core/ingestion/import-processor.ts index 3c52fa7a6..3dff47094 100644 --- a/gitnexus/src/core/ingestion/import-processor.ts +++ b/gitnexus/src/core/ingestion/import-processor.ts @@ -12,7 +12,11 @@ import type { ExtractedImport } from './workers/parse-worker.js'; import { getTreeSitterBufferSize } from './constants.js'; import { loadImportConfigs } from './language-config.js'; import { buildSuffixIndex } from './import-resolvers/utils.js'; -import type { ResolutionContext, ModuleAliasMap } from './resolution-context.js'; +import type { + ResolutionContext, + ModuleAliasMap, + NamedImportMap, +} from './model/resolution-context.js'; import type { ImportResult, ResolveCtx, @@ -61,30 +65,6 @@ function wireImplicitImports( // Avoids expanding every Go package import into N individual ImportMap edges. export type PackageMap = Map>; -// Type: Map> -// Tracks which specific names a file imports from which sources (TS/Python only). -// Used to tighten Tier 2a resolution: `import { User } from './models'` -// means only `User` (not `Repo`) is visible from models.ts via this import. -// Stores both the resolved source path and the original exported name so that -// aliased imports (`import { User as U }`) can resolve U → User in the source file. -export interface NamedImportBinding { - sourcePath: string; - exportedName: string; -} -export type NamedImportMap = Map>; - -/** - * Check if a file path is directly inside a package directory identified by its suffix. - * Used by the symbol resolver for Go and C# directory-level import matching. - */ -export function isFileInPackageDir(filePath: string, dirSuffix: string): boolean { - // Prepend '/' so paths like "internal/auth/service.go" match suffix "/internal/auth/" - const normalized = '/' + filePath.replace(/\\/g, '/'); - if (!normalized.includes(dirSuffix)) return false; - const afterDir = normalized.substring(normalized.indexOf(dirSuffix) + dirSuffix.length); - return !afterDir.includes('/'); -} - // ImportResolutionContext is defined in ./import-resolvers/types.ts — re-exported here for consumers. export function buildImportResolutionContext(allPaths: string[]): ImportResolutionContext { diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 38997cb0d..141ba59af 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -9,7 +9,7 @@ * so adding a language to the enum without creating a provider is a compiler error. */ -import type { SupportedLanguages } from 'gitnexus-shared'; +import type { SupportedLanguages, MroStrategy } from 'gitnexus-shared'; import type { LanguageTypeConfig } from './type-extractors/types.js'; import type { CallRouter } from './call-routing.js'; import type { ClassExtractor } from './class-types.js'; @@ -26,13 +26,10 @@ import type { NodeLabel } from 'gitnexus-shared'; export type CaptureMap = Record; // ── Strategy tag types ───────────────────────────────────────────────────── -/** MRO strategy for multiple inheritance resolution. */ -export type MroStrategy = - | 'first-wins' - | 'c3' - | 'leftmost-base' - | 'implements-split' - | 'qualified-syntax'; +// NOTE: `MroStrategy` is defined in `gitnexus-shared` and re-exported above +// so `core/ingestion/model/resolve.ts` can consume it without importing from +// this file (which would pull in the full language-registry dependency graph). + /** How a language handles imports — determines wildcard synthesis behavior. */ export type ImportSemantics = 'named' | 'wildcard' | 'namespace'; diff --git a/gitnexus/src/core/ingestion/model/field-registry.ts b/gitnexus/src/core/ingestion/model/field-registry.ts new file mode 100644 index 000000000..45fe5c86a --- /dev/null +++ b/gitnexus/src/core/ingestion/model/field-registry.ts @@ -0,0 +1,53 @@ +/** + * Field Registry + * + * Owner-scoped field/property index extracted from SymbolTable. + * Stores Property symbols keyed by `ownerNodeId\0fieldName` for O(1) lookup. + */ + +import type { SymbolDefinition } from './symbol-table.js'; + +// --------------------------------------------------------------------------- +// Public read-only interface +// --------------------------------------------------------------------------- + +export interface FieldRegistry { + /** Look up a field/property by its owning class nodeId and field name. */ + lookupFieldByOwner(ownerNodeId: string, fieldName: string): SymbolDefinition | undefined; +} + +// --------------------------------------------------------------------------- +// Mutable interface (used internally by SymbolTable.add / clear) +// --------------------------------------------------------------------------- + +export interface MutableFieldRegistry extends FieldRegistry { + /** Register a field/property under its owner. */ + register(ownerNodeId: string, fieldName: string, def: SymbolDefinition): void; + /** Clear all entries. */ + clear(): void; +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +export const createFieldRegistry = (): MutableFieldRegistry => { + const fieldByOwner = new Map(); + + const lookupFieldByOwner = ( + ownerNodeId: string, + fieldName: string, + ): SymbolDefinition | undefined => { + return fieldByOwner.get(`${ownerNodeId}\0${fieldName}`); + }; + + const register = (ownerNodeId: string, fieldName: string, def: SymbolDefinition): void => { + fieldByOwner.set(`${ownerNodeId}\0${fieldName}`, def); + }; + + const clear = (): void => { + fieldByOwner.clear(); + }; + + return { lookupFieldByOwner, register, clear }; +}; diff --git a/gitnexus/src/core/ingestion/heritage-map.ts b/gitnexus/src/core/ingestion/model/heritage-map.ts similarity index 60% rename from gitnexus/src/core/ingestion/heritage-map.ts rename to gitnexus/src/core/ingestion/model/heritage-map.ts index 46d0c2120..16ecf3305 100644 --- a/gitnexus/src/core/ingestion/heritage-map.ts +++ b/gitnexus/src/core/ingestion/model/heritage-map.ts @@ -7,16 +7,78 @@ * resolves type names to nodeIds via `lookupClassByName`, NOT graph-edge * queries. * - * Combines two previously separate concerns: + * Combines two concerns: * 1. **Parent/ancestor lookup** (MRO-aware method resolution) * 2. **Implementor lookup** (interface dispatch — which files contain * classes implementing a given interface) */ -import type { ExtractedHeritage } from './workers/parse-worker.js'; import type { ResolutionContext } from './resolution-context.js'; -import { getLanguageFromFilename } from 'gitnexus-shared'; -import { resolveExtendsType } from './heritage-processor.js'; +import { getLanguageFromFilename, type SupportedLanguages } from 'gitnexus-shared'; + +// --------------------------------------------------------------------------- +// ExtractedHeritage — the shape produced by the parse worker / heritage +// extractor. Defined here so `model/` has no upward imports; consumers +// import this type from the model module. +// --------------------------------------------------------------------------- + +export interface ExtractedHeritage { + filePath: string; + className: string; + parentName: string; + /** 'extends' | 'implements' | 'trait-impl' | 'include' | 'extend' | 'prepend' */ + kind: string; +} + +// --------------------------------------------------------------------------- +// Heritage resolution strategy (the per-language knobs that drive +// `resolveExtendsType` below). Pulled out as an explicit strategy object so +// the model layer depends on a plain data shape rather than on the language +// provider registry. +// --------------------------------------------------------------------------- + +export interface HeritageResolutionStrategy { + /** If set and the parent name matches, force IMPLEMENTS even when the + * symbol is unresolved (e.g. `/^I[A-Z]/` for C# / Java). */ + readonly interfaceNamePattern?: RegExp; + /** Fallback edge for unresolved parents when the name pattern doesn't + * match (Swift uses 'IMPLEMENTS' for protocol conformance). */ + readonly defaultEdge: 'EXTENDS' | 'IMPLEMENTS'; +} + +/** Callback used by `buildHeritageMap` to look up the resolution strategy + * for a given language. Injected by callers so the model module doesn't + * depend on `../languages/index.js`. */ +export type HeritageStrategyLookup = (lang: SupportedLanguages) => HeritageResolutionStrategy; + +/** + * Determine whether a heritage.extends capture is actually an IMPLEMENTS + * relationship. Consults the symbol table first (authoritative — Tier 1 / + * Tier 2 resolution); falls back to the injected {@link HeritageResolutionStrategy} + * heuristics for external symbols not present in the graph. + */ +export const resolveExtendsType = ( + parentName: string, + currentFilePath: string, + ctx: ResolutionContext, + strategy: HeritageResolutionStrategy, +): { type: 'EXTENDS' | 'IMPLEMENTS'; idPrefix: string } => { + const resolved = ctx.resolve(parentName, currentFilePath); + if (resolved && resolved.candidates.length > 0) { + const isInterface = resolved.candidates[0].type === 'Interface'; + return isInterface + ? { type: 'IMPLEMENTS', idPrefix: 'Interface' } + : { type: 'EXTENDS', idPrefix: 'Class' }; + } + // Unresolved symbol — fall back to strategy heuristics. + if (strategy.interfaceNamePattern?.test(parentName)) { + return { type: 'IMPLEMENTS', idPrefix: 'Interface' }; + } + if (strategy.defaultEdge === 'IMPLEMENTS') { + return { type: 'IMPLEMENTS', idPrefix: 'Interface' }; + } + return { type: 'EXTENDS', idPrefix: 'Class' }; +}; // --------------------------------------------------------------------------- // Public types @@ -41,6 +103,12 @@ export interface HeritageMap { /** Shared empty set returned when no implementors are found. */ const EMPTY_SET: ReadonlySet = new Set(); +/** Default strategy used when `buildHeritageMap` is called without an + * explicit `getHeritageStrategy` callback — the fallback for a language + * whose provider sets no interface-name pattern and no non-default + * `heritageDefaultEdge`. */ +const DEFAULT_HERITAGE_STRATEGY: HeritageResolutionStrategy = { defaultEdge: 'EXTENDS' }; + // --------------------------------------------------------------------------- // Builder // --------------------------------------------------------------------------- @@ -49,18 +117,18 @@ const EMPTY_SET: ReadonlySet = new Set(); * Build a HeritageMap from accumulated ExtractedHeritage records. * * Resolves class/interface/struct/trait names to nodeIds via - * `ctx.symbols.lookupClassByName`. When a name resolves to multiple + * `ctx.model.types.lookupClassByName`. When a name resolves to multiple * candidates, all are recorded (partial-class / cross-file scenario). * Unresolvable names are silently skipped — a missing parent is better * than a wrong edge. * * Also builds the implementor index (interface name → implementing file - * paths) that was previously maintained by `buildImplementorMap` in - * call-processor.ts. + * paths) used by interface-dispatch in call resolution. */ export const buildHeritageMap = ( heritage: readonly ExtractedHeritage[], ctx: ResolutionContext, + getHeritageStrategy?: HeritageStrategyLookup, ): HeritageMap => { // childNodeId → Set (Set to deduplicate cross-chunk duplicates) const directParents = new Map>(); @@ -70,8 +138,8 @@ export const buildHeritageMap = ( for (const h of heritage) { // ── Parent lookup (nodeId-based) ──────────────────────────────── - const childDefs = ctx.symbols.lookupClassByName(h.className); - const parentDefs = ctx.symbols.lookupClassByName(h.parentName); + const childDefs = ctx.model.types.lookupClassByName(h.className); + const parentDefs = ctx.model.types.lookupClassByName(h.parentName); if (childDefs.length > 0 && parentDefs.length > 0) { for (const child of childDefs) { @@ -99,16 +167,15 @@ export const buildHeritageMap = ( // // Known limitation: `getImplementorFiles` is keyed by interface **name** // (string), so two interfaces with the same unqualified name in different - // packages (e.g. `pkgA.IRepository` vs `pkgB.IRepository`) collide. This - // matches the behavior of the prior standalone `ImplementorMap` and is - // not a regression introduced by this consolidation. + // packages (e.g. `pkgA.IRepository` vs `pkgB.IRepository`) collide. let isImpl = false; if (h.kind === 'implements') { isImpl = true; } else if (h.kind === 'extends') { const lang = getLanguageFromFilename(h.filePath); if (lang) { - const { type } = resolveExtendsType(h.parentName, h.filePath, ctx, lang); + const strategy = getHeritageStrategy?.(lang) ?? DEFAULT_HERITAGE_STRATEGY; + const { type } = resolveExtendsType(h.parentName, h.filePath, ctx, strategy); isImpl = type === 'IMPLEMENTS'; } } diff --git a/gitnexus/src/core/ingestion/model/index.ts b/gitnexus/src/core/ingestion/model/index.ts new file mode 100644 index 000000000..27f939171 --- /dev/null +++ b/gitnexus/src/core/ingestion/model/index.ts @@ -0,0 +1,88 @@ +/** + * Semantic Model — public module surface. + * + * Barrel re-export for the `model/` module. Consumers outside `model/` + * should import from this file rather than reaching into individual + * registry files. + * + * The model is owner-scoped type/method/field knowledge layered above + * `SymbolTable`. File-indexed and name-keyed callable lookups stay in + * `SymbolTable` by design. + */ + +// Unified semantic model (factory + interfaces). SemanticModel is the +// top-level container and owns the file/callable SymbolTable as a +// nested `symbols` field. +export { + type SemanticModel, + type MutableSemanticModel, + createSemanticModel, +} from './semantic-model.js'; + +// SymbolTable is exclusively owned by SemanticModel. Re-exported here +// for the rare caller that needs the file/callable interface in +// isolation (e.g. tests). +export { + type SymbolTableReader, + type SymbolTableWriter, + createSymbolTable, +} from './symbol-table.js'; + +// Type registry (classes, structs, interfaces, enums, records, impls) +export { + type TypeRegistry, + type MutableTypeRegistry, + createTypeRegistry, +} from './type-registry.js'; + +// Method registry (owner-scoped methods with arity-aware overload lookup) +export { + type MethodRegistry, + type MutableMethodRegistry, + createMethodRegistry, +} from './method-registry.js'; + +// Field registry (owner-scoped fields/properties) +export { + type FieldRegistry, + type MutableFieldRegistry, + createFieldRegistry, +} from './field-registry.js'; + +// MRO-aware method resolution (C3, first-wins, leftmost-base, implements-split, +// qualified-syntax). Pure function that depends only on the model + HeritageMap. +// `MroStrategy` itself lives in `gitnexus-shared`; re-exported here for +// consumers that reach model behavior through the barrel. +export { lookupMethodByOwnerWithMRO } from './resolve.js'; + +// Named-import types and package-dir helper. Re-exported so barrel +// consumers don't need to reach into a specific model file. +export { + type NamedImportBinding, + type NamedImportMap, + isFileInPackageDir, +} from './resolution-context.js'; + +// Heritage types. `buildHeritageMap` + `resolveExtendsType` are exported +// directly from `heritage-map.ts` and are not re-surfaced here to keep +// the barrel narrow. +export { + type ExtractedHeritage, + type HeritageResolutionStrategy, + type HeritageStrategyLookup, +} from './heritage-map.js'; + +// Behavior-grouped dispatch table for SymbolTable.add() routing. +// See registration-table.ts module JSDoc for the behavior group taxonomy +// and "how to add a new NodeLabel" checklist. +// NOTE: createRegistrationTable, RegistrationHook, and RegistrationTableDeps +// are deliberately NOT re-exported here — they are factory internals of +// SemanticModel and should only be imported directly from registration-table.js +// by semantic-model.ts and the registration-table.test.ts file. +export { + CALLABLE_ONLY_LABELS, + INERT_LABELS, + DISPATCH_LABELS, + ALL_NODE_LABELS, + type LabelBehavior, +} from './registration-table.js'; diff --git a/gitnexus/src/core/ingestion/model/method-registry.ts b/gitnexus/src/core/ingestion/model/method-registry.ts new file mode 100644 index 000000000..be28e5782 --- /dev/null +++ b/gitnexus/src/core/ingestion/model/method-registry.ts @@ -0,0 +1,204 @@ +/** + * Method Registry + * + * Owner-scoped method index extracted from SymbolTable. + * Stores Method/Constructor/Function-with-ownerId symbols keyed by + * `ownerNodeId\0methodName` for O(1) lookup. Supports overloads + * (array values) and arity-based filtering. + */ + +import type { SymbolDefinition } from './symbol-table.js'; + +// --------------------------------------------------------------------------- +// Public read-only interface +// --------------------------------------------------------------------------- + +export interface MethodRegistry { + /** + * Look up 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; + + /** + * Flat-by-name lookup across all owners. Returns every method registered + * with the given unqualified name, in registration order, accumulated + * across owners and overloads. + * + * Required by Tier 3 global resolution: Method and Constructor do not + * land in `SymbolTable.callableByName`, so Tier 3 reaches them through + * this flat index instead. Returns `[]` on miss — never `undefined` — + * so callers can concatenate without null checks. + * + * Reference identity: each returned def is the same object reference + * stored under `lookupMethodByOwner`, so a method symbol occupies one + * allocation reachable from two indexes. + */ + lookupMethodByName(name: string): readonly SymbolDefinition[]; + + /** + * True iff at least one registered def has `type === 'Function'` — i.e., + * a Python/Rust/Kotlin class method emitted by the worker as + * `Function + ownerId` rather than as a strict `Method` label. Such defs + * are double-indexed: they land in `SymbolTable.callableByName` (via the + * Function callable-index gate) AND in this registry (via the + * dispatch-key normalization in `wrappedAdd`). Tier 3 resolution must + * then dedup the two indexes by nodeId. + * + * When this flag is false, the callable and method indexes are + * guaranteed disjoint and Tier 3 can skip the dedup pass entirely. + * The flag is monotonic (false→true once, never back) for the lifetime + * of the MethodRegistry. + */ + readonly hasFunctionMethods: boolean; +} + +// --------------------------------------------------------------------------- +// Mutable interface (used internally by SymbolTable.add / clear) +// --------------------------------------------------------------------------- + +export interface MutableMethodRegistry extends MethodRegistry { + /** Register a method under its owner. Supports multiple overloads. */ + register(ownerNodeId: string, methodName: string, def: SymbolDefinition): void; + /** Clear all entries. */ + clear(): void; +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +export const createMethodRegistry = (): MutableMethodRegistry => { + const methodByOwner = new Map(); + // Secondary flat-by-name index. Values are the SAME SymbolDefinition + // references stored under `methodByOwner` — no copy, just a second key. + // Populated in lockstep by `register()` and emptied by `clear()`. + const methodsByName = new Map(); + const EMPTY: readonly SymbolDefinition[] = Object.freeze([]); + // Set once when a Function+ownerId def lands here, powers the Tier 3 + // dedup fast-path. Monotonic: never unset except on `clear()`. + let hasFunctionMethodsFlag = false; + + const lookupMethodByOwner = ( + ownerNodeId: string, + methodName: string, + argCount?: number, + ): SymbolDefinition | undefined => { + const defs = methodByOwner.get(`${ownerNodeId}\0${methodName}`); + if (!defs || defs.length === 0) 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. + // + // Streaming loop avoids allocating a filtered array on the common + // "arity selects 0 or 1 match" path. We scan once, count arity + // matches, and only materialize a narrowed array if at least one + // match was found and at least one non-match exists. If arity rules + // out every candidate, fall back to the unfiltered set so the + // caller's fuzzy path still has something to work with. + let pool: readonly SymbolDefinition[] = defs; + if (argCount !== undefined && defs.length > 1) { + let matchedCount = 0; + let rejectedCount = 0; + for (const d of defs) { + if (d.parameterCount === undefined) { + matchedCount++; + continue; + } + const min = d.requiredParameterCount ?? d.parameterCount; + if (argCount >= min && argCount <= d.parameterCount) matchedCount++; + else rejectedCount++; + } + // Only narrow when the filter actually discriminates: at least one + // match AND at least one rejection. Pure-match and pure-reject + // paths both keep the unfiltered pool (the latter because fallback + // semantics demand it). + if (matchedCount > 0 && rejectedCount > 0) { + const arityMatched: SymbolDefinition[] = []; + for (const d of defs) { + if (d.parameterCount === undefined) { + arityMatched.push(d); + continue; + } + const min = d.requiredParameterCount ?? d.parameterCount; + if (argCount >= min && argCount <= d.parameterCount) arityMatched.push(d); + } + pool = arityMatched; + } + } + + 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 lookupMethodByName = (name: string): readonly SymbolDefinition[] => { + return methodsByName.get(name) ?? EMPTY; + }; + + const register = (ownerNodeId: string, methodName: string, def: SymbolDefinition): void => { + const key = `${ownerNodeId}\0${methodName}`; + const existing = methodByOwner.get(key); + if (existing) { + existing.push(def); + } else { + methodByOwner.set(key, [def]); + } + const byName = methodsByName.get(methodName); + if (byName) { + byName.push(def); + } else { + methodsByName.set(methodName, [def]); + } + // A `Function`-typed def reaching MethodRegistry means the worker + // emitted a Python/Rust/Kotlin class method as `Function + ownerId`. + // It was already written into `SymbolTable.callableByName` by the + // upstream Function callable-index gate, so the two indexes are no + // longer disjoint for this registry's lifetime — Tier 3 must dedup. + if (!hasFunctionMethodsFlag && def.type === 'Function') { + hasFunctionMethodsFlag = true; + } + }; + + const clear = (): void => { + methodByOwner.clear(); + methodsByName.clear(); + hasFunctionMethodsFlag = false; + }; + + return { + lookupMethodByOwner, + lookupMethodByName, + register, + clear, + get hasFunctionMethods() { + return hasFunctionMethodsFlag; + }, + }; +}; diff --git a/gitnexus/src/core/ingestion/model/registration-table.ts b/gitnexus/src/core/ingestion/model/registration-table.ts new file mode 100644 index 000000000..a5bb14f21 --- /dev/null +++ b/gitnexus/src/core/ingestion/model/registration-table.ts @@ -0,0 +1,333 @@ +/** + * Registration Dispatch Table + * + * Behavior-grouped O(1) dispatch table for routing `SymbolTable.add()` + * registrations into the semantic registries. Replaces the cascading + * `if/else` ladder in `symbol-table.ts` with a `Map` + * whose entries point to closure-captured hooks. + * + * ## Ownership diagram + * + * SemanticModel + * ├── types (TypeRegistry) ← classLikeHook / implHook write here + * ├── methods (MethodRegistry) ← methodHook writes here + * ├── fields (FieldRegistry) ← propertyHook writes here + * └── symbols (SymbolTable) ← owns fileIndex + callableByName, + * calls dispatch() in add() + * + * ## Behavior groups (5 hooks, 13 table entries) + * + * | Group | NodeLabel values | Hook | Skip callable? | + * |---------------|---------------------------------------------------|--------------|----------------| + * | class-like | Class, Struct, Interface, Enum, Record, Trait | classLikeHook | no | + * | method-like | Method, Constructor | methodHook | no | + * | property | Property | propertyHook | YES | + * | impl-block | Impl | implHook | no | + * | callable-only | Function, Macro, Delegate | (no entry) | no | + * + * Every other `NodeLabel` is "inert" — reached by `fileIndex` only. No + * specialized registry, no callable index append. + * + * ## How to add a new NodeLabel + * + * 1. Add the variant to the `NodeLabel` union in `gitnexus-shared/src/graph/types.ts`. + * 2. Decide which behavior group it belongs to by asking "which lookups must + * return this symbol?" (not "what language feature is it?"). A new Swift + * `Extension` is class-like if you want owner-scoped method lookup on it; + * a new Kotlin `Object` is class-like for the same reason. + * 3. Either: + * - Add a table entry here pointing at one of the existing hooks, OR + * - Add it to `CALLABLE_ONLY_LABELS` if it is a free callable, OR + * - Add it to `INERT_LABELS` if it's metadata-only (File, Folder, Decorator, + * etc.) — never queried via owner/class lookups. + * 4. If none of the above fit — the new kind needs a brand-new registry — + * design the registry first in `model/`, then add a new hook closure + * and table entries. Update `DISPATCH_LABELS` / the exhaustiveness guard + * accordingly. + * + * The runtime exhaustiveness guard in `symbol-table.ts` will warn if a + * `NodeLabel` is missing from all three sets. + */ + +import type { NodeLabel } from 'gitnexus-shared'; +import type { SymbolDefinition, ClassLikeLabel, FreeCallableLabel } from './symbol-table.js'; +import { FREE_CALLABLE_TYPES } from './symbol-table.js'; +import type { MutableTypeRegistry } from './type-registry.js'; +import type { MutableMethodRegistry } from './method-registry.js'; +import type { MutableFieldRegistry } from './field-registry.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Registration hook — a pure side-effectful function closed over a + * specific registry. Performs the specialized registry write into the + * appropriate owner-scoped registry for one NodeLabel. + * + * Closure capture is the isolation mechanism: `propertyHook` literally + * cannot call `types.registerClass` because its closure does not hold + * a reference to `types`. This is the runtime half of the principle of + * least authority — the compile-time half is enforced by TypeScript. + * + * The callable-index gate lives inside `SymbolTable.add()` via the + * `FREE_CALLABLE_TYPES` allowlist — the dispatch table does not + * participate in that decision. + */ +export type RegistrationHook = (name: string, def: SymbolDefinition) => void; + +/** + * Dependencies required to build the dispatch table. Matches the shape + * that `createSemanticModel()` passes into `createRegistrationTable()`. + */ +export interface RegistrationTableDeps { + readonly types: MutableTypeRegistry; + readonly methods: MutableMethodRegistry; + readonly fields: MutableFieldRegistry; +} + +// --------------------------------------------------------------------------- +// Single source of truth: NodeLabel → behavior category +// --------------------------------------------------------------------------- + +/** + * Behavior category for a NodeLabel during ingestion. Determines which + * registry (if any) receives the symbol write during `SymbolTable.add()`: + * + * - `dispatch` — owner-scoped registry write via the dispatch table + * (Class/Struct/Interface/Enum/Record/Trait → types.registerClass, + * Method/Constructor → methods.register, + * Property → fields.register, + * Impl → types.registerImpl) + * - `callable-only` — no specialized registry; symbol appears in + * `callableByName` via `SymbolTable.add()`'s + * FREE_CALLABLE_TYPES gate (Function/Macro/Delegate) + * - `inert` — no registry, no callable index; file-index only + * (metadata / structural nodes like Project, Module, + * Import, Decorator, etc.) + * + * `Function` has a twist: `Function`-with-`ownerId` (Python `def` in a + * class body, Rust trait method, Kotlin companion method) is pre-normalized + * to `Method` in `createSemanticModel`'s `wrappedAdd` before dispatch lookup, + * so only free functions actually flow through the callable-only path. + */ +export type LabelBehavior = 'dispatch' | 'callable-only' | 'inert'; + +/** + * **Single source of truth** for NodeLabel classification. Every NodeLabel + * has exactly one behavior category — enforced at compile time by the + * `as const satisfies Record` combo: + * + * - **Completeness** — `Record` requires every + * NodeLabel to be a key. Missing a label fails to compile with + * "Property 'X' is missing in type ..." naming the drifted label. + * - **No extras** — `satisfies` performs excess-property checking on + * object literals, so a non-NodeLabel string key fails to compile. + * - **No duplicates** — object keys are unique by construction. A label + * cannot be classified into two categories by accident. + * - **Valid values** — `LabelBehavior` is a narrow union, so a typo in + * the category name fails to compile. + * + * Adding a new NodeLabel to `gitnexus-shared`: TypeScript will flag this + * file as incomplete. Add the new label with its behavior category and + * the three `*_LABELS` Sets + `ALL_NODE_LABELS` array below are derived + * automatically — no separate list to update, no runtime drift detection + * needed. + * + * NOTE: `Type` and `CodeElement` are inert wrappers for language features + * that don't yet have a dedicated registry (typedefs, synthesized dynamic + * calls). If future work needs owner-scoped lookup for them, change their + * category to `'dispatch'` and add a hook in `createRegistrationTable`. + * Do not special-case them inside `SymbolTable.add()`. + */ +const LABEL_BEHAVIOR = { + // dispatch — owner-scoped registry writes + Class: 'dispatch', + Struct: 'dispatch', + Interface: 'dispatch', + Enum: 'dispatch', + Record: 'dispatch', + Trait: 'dispatch', + Method: 'dispatch', + Constructor: 'dispatch', + Property: 'dispatch', + Impl: 'dispatch', + + // callable-only — file index + callableByName, no owner scope + Function: 'callable-only', + Macro: 'callable-only', + Delegate: 'callable-only', + + // inert — file index only + Project: 'inert', + Package: 'inert', + Module: 'inert', + Folder: 'inert', + File: 'inert', + Variable: 'inert', + Decorator: 'inert', + Import: 'inert', + Type: 'inert', + CodeElement: 'inert', + Community: 'inert', + Process: 'inert', + Typedef: 'inert', + Union: 'inert', + Namespace: 'inert', + TypeAlias: 'inert', + Const: 'inert', + Static: 'inert', + Annotation: 'inert', + Template: 'inert', + Section: 'inert', + Route: 'inert', + Tool: 'inert', +} as const satisfies Record & + // Cross-invariant 1 — every class-like label (participates in + // qualifiedName fallback in `SymbolTable.add()`) MUST be classified as + // 'dispatch'. Adding a label to `CLASS_TYPES_TUPLE` without classifying + // it as 'dispatch' fails with a type error naming the drifted label. + Record & + // Cross-invariant 2 — every free-callable label (gate in + // `SymbolTable.add()` via `FREE_CALLABLE_TYPES`) MUST be classified as + // 'callable-only'. Adding a label to `FREE_CALLABLE_TUPLE` without + // classifying it as 'callable-only' fails with a type error naming the + // drifted label. + Record; + +// --------------------------------------------------------------------------- +// Derived runtime collections — all keyed off LABEL_BEHAVIOR +// --------------------------------------------------------------------------- + +/** + * All known NodeLabels, derived from the keys of `LABEL_BEHAVIOR`. The + * `satisfies Record` bijection above proves + * that `Object.keys(LABEL_BEHAVIOR)` is exactly the NodeLabel set — + * the cast to `NodeLabel[]` is sound, not a type-system bypass. + * + * Consumers (e.g., the semantic-model barrel re-export for tests) can + * rely on this list being complete by construction. No runtime drift + * check is needed or possible — the type system is the proof. + */ +export const ALL_NODE_LABELS: readonly NodeLabel[] = Object.keys(LABEL_BEHAVIOR) as NodeLabel[]; + +const labelsWithBehavior = (behavior: LabelBehavior): NodeLabel[] => + ALL_NODE_LABELS.filter((label) => LABEL_BEHAVIOR[label] === behavior); + +/** + * NodeLabel values that are free callables — appear in `callableByName` + * but have no owner-scoped specialized registry. Alias of + * {@link FREE_CALLABLE_TYPES} exported here for taxonomy-test use. The + * compile-time cross-invariant on `LABEL_BEHAVIOR` above guarantees the + * alias and the LABEL_BEHAVIOR `callable-only` classification cannot + * drift. + */ +export const CALLABLE_ONLY_LABELS: ReadonlySet = FREE_CALLABLE_TYPES; + +/** + * NodeLabel values that touch only the file index — no specialized + * registry, no callable index. + */ +export const INERT_LABELS: ReadonlySet = new Set(labelsWithBehavior('inert')); + +/** + * NodeLabel values that have a dispatch table entry. `createRegistrationTable` + * below must provide a hook for exactly this set — the test file's behavior- + * group tests and the integration tests pin the hook↔label correspondence. + */ +export const DISPATCH_LABELS: ReadonlySet = new Set(labelsWithBehavior('dispatch')); + +/** + * Type-level extraction of every label classified as `'dispatch'` in + * {@link LABEL_BEHAVIOR}. Used by {@link createRegistrationTable} as the + * key set of its internal object literal, so the `satisfies + * Record` check fails at build time if + * a dispatch-classified label is missing a hook, or a hook is wired to + * a non-dispatch label. This closes the last compile-time gap between + * `LABEL_BEHAVIOR` and the dispatch table. + */ +type DispatchLabel = { + [K in NodeLabel]: (typeof LABEL_BEHAVIOR)[K] extends 'dispatch' ? K : never; +}[NodeLabel]; + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +/** + * Build the dispatch table. Must be called once per `createSymbolTable` + * invocation so each hook closes over that SymbolTable's injected + * registries. Reusing a single module-level instance would cause hooks + * to write into the wrong SemanticModel. + */ +export const createRegistrationTable = ( + deps: RegistrationTableDeps, +): Map => { + const { types, methods, fields } = deps; + + // Hook 1: class-like — Class, Struct, Interface, Enum, Record, Trait. + // Shared reference — six table entries point at this one closure. + const classLikeHook: RegistrationHook = (name, def) => { + const qualifiedKey = def.qualifiedName ?? name; + types.registerClass(name, qualifiedKey, def); + }; + + // Hook 2: method-like — Method, Constructor. Silently skipped if the + // caller did not provide an ownerId (Property without ownerId is + // treated the same way). + const methodHook: RegistrationHook = (name, def) => { + if (def.ownerId) { + methods.register(def.ownerId, name, def); + } + }; + + // Hook 3: property — Property. Silently skipped without ownerId. + // Property is not in `FREE_CALLABLE_TYPES`, so `SymbolTable.add()` already + // excludes it from `callableByName`; common property names like + // `id` / `name` / `type` never pollute the callable index. + const propertyHook: RegistrationHook = (name, def) => { + if (def.ownerId) { + fields.register(def.ownerId, name, def); + } + }; + + // Hook 4: impl-block — Rust `impl` blocks. Kept separate from classLikeHook + // because heritage resolution must not treat Impls as class candidates + // (an Impl is not a parent type, it's an ancillary dispatch table). + const implHook: RegistrationHook = (name, def) => { + types.registerImpl(name, def); + }; + + // Single source of truth for the label → hook mapping. The + // `satisfies Record` intersection + // fails at build time if (a) any label classified as 'dispatch' in + // `LABEL_BEHAVIOR` is missing here, or (b) any key here is not + // classified as 'dispatch'. This is the compile-time twin of the + // runtime taxonomy — no drift possible. + const dispatchByLabel = { + // class-like — six labels share the single `classLikeHook` closure, + // kept in lockstep with `CLASS_TYPES_TUPLE` via the + // `Record` cross-invariant on + // `LABEL_BEHAVIOR`. + Class: classLikeHook, + Struct: classLikeHook, + Interface: classLikeHook, + Enum: classLikeHook, + Record: classLikeHook, + Trait: classLikeHook, + // method-like — routed via dispatch-key normalization in + // `wrappedAdd` so Function+ownerId also reaches `methodHook`. + Method: methodHook, + Constructor: methodHook, + // property — callable-index exclusion is enforced by + // `SymbolTable.add()` (Property is not in `FREE_CALLABLE_TYPES`). + Property: propertyHook, + // impl-block — Rust `impl` blocks. Separate from classLikeHook because + // heritage resolution must not treat Impls as class candidates. + Impl: implHook, + } as const satisfies Record; + + return new Map( + Object.entries(dispatchByLabel) as [NodeLabel, RegistrationHook][], + ); +}; diff --git a/gitnexus/src/core/ingestion/resolution-context.ts b/gitnexus/src/core/ingestion/model/resolution-context.ts similarity index 55% rename from gitnexus/src/core/ingestion/resolution-context.ts rename to gitnexus/src/core/ingestion/model/resolution-context.ts index 06b2c89be..b56524b36 100644 --- a/gitnexus/src/core/ingestion/resolution-context.ts +++ b/gitnexus/src/core/ingestion/model/resolution-context.ts @@ -1,9 +1,7 @@ /** * Resolution Context * - * Single implementation of tiered name resolution. Replaces the duplicated - * tier-selection logic previously split between symbol-resolver.ts and - * call-processor.ts. + * Single implementation of tiered name resolution. * * Resolution tiers (highest confidence first): * 1. Same file (lookupExactAll — authoritative) @@ -20,11 +18,112 @@ * (three O(1) index lookups with a narrow, type-specific result set). */ -import type { SymbolTable, SymbolDefinition } from './symbol-table.js'; -import { createSymbolTable } from './symbol-table.js'; -import type { NamedImportMap } from './import-processor.js'; -import { isFileInPackageDir } from './import-processor.js'; -import { walkBindingChain } from './named-binding-processor.js'; +import type { SymbolDefinition, SymbolTableReader } from './symbol-table.js'; +import type { MutableSemanticModel } from './semantic-model.js'; +import { createSemanticModel } from './semantic-model.js'; + +// --------------------------------------------------------------------------- +// Named-import types — describe how a file imports specific names from a +// source file. Consumed by the Tier 2a-named binding-chain walker below. +// --------------------------------------------------------------------------- + +/** + * A single named binding in a source file (e.g. `import { User as U }`). + * Stores both the resolved source path and the original exported name so + * that aliased imports can resolve U → User in the source file. + */ +export interface NamedImportBinding { + sourcePath: string; + exportedName: string; +} + +/** + * Map>. + * + * Tracks which specific names a file imports from which sources (TS / Python + * / Rust / Java-static / ...). Used to tighten Tier 2a resolution: + * `import { User } from './models'` means only `User` (not `Repo`) is + * visible from models.ts via this import. + */ +export type NamedImportMap = Map>; + +/** + * Check if a file path is directly inside a package directory identified by + * its suffix. Used by Tier 2b package-scoped resolution (Go / C#). + */ +export function isFileInPackageDir(filePath: string, dirSuffix: string): boolean { + // Prepend '/' so paths like "internal/auth/service.go" match suffix "/internal/auth/" + const normalized = '/' + filePath.replace(/\\/g, '/'); + if (!normalized.includes(dirSuffix)) return false; + const afterDir = normalized.substring(normalized.indexOf(dirSuffix) + dirSuffix.length); + return !afterDir.includes('/'); +} + +/** Maximum re-export hops walkBindingChain will follow before giving up. + * A hard cap is needed to defend against pathological cycles that slip + * past the `visited` Set (e.g. a binding chain whose key is equal by + * string value but visits distinct modules). Five hops covers the + * common TypeScript monorepo pattern (component → pkg/index → + * packages/index → root/index → types/index). Chains longer than this + * fall through to Tier 2a-import / Tier 2b / Tier 3 resolution, which + * is a silent false-negative that the caller may or may not recover + * from. If a real repo hits this limit, raise it — there is no + * correctness reason to keep it at exactly 5. */ +const MAX_BINDING_CHAIN_DEPTH = 5; + +/** + * Walk a named-binding re-export chain through NamedImportMap. + * + * When file A imports { User } from B, and B re-exports { User } from C, + * the NamedImportMap for A points to B, but B has no User definition. + * This function follows the chain: A → B → C until a definition is found. + * + * Returns the definitions found at the end of the chain, or null if the + * chain breaks (missing binding, circular reference, or + * {@link MAX_BINDING_CHAIN_DEPTH} exceeded). Internal to + * resolution-context — not exported from the model barrel. + */ +function walkBindingChain( + name: string, + currentFilePath: string, + symbolTable: SymbolTableReader, + namedImportMap: NamedImportMap, +): readonly SymbolDefinition[] | null { + // Fast exit: most files have no named imports at all. Skip the Set + // allocation + loop entry on the common empty-binding path so resolve() + // stays allocation-free for the typical call site. + const firstBindings = namedImportMap.get(currentFilePath); + if (!firstBindings) return null; + const firstBinding = firstBindings.get(name); + if (!firstBinding) return null; + + let lookupFile = currentFilePath; + let lookupName = name; + const visited = new Set(); + + for (let depth = 0; depth < MAX_BINDING_CHAIN_DEPTH; depth++) { + const bindings = depth === 0 ? firstBindings : namedImportMap.get(lookupFile); + if (!bindings) return null; + + const binding = depth === 0 ? firstBinding : bindings.get(lookupName); + if (!binding) return null; + + const key = `${binding.sourcePath}:${binding.exportedName}`; + if (visited.has(key)) return null; // circular + visited.add(key); + + const targetName = binding.exportedName; + const resolvedDefs = symbolTable.lookupExactAll(binding.sourcePath, targetName); + + if (resolvedDefs.length > 0) return resolvedDefs; + + // No definition in source file → follow re-export chain + lookupFile = binding.sourcePath; + lookupName = targetName; + } + + return null; +} /** Resolution tier for tracking, logging, and test assertions. */ export type ResolutionTier = 'same-file' | 'import-scoped' | 'global'; @@ -59,8 +158,13 @@ export interface ResolutionContext { resolve(name: string, fromFile: string): TieredCandidates | null; // --- Data access (for pipeline wiring, not resolution) --- - /** Symbol table — used by parsing-processor to populate symbols. */ - readonly symbols: SymbolTable; + /** Semantic model — the top-level container for types, methods, fields, + * and the nested file/callable SymbolTable. Typed as + * {@link MutableSemanticModel} because `ResolutionContext` is the + * lifecycle owner — the pipeline registers symbols through it during + * the fan-out phase. Resolvers that only query should annotate their + * own fields as {@link SemanticModel} to drop write access. */ + readonly model: MutableSemanticModel; /** Raw maps — used by import-processor to populate import data. */ readonly importMap: ImportMap; readonly packageMap: PackageMap; @@ -86,7 +190,8 @@ export interface ResolutionContext { } export const createResolutionContext = (): ResolutionContext => { - const symbols = createSymbolTable(); + const model = createSemanticModel(); + const symbols = model.symbols; const importMap: ImportMap = new Map(); const packageMap: PackageMap = new Map(); const namedImportMap: NamedImportMap = new Map(); @@ -194,27 +299,75 @@ export const createResolutionContext = (): ResolutionContext => { // Tier 3: Global — targeted O(1) index lookups for each symbol category. // Class-like symbols (Class, Struct, Interface, Enum, Record, Trait) are // covered by lookupClassByName; Rust impl blocks by lookupImplByName - // (separate to avoid polluting heritage resolution); callables (Function, - // Method, Constructor, Macro, Delegate) by lookupCallableByName. - // The three indexes cover disjoint symbol types so no dedup is needed. - // Consumers must check candidates.length and refuse ambiguous matches. + // (separate to avoid polluting heritage resolution); free callables + // (Function, Macro, Delegate) by lookupCallableByName; owner-scoped + // methods and constructors by `model.methods.lookupMethodByName`. + // + // FREE_CALLABLE_TYPES excludes Method/Constructor, so strictly-labeled + // methods are disjoint between the two indexes. + // + // Partial-state caveat: Python/Rust/Kotlin class methods are emitted + // as Function + ownerId — `rawSymbols.add` routes them through both + // the Function callable index AND, via the dispatch-key normalization + // in `wrappedAdd`, the method registry. The same `SymbolDefinition` + // reference lands in both `callableDefs` and `methodDefs`, so the + // Set-based dedup below is required. // // Known exclusion: TypeAlias, Const, and Variable are NOT reachable at - // Tier 3 — they don't belong to any of the three indexes. In practice - // they were never useful as Tier 3 candidates: TypeAlias is not a call - // target, Const/Variable are resolved via import or same-file tiers. - // If a future language needs them at Tier 3, add a dedicated index. - // Macro (C/C++) and Delegate (C#) ARE included in the callable index + // Tier 3 — they don't belong to any of the indexes. TypeAlias is not + // a call target; Const/Variable are resolved via import or same-file + // tiers. Macro (C/C++) and Delegate (C#) stay in the callable index // since call-processor.ts treats them as callable targets. - const classDefs = symbols.lookupClassByName(name); - const implDefs = symbols.lookupImplByName(name); + const classDefs = model.types.lookupClassByName(name); + const implDefs = model.types.lookupImplByName(name); const callableDefs = symbols.lookupCallableByName(name); + const methodDefs = model.methods.lookupMethodByName(name); - if (classDefs.length === 0 && implDefs.length === 0 && callableDefs.length === 0) { + if ( + classDefs.length === 0 && + implDefs.length === 0 && + callableDefs.length === 0 && + methodDefs.length === 0 + ) { tierMiss++; return null; } - const globalDefs = [...classDefs, ...implDefs, ...callableDefs]; + + // Fast path: if no `Function + ownerId` class method was ever + // registered into the method registry (the only source of + // cross-index duplication), the callable and method indexes are + // guaranteed disjoint and we can concat without dedup. + if (!model.methods.hasFunctionMethods) { + const globalDefs: SymbolDefinition[] = [ + ...classDefs, + ...implDefs, + ...callableDefs, + ...methodDefs, + ]; + tierGlobal++; + return { candidates: globalDefs, tier: 'global' }; + } + + // Slow path: dedup by nodeId because the same SymbolDefinition + // reference can land in both `callableDefs` (via the Function + // callable-index gate) and `methodDefs` (via the dispatch-key + // normalization routing Function+ownerId into MethodRegistry). + // Dedup covers all four index reads so any nodeId overlap (even + // theoretical ones between classDefs/implDefs) is caught. + const globalDefs: SymbolDefinition[] = []; + const seen = new Set(); + const pushUnique = (pool: readonly SymbolDefinition[]): void => { + for (const def of pool) { + if (seen.has(def.nodeId)) continue; + seen.add(def.nodeId); + globalDefs.push(def); + } + }; + pushUnique(classDefs); + pushUnique(implDefs); + pushUnique(callableDefs); + pushUnique(methodDefs); + tierGlobal++; return { candidates: globalDefs, tier: 'global' }; }; @@ -271,7 +424,7 @@ export const createResolutionContext = (): ResolutionContext => { }); const clear = (): void => { - symbols.clear(); + model.clear(); importMap.clear(); packageMap.clear(); namedImportMap.clear(); @@ -288,7 +441,7 @@ export const createResolutionContext = (): ResolutionContext => { return { resolve, - symbols, + model, importMap, packageMap, namedImportMap, diff --git a/gitnexus/src/core/ingestion/model/resolve.ts b/gitnexus/src/core/ingestion/model/resolve.ts new file mode 100644 index 000000000..106630667 --- /dev/null +++ b/gitnexus/src/core/ingestion/model/resolve.ts @@ -0,0 +1,284 @@ +/** + * Deterministic Resolution Functions + * + * Pure functions that resolve methods across the inheritance hierarchy + * using only the SemanticModel registries and HeritageMap — NO dependency + * on resolution-context.ts (circular dependency risk). + */ + +import type { SymbolDefinition } from './symbol-table.js'; +import type { SemanticModel } from './semantic-model.js'; +import type { HeritageMap } from './heritage-map.js'; +import type { MroStrategy } from 'gitnexus-shared'; + +// --------------------------------------------------------------------------- +// MRO primitives. +// +// `c3Linearize` and its BFS helper `gatherAncestors` live here so the model +// layer stays a pure leaf — mro-processor.ts (graph-level MRO emission) +// imports `c3Linearize` from this file. +// --------------------------------------------------------------------------- + +/** + * Gather all ancestor IDs in BFS / topological order. + * Returns the linearized list of ancestor IDs (excluding the class itself). + */ +function gatherAncestors(classId: string, parentMap: Map): string[] { + const visited = new Set(); + const order: string[] = []; + const queue: string[] = [...(parentMap.get(classId) ?? [])]; + + while (queue.length > 0) { + const id = queue.shift()!; + if (visited.has(id)) continue; + visited.add(id); + order.push(id); + const grandparents = parentMap.get(id); + if (grandparents) { + for (const gp of grandparents) { + if (!visited.has(gp)) queue.push(gp); + } + } + } + + return order; +} + +/** + * Compute C3 linearization for a class given a parentMap. + * Returns an array of ancestor IDs in C3 order (excluding the class itself), + * or null if linearization fails (inconsistent or cyclic hierarchy). + * + * Used internally by `lookupMethodByOwnerWithMRO` for the Python MRO + * strategy and re-exported for mro-processor.ts (graph-level MRO emission). + */ +export function c3Linearize( + classId: string, + parentMap: Map, + cache: Map, + inProgress?: Set, +): string[] | null { + if (cache.has(classId)) return cache.get(classId)!; + + // Cycle detection: if we're already computing this class, the hierarchy is cyclic + const visiting = inProgress ?? new Set(); + if (visiting.has(classId)) { + cache.set(classId, null); + return null; + } + visiting.add(classId); + + const directParents = parentMap.get(classId); + if (!directParents || directParents.length === 0) { + visiting.delete(classId); + cache.set(classId, []); + return []; + } + + // Compute linearization for each parent first + const parentLinearizations: string[][] = []; + for (const pid of directParents) { + const pLin = c3Linearize(pid, parentMap, cache, visiting); + if (pLin === null) { + visiting.delete(classId); + cache.set(classId, null); + return null; + } + parentLinearizations.push([pid, ...pLin]); + } + + // Add the direct parents list as the final sequence + const sequences = [...parentLinearizations, [...directParents]]; + const result: string[] = []; + + while (sequences.some((s) => s.length > 0)) { + // Find a good head: one that doesn't appear in the tail of any other sequence + let head: string | null = null; + for (const seq of sequences) { + if (seq.length === 0) continue; + const candidate = seq[0]; + const inTail = sequences.some( + (other) => other.length > 1 && other.indexOf(candidate, 1) !== -1, + ); + if (!inTail) { + head = candidate; + break; + } + } + + if (head === null) { + // Inconsistent hierarchy + visiting.delete(classId); + cache.set(classId, null); + return null; + } + + result.push(head); + + // Remove the chosen head from all sequences + for (const seq of sequences) { + if (seq.length > 0 && seq[0] === head) { + seq.shift(); + } + } + } + + visiting.delete(classId); + cache.set(classId, result); + return result; +} + +// `gatherAncestors` is exported so mro-processor.ts can reuse the same +// BFS traversal for graph-level MRO emission. +export { gatherAncestors }; + +// --------------------------------------------------------------------------- +// C3 linearization cache (per HeritageMap, auto-drained via WeakMap) +// --------------------------------------------------------------------------- + +/** + * Per-HeritageMap cache of C3 linearization results keyed by owner nodeId. + * + * HeritageMap instances are immutable after construction, so C3 output is + * stable for the lifetime of a HeritageMap. WeakMap lets the cache auto-drain + * when the HeritageMap is garbage collected (end of ingestion run), so we + * never need to manually invalidate it. + * + * `null` is a sentinel for "C3 failed for this owner" (cyclic or inconsistent + * hierarchy) so we don't re-run the expensive linearization repeatedly. + */ +const c3LinearizationCache = new WeakMap>(); + +const getCachedC3Linearization = ( + ownerNodeId: string, + heritageMap: HeritageMap, +): readonly string[] | null => { + let perHmCache = c3LinearizationCache.get(heritageMap); + if (!perHmCache) { + perHmCache = new Map(); + c3LinearizationCache.set(heritageMap, perHmCache); + } + const cached = perHmCache.get(ownerNodeId); + if (cached !== undefined) return cached; + const parentMap = buildParentMapFromHeritage(ownerNodeId, heritageMap); + const result = c3Linearize(ownerNodeId, parentMap, new Map()) ?? null; + perHmCache.set(ownerNodeId, result); + return result; +}; + +// --------------------------------------------------------------------------- +// Heritage → parentMap conversion +// --------------------------------------------------------------------------- + +/** + * Build a parentMap from HeritageMap for use with c3Linearize. + * Traverses the parent chain starting from startNodeId, collecting all + * parent→children relationships into a Map. + * + * Uses a head-pointer BFS (queue[head++]) instead of Array.shift() to avoid + * O(n) per-dequeue re-indexing. For wide/shallow hierarchies common in + * large Java/C# codebases this keeps the walk linear in ancestor count. + */ +const buildParentMapFromHeritage = ( + startNodeId: string, + heritageMap: HeritageMap, +): Map => { + const parentMap = new Map(); + const visited = new Set(); + const queue: string[] = [startNodeId]; + let head = 0; + + while (head < queue.length) { + const nodeId = queue[head++]!; + if (visited.has(nodeId)) continue; + visited.add(nodeId); + const parents = heritageMap.getParents(nodeId); + if (parents.length > 0) { + parentMap.set(nodeId, parents); + for (const p of parents) { + if (!visited.has(p)) queue.push(p); + } + } + } + + return parentMap; +}; + +// --------------------------------------------------------------------------- +// MRO-aware method lookup +// --------------------------------------------------------------------------- + +/** + * Look up a method on an owner class, walking the parent chain via HeritageMap + * when the method isn't found on the direct owner. + * + * Respects the 5 per-language MRO strategies: + * - `first-wins`: BFS ancestor walk, first match wins (default) + * - `leftmost-base`: BFS ancestor walk, leftmost base in declaration order wins (C++); + * HeritageMap preserves insertion order matching source declaration, + * so BFS order is equivalent to leftmost-base semantics + * - `c3`: C3-linearized ancestor order, first match wins (Python) + * - `implements-split`: BFS ancestor walk, first match wins (Java/C#) — + * full ambiguity detection for multiple interface defaults + * is handled by computeMRO at graph level + * - `qualified-syntax`: No auto-resolution (Rust) — returns undefined + * + * Uses the `c3Linearize` defined in this file (also consumed by + * mro-processor.ts for graph-level MRO emission) for the `c3` strategy. + * + * Depends only on {@link SemanticModel} + {@link HeritageMap} + an + * {@link MroStrategy} literal — NO dependency on SymbolTable, the language + * registry, or resolution-context, which keeps the `model/` module free of + * cross-layer imports. Callers derive the strategy from their language + * provider before invoking this function. + * + * @internal This is the low-level MRO walker. Exported so call-processor's + * higher-level resolvers (and unit tests) can invoke it directly. Callers + * outside `core/ingestion/` should use the higher-level resolvers in + * call-processor.ts instead of depending on this function. + */ +export const lookupMethodByOwnerWithMRO = ( + ownerNodeId: string, + methodName: string, + heritageMap: HeritageMap, + model: SemanticModel, + strategy: MroStrategy, + argCount?: number, +): SymbolDefinition | undefined => { + // 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 = model.methods.lookupMethodByOwner(ownerNodeId, methodName, argCount); + if (direct) return direct; + + // Rust: requires qualified syntax (::method), no auto-resolution + if (strategy === 'qualified-syntax') return undefined; + + // Determine ancestor walk order based on MRO strategy. + // readonly to accept the cached (frozen) c3 linearization without copying. + let ancestors: readonly string[]; + if (strategy === 'c3') { + // C3 linearization (memoized per HeritageMap + // so repeated calls for the same owner within an ingestion run reuse the + // linearization instead of rebuilding the parent map and re-running C3). + // c3Linearize returns ancestors only (excludes the owner itself), + // matching heritageMap.getAncestors() semantics. + const c3Result = getCachedC3Linearization(ownerNodeId, heritageMap); + // Fall back to BFS order if C3 fails (cyclic or inconsistent hierarchy). + // Note: BFS order may not preserve Python MRO semantics in these edge + // cases, but cyclic/inconsistent hierarchies are invalid in Python anyway. + ancestors = c3Result ?? heritageMap.getAncestors(ownerNodeId); + } else { + // first-wins, leftmost-base, implements-split: BFS order via HeritageMap + ancestors = heritageMap.getAncestors(ownerNodeId); + } + + // 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 = model.methods.lookupMethodByOwner(ancestorId, methodName, argCount); + if (method) return method; + } + + return undefined; +}; diff --git a/gitnexus/src/core/ingestion/model/semantic-model.ts b/gitnexus/src/core/ingestion/model/semantic-model.ts new file mode 100644 index 000000000..9a822f8be --- /dev/null +++ b/gitnexus/src/core/ingestion/model/semantic-model.ts @@ -0,0 +1,193 @@ +/** + * Semantic Model + * + * Top-level orchestrator for all resolution-time data. Owns: + * + * - Three owner-scoped registries (types, methods, fields) + * - A nested SymbolTable (file + callable name indexes) wrapped so + * that `add()` fans out into the registries via the dispatch table + * + * ## DAG direction + * + * gitnexus-shared (NodeLabel) — leaf + * ↑ + * symbol-table.ts — pure file/callable index + * ↑ + * model/type-registry / method-registry / field-registry + * ↑ + * model/registration-table.ts — dispatch table factory + * ↑ + * model/semantic-model.ts — THIS FILE (orchestrator) + * ↑ + * resolve.ts, call-processor.ts, resolution-context.ts, ... + * + * `symbol-table.ts` is a leaf — it never imports from `./model/`. This + * file (semantic-model.ts) is the ONLY place where SymbolTable and the + * owner-scoped registries are composed. Upstream consumers pass around + * the `SemanticModel` interface and reach into `.symbols` for file-scoped + * operations or `.types` / `.methods` / `.fields` for owner-scoped ones. + * + * ## Fan-out via wrapped add() + * + * `createSemanticModel()` creates a pure SymbolTable, creates the three + * registries, builds a dispatch table via `createRegistrationTable`, and + * exposes a SymbolTable-shaped façade whose `add()`: + * + * 1. Calls `rawSymbols.add()` — writes the fileIndex + callable index + * and returns the fully-built `SymbolDefinition`. + * 2. Runs pre-dispatch normalization (`Function`-with-`ownerId` routes + * as `Method`). + * 3. Looks up the dispatch table and invokes the hook, which writes to + * the appropriate owner-scoped registry. + * + * The wrapper is the only place where the two layers are combined. A + * direct `createSymbolTable()` caller (e.g. an isolated unit test) gets + * the pure, registry-free behavior — no surprises, no hidden side + * effects. + */ + +import type { NodeLabel } from 'gitnexus-shared'; +import type { TypeRegistry, MutableTypeRegistry } from './type-registry.js'; +import type { MethodRegistry, MutableMethodRegistry } from './method-registry.js'; +import type { FieldRegistry, MutableFieldRegistry } from './field-registry.js'; +import { createTypeRegistry } from './type-registry.js'; +import { createMethodRegistry } from './method-registry.js'; +import { createFieldRegistry } from './field-registry.js'; +import type { + SymbolTableReader, + SymbolTableWriter, + SymbolDefinition, + AddMetadata, +} from './symbol-table.js'; +import { createSymbolTable } from './symbol-table.js'; +import { createRegistrationTable } from './registration-table.js'; + +// --------------------------------------------------------------------------- +// Public read-only interface +// --------------------------------------------------------------------------- + +/** + * Aggregated read-only view of the semantic registries plus the nested + * file/callable SymbolTable. + * + * `symbols` is typed as {@link SymbolTableReader} — consumers can query + * symbols but cannot register new ones or trigger a reset. Callers that + * need to register symbols or reset state must hold a + * {@link MutableSemanticModel} reference instead, which widens + * `symbols` back to {@link SymbolTableWriter} and adds `clear()` on the + * model itself. + * + * This segregation is the runtime half of the principle of least + * authority: a resolver that receives `SemanticModel` physically cannot + * mutate the index, so it cannot desync the leaf from the owner-scoped + * registries even accidentally. + */ +export interface SemanticModel { + readonly types: TypeRegistry; + readonly methods: MethodRegistry; + readonly fields: FieldRegistry; + readonly symbols: SymbolTableReader; +} + +// --------------------------------------------------------------------------- +// Mutable interface +// --------------------------------------------------------------------------- + +/** Mutable variant — exposes the MutableX registries, a Writer-typed + * `symbols` facade, and a full-cascade reset. This is the interface + * held by the lifecycle owner (pipeline, resolution-context); resolvers + * that only query should hold the narrower {@link SemanticModel}. */ +export interface MutableSemanticModel extends SemanticModel { + readonly types: MutableTypeRegistry; + readonly methods: MutableMethodRegistry; + readonly fields: MutableFieldRegistry; + readonly symbols: SymbolTableWriter; + /** Clear all registries AND the nested SymbolTable. */ + clear(): void; +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- +// +// NodeLabel taxonomy drift detection lives in `registration-table.ts` as a +// pure compile-time check — the `LABEL_BEHAVIOR` map is +// `Record` with `as const satisfies`, which proves +// coverage, uniqueness, and no-extra-keys at build time. No runtime guard +// is needed because drift is structurally impossible in the source. + +export const createSemanticModel = (): MutableSemanticModel => { + // 1. Create the pure, registry-unaware SymbolTable leaf. + // rawSymbols is the only handle in the codebase whose type (the + // internal createSymbolTable return) includes `.clear()`. cascadeClear + // below reaches it here; no external caller receives this variable. + const rawSymbols = createSymbolTable(); + + // 2. Create the three owner-scoped registries. + const types = createTypeRegistry(); + const methods = createMethodRegistry(); + const fields = createFieldRegistry(); + + // 3. Build the dispatch table, closed over THIS instance's registries. + const dispatchTable = createRegistrationTable({ types, methods, fields }); + + // 4. Wrap rawSymbols so `add()` fans out into the registries via the + // dispatch table. See module JSDoc for the three-step contract. + const wrappedAdd = ( + filePath: string, + name: string, + nodeId: string, + type: NodeLabel, + metadata?: AddMetadata, + ): SymbolDefinition => { + const def = rawSymbols.add(filePath, name, nodeId, type, metadata); + + // Function-with-ownerId (Python `def` in a class body, Rust trait + // method, Kotlin companion method) routes as Method. Keeps the + // dispatch table single-purpose. + const dispatchKey: NodeLabel = + type === 'Function' && metadata?.ownerId !== undefined ? 'Method' : type; + + const hook = dispatchTable.get(dispatchKey); + if (hook) { + hook(name, def); + } + + return def; + }; + + // Cascade clear: single source of truth for "reset the entire model". + // Wired into both `model.clear()` AND `model.symbols.clear()` so that a + // caller holding only a SymbolTable reference can't leave the + // owner-scoped registries populated while the file/callable indexes go + // empty (the phantom-resolution failure mode). + const cascadeClear = (): void => { + types.clear(); + methods.clear(); + fields.clear(); + rawSymbols.clear(); + }; + + // Writer-typed facade: exposes reads + add, but NO `clear` field. + // Callers holding a `SemanticModel.symbols` reference cannot desync + // the leaf indexes from the owner-scoped registries. Consumers that + // only query should widen their annotation to SymbolTableReader for + // least-authority clarity. + const symbols: SymbolTableWriter = { + add: wrappedAdd, + lookupExact: rawSymbols.lookupExact, + lookupExactFull: rawSymbols.lookupExactFull, + lookupExactAll: rawSymbols.lookupExactAll, + lookupCallableByName: rawSymbols.lookupCallableByName, + getFiles: rawSymbols.getFiles, + getStats: rawSymbols.getStats, + }; + + return { + types, + methods, + fields, + symbols, + clear: cascadeClear, + }; +}; diff --git a/gitnexus/src/core/ingestion/model/symbol-table.ts b/gitnexus/src/core/ingestion/model/symbol-table.ts new file mode 100644 index 000000000..046998778 --- /dev/null +++ b/gitnexus/src/core/ingestion/model/symbol-table.ts @@ -0,0 +1,381 @@ +/** + * Symbol Table — file-indexed + callable-name symbol storage. + * + * This module is a PURE LEAF in the ingestion DAG. It owns two orthogonal + * O(1) indexes: + * + * 1. fileIndex — Map> + * for same-file lookups (Tier 1 resolution) + * 2. callableByName — Map + * for name-keyed callable lookups (Tier 3 widen) + * + * SymbolTable deliberately knows NOTHING about the owner-scoped registries + * (types, methods, fields) that sit above it in the DAG. Those registries + * live in `model/` and depend on SymbolTable, not the other way around. + * {@link createSemanticModel} composes this pure SymbolTable with the + * registries and wraps `add()` to fan out registrations into both layers. + * + * DAG direction (strictly enforced): + * + * gitnexus-shared (NodeLabel) — leaf type + * ↑ + * symbol-table.ts — THIS FILE (pure storage) + * ↑ + * model/type-registry.ts, method-registry.ts, field-registry.ts + * ↑ + * model/registration-table.ts — dispatch table factory + * ↑ + * model/semantic-model.ts — orchestrator, wraps add() + * ↑ + * model/resolve.ts, call-processor.ts, resolution-context.ts, ... + * + * No arrow ever points downward from this file. If you are tempted to + * import from `./model/` here, you are going the wrong way — move the + * logic up the DAG instead. + */ + +import type { NodeLabel } from 'gitnexus-shared'; + +/** + * Class-like NodeLabels — used for qualifiedName fallback inside + * `SymbolTable.add()` and (via import into `model/registration-table.ts`) + * as the single source of truth for which labels route to classHook + * in the dispatch table. + * + * Exported as a `readonly` tuple so that `typeof CLASS_TYPES_TUPLE[number]` + * yields a precise literal union (`ClassLikeLabel`). The model layer + * imports this tuple and uses `Record` in a + * `satisfies` intersection to enforce at COMPILE TIME that every label + * listed here is also classified as dispatch in `LABEL_BEHAVIOR`. Adding + * a new class-like label to this tuple without updating `LABEL_BEHAVIOR` + * fails TypeScript. + * + * 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. + */ +export const CLASS_TYPES_TUPLE = [ + 'Class', + 'Struct', + 'Interface', + 'Enum', + 'Record', + 'Trait', +] as const satisfies readonly NodeLabel[]; + +export type ClassLikeLabel = (typeof CLASS_TYPES_TUPLE)[number]; + +export const CLASS_TYPES: ReadonlySet = new Set(CLASS_TYPES_TUPLE); + +/** Free-callable labels — single source of truth for "callables that have + * NO owner scope". Methods and constructors are owner-scoped and live in + * `MethodRegistry` — Tier 3 reaches them via + * `model.methods.lookupMethodByName`. See `resolution-context.ts` Tier 3 + * for how both indexes are consulted together. + * + * Exported as a `readonly` tuple so that `typeof FREE_CALLABLE_TUPLE[number]` + * yields a precise literal union (`FreeCallableLabel`). `registration-table.ts` + * imports this type and uses `Record` in + * a `satisfies` intersection to enforce at COMPILE TIME that every label + * listed here is also classified as `callable-only` in `LABEL_BEHAVIOR`. + * Adding a label to this tuple without updating `LABEL_BEHAVIOR` fails + * TypeScript. + * + * Partial-state caveat: Python/Rust/Kotlin class methods are emitted by + * the worker as `Function` + `ownerId` (not `Method`), so they still land + * here via the `Function` entry. Collapsing those three languages onto the + * `Method` label is pending a `def.type` preservation decision. + */ +export const FREE_CALLABLE_TUPLE = [ + 'Function', + 'Macro', // C/C++ + 'Delegate', // C# +] as const satisfies readonly NodeLabel[]; + +export type FreeCallableLabel = (typeof FREE_CALLABLE_TUPLE)[number]; + +export const FREE_CALLABLE_TYPES: ReadonlySet = new Set(FREE_CALLABLE_TUPLE); + +/** Symbol types that can be the TARGET of a call in the resolver's kind + * filter — superset of {@link FREE_CALLABLE_TYPES} that also admits + * owner-scoped methods and constructors pulled in from `MethodRegistry`. + * + * Why the split: `FREE_CALLABLE_TYPES` now has a narrow meaning (free + * callables indexed in `callableByName`), but call resolution still + * needs to accept Method and Constructor candidates once they have been + * unioned in from `model.methods.lookupMethodByName`. The resolver uses + * this constant for kind filtering in + * `filterCallableCandidates` / `countCallableCandidates`. + */ +export const CALL_TARGET_TYPES: ReadonlySet = new Set([ + ...FREE_CALLABLE_TYPES, + 'Method', + 'Constructor', +]); + +export interface SymbolDefinition { + nodeId: string; + filePath: string; + type: NodeLabel; + /** Canonical dot-separated qualified type name for class-like symbols + * (e.g. `App.Models.User`). Falls back to the simple symbol name when no + * package/namespace/module scope exists or no explicit qualified metadata is provided. */ + qualifiedName?: string; + parameterCount?: number; + /** Number of required (non-optional, non-default) parameters. + * Enables range-based arity filtering: argCount >= requiredParameterCount && argCount <= parameterCount. */ + requiredParameterCount?: number; + /** Per-parameter type names for overload disambiguation (e.g. ['int', 'String']). + * Populated when parameter types are resolvable from AST (any typed language). */ + parameterTypes?: string[]; + /** Raw return type text extracted from AST (e.g. 'User', 'Promise') */ + returnType?: string; + /** Declared type for non-callable symbols — fields/properties (e.g. 'Address', 'List') */ + declaredType?: string; + /** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */ + ownerId?: string; +} + +/** + * Optional metadata accepted by {@link SymbolTable.add}. Kept as a separate + * type alias so callers and wrappers can share the same shape. + */ +export interface AddMetadata { + parameterCount?: number; + requiredParameterCount?: number; + parameterTypes?: string[]; + returnType?: string; + declaredType?: string; + ownerId?: string; + qualifiedName?: string; +} + +/** + * Pure read-only view over the file and callable indexes. Does NOT + * include `add()` or `clear()`. + * + * Used by consumers that only query symbols (resolvers, type-env, field + * extractors). The interface is strictly observational — holding a + * `SymbolTableReader` cannot mutate the table in any way. + * + * For consumers that also need to register symbols, use + * {@link SymbolTableWriter}, which extends this interface with `add()`. + * Neither interface exposes `clear()` — that capability lives on the + * internal factory return type and is reachable only inside + * `SemanticModel` via `rawSymbols`. + * + * Segregating the observer contract from the mutation contract means + * callers holding only a Reader can never desync the model. + */ +export interface SymbolTableReader { + /** + * High Confidence: Look for a symbol specifically inside a file. + * Returns the Node ID if found. + */ + lookupExact: (filePath: string, name: string) => string | undefined; + + /** + * High Confidence: Look for a symbol in a specific file, returning full definition. + * Returns first matching definition — use lookupExactAll for overloaded methods. + */ + lookupExactFull: (filePath: string, name: string) => SymbolDefinition | undefined; + + /** + * High Confidence: Look for ALL symbols with this name in a specific file. + * Returns all definitions, including overloaded methods with the same name. + * The returned array is a view into the live internal index — callers + * MUST NOT mutate it. Use `readonly` to enforce this at the type level. + */ + lookupExactAll: (filePath: string, name: string) => readonly SymbolDefinition[]; + + /** + * Look up callable symbols (Function, Macro, Delegate) by name. + * O(1) via dedicated eagerly-populated index keyed by symbol name. + * Returned array is a view into the live index — do not mutate. + */ + lookupCallableByName: (name: string) => readonly SymbolDefinition[]; + + /** + * Iterate all indexed file paths. + * Used by Tier 2b (package-scoped) resolution to walk files matching a + * package directory suffix without a global name scan. + */ + getFiles: () => IterableIterator; + + /** + * Debugging: See how many files are tracked. + */ + getStats: () => { + fileCount: number; + }; +} + +/** + * Writer view — reads + symbol registration. Does NOT include `clear()`. + * + * `MutableSemanticModel.symbols` is typed as this interface, so the + * lifecycle owner can register symbols and query them. Full-model + * resets flow through `model.clear()`. + * + * The cascading `clear()` capability lives exclusively on the internal + * factory return type ({@link createSymbolTable}) — a private handle + * held only by `SemanticModel` via `rawSymbols`. + */ +export interface SymbolTableWriter extends SymbolTableReader { + /** + * Register a symbol in the file and (if callable) name-keyed indexes. + * + * Returns the constructed {@link SymbolDefinition} so higher-layer + * wrappers (e.g. `createSemanticModel`) can reuse it without rebuilding + * the def. This keeps the fan-out in one allocation. + */ + add: ( + filePath: string, + name: string, + nodeId: string, + type: NodeLabel, + metadata?: AddMetadata, + ) => SymbolDefinition; +} + +/** + * Internal return type for {@link createSymbolTable} — extends the + * writer with `clear()`. This capability is intentionally NOT exported + * as a named interface; consumers should hold a `SymbolTableReader` or + * `SymbolTableWriter` instead. + * + * `SemanticModel`'s constructor is the only caller of `createSymbolTable`, + * and it retains the returned handle as the private `rawSymbols` + * reference so `cascadeClear` can reach `clear()`. Every other consumer + * receives the narrower `SymbolTableWriter` facade on `model.symbols`. + */ +interface InternalSymbolTable extends SymbolTableWriter { + /** + * Cleanup memory. Clears only the file and callable indexes owned here — + * owner-scoped registries are cleared by their respective owners via + * `model.clear()`. + */ + clear: () => void; +} + +export const createSymbolTable = (): InternalSymbolTable => { + // 1. File-Specific Index — stores full SymbolDefinition(s) for O(1) lookup. + // Structure: FilePath -> (SymbolName -> SymbolDefinition[]) + // Array allows overloaded methods (same name, different signatures) to coexist. + const fileIndex = new Map>(); + + // 2. Eagerly-populated Callable Index — maintained on add(). + // Structure: SymbolName -> [Callable Definitions] + // Only Function, Method, Constructor, Macro, Delegate symbols are indexed. + const callableByName = new Map(); + + const add = ( + filePath: string, + name: string, + nodeId: string, + type: NodeLabel, + metadata?: AddMetadata, + ): SymbolDefinition => { + const qualifiedName = CLASS_TYPES.has(type) + ? (metadata?.qualifiedName ?? name) + : metadata?.qualifiedName; + const def: SymbolDefinition = { + nodeId, + filePath, + type, + ...(qualifiedName !== undefined ? { qualifiedName } : {}), + ...(metadata?.parameterCount !== undefined + ? { parameterCount: metadata.parameterCount } + : {}), + ...(metadata?.requiredParameterCount !== undefined + ? { requiredParameterCount: metadata.requiredParameterCount } + : {}), + ...(metadata?.parameterTypes !== undefined + ? { parameterTypes: metadata.parameterTypes } + : {}), + ...(metadata?.returnType !== undefined ? { returnType: metadata.returnType } : {}), + ...(metadata?.declaredType !== undefined ? { declaredType: metadata.declaredType } : {}), + ...(metadata?.ownerId !== undefined ? { ownerId: metadata.ownerId } : {}), + }; + + // A. File Index — unconditional. + if (!fileIndex.has(filePath)) { + fileIndex.set(filePath, new Map()); + } + const fileMap = fileIndex.get(filePath)!; + if (!fileMap.has(name)) { + fileMap.set(name, [def]); + } else { + fileMap.get(name)!.push(def); + } + + // B. Callable Index — gated by FREE_CALLABLE_TYPES. + // Note: Property is NOT in FREE_CALLABLE_TYPES, so it never lands here. + // This is the single source of truth for callable-index membership; + // the higher-layer dispatch table only decides owner-scoped routing. + // + // Fallback: `Method` or `Constructor` without an `ownerId` is an + // extractor contract violation (AST-degraded parse, or a buggy + // language extractor). The owner-scoped dispatch hook silently + // skips such defs because it has no owner to key them under, so + // without this fallback they would be invisible at Tier 3 global + // resolution. Route them through `callableByName` so they remain + // reachable by name — matching pre-dispatch-table behavior. + const isOrphanedOwnerScoped = + (type === 'Method' || type === 'Constructor') && metadata?.ownerId === undefined; + if (FREE_CALLABLE_TYPES.has(type) || isOrphanedOwnerScoped) { + const existing = callableByName.get(name); + if (existing) { + existing.push(def); + } else { + callableByName.set(name, [def]); + } + } + + return def; + }; + + const lookupExact = (filePath: string, name: string): string | undefined => { + const defs = fileIndex.get(filePath)?.get(name); + return defs?.[0]?.nodeId; + }; + + const lookupExactFull = (filePath: string, name: string): SymbolDefinition | undefined => { + const defs = fileIndex.get(filePath)?.get(name); + return defs?.[0]; + }; + + const lookupExactAll = (filePath: string, name: string): SymbolDefinition[] => { + return fileIndex.get(filePath)?.get(name) ?? []; + }; + + const lookupCallableByName = (name: string): SymbolDefinition[] => { + return callableByName.get(name) ?? []; + }; + + /** Returns a live iterator over all indexed file paths (fileIndex.keys()). + * The iterator is invalidated if add() changes fileIndex.size during + * iteration (ES2015 Map spec). Safe in the current pipeline because all + * symbols are added before resolution begins. */ + const getFiles = (): IterableIterator => fileIndex.keys(); + + const getStats = () => ({ + fileCount: fileIndex.size, + }); + + const clear = () => { + fileIndex.clear(); + callableByName.clear(); + }; + + return { + add, + lookupExact, + lookupExactFull, + lookupExactAll, + lookupCallableByName, + getFiles, + getStats, + clear, + }; +}; diff --git a/gitnexus/src/core/ingestion/model/type-registry.ts b/gitnexus/src/core/ingestion/model/type-registry.ts new file mode 100644 index 000000000..95d52dbc1 --- /dev/null +++ b/gitnexus/src/core/ingestion/model/type-registry.ts @@ -0,0 +1,113 @@ +/** + * Type Registry + * + * Class/struct/interface index extracted from SymbolTable. + * Eagerly-populated indexes keyed by symbol name and qualified name. + * Also includes a separate index for Rust Impl blocks. + */ + +import type { SymbolDefinition } from './symbol-table.js'; + +// --------------------------------------------------------------------------- +// Public read-only interface +// --------------------------------------------------------------------------- + +export interface TypeRegistry { + /** + * Look up class-like definitions (Class, Struct, Interface, Enum, Record, Trait) + * by simple name. Returns all matching definitions across files + * (e.g. partial classes). Returned array is a view into the live + * internal index — do not mutate. + */ + lookupClassByName(name: string): readonly SymbolDefinition[]; + + /** + * Look up class-like definitions by canonical qualified name. + * Qualified names are normalized to dot-separated scope segments across languages, + * e.g. `App.Models.User`, `com.example.User`, or `Admin.User`. + * Returned array is a view into the live index — do not mutate. + */ + lookupClassByQualifiedName(qualifiedName: string): readonly SymbolDefinition[]; + + /** + * Look up Impl nodes by name. Used by Tier 3 resolution to include Rust + * impl blocks alongside class-like candidates. + * Returned array is a view into the live index — do not mutate. + */ + lookupImplByName(name: string): readonly SymbolDefinition[]; +} + +// --------------------------------------------------------------------------- +// Mutable interface (used internally by SymbolTable.add / clear) +// --------------------------------------------------------------------------- + +export interface MutableTypeRegistry extends TypeRegistry { + /** Register a class-like type by name and qualified name. */ + registerClass(name: string, qualifiedName: string, def: SymbolDefinition): void; + /** Register a Rust Impl block by name. */ + registerImpl(name: string, def: SymbolDefinition): void; + /** Clear all entries. */ + clear(): void; +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +export const createTypeRegistry = (): MutableTypeRegistry => { + const classByName = new Map(); + const classByQualifiedName = new Map(); + const implByName = new Map(); + + const lookupClassByName = (name: string): SymbolDefinition[] => { + return classByName.get(name) ?? []; + }; + + const lookupClassByQualifiedName = (qualifiedName: string): SymbolDefinition[] => { + return classByQualifiedName.get(qualifiedName) ?? []; + }; + + const lookupImplByName = (name: string): SymbolDefinition[] => { + return implByName.get(name) ?? []; + }; + + const registerClass = (name: string, qualifiedName: string, def: SymbolDefinition): void => { + const existing = classByName.get(name); + if (existing) { + existing.push(def); + } else { + classByName.set(name, [def]); + } + + const qualifiedMatches = classByQualifiedName.get(qualifiedName); + if (qualifiedMatches) { + qualifiedMatches.push(def); + } else { + classByQualifiedName.set(qualifiedName, [def]); + } + }; + + const registerImpl = (name: string, def: SymbolDefinition): void => { + const existing = implByName.get(name); + if (existing) { + existing.push(def); + } else { + implByName.set(name, [def]); + } + }; + + const clear = (): void => { + classByName.clear(); + classByQualifiedName.clear(); + implByName.clear(); + }; + + return { + lookupClassByName, + lookupClassByQualifiedName, + lookupImplByName, + registerClass, + registerImpl, + clear, + }; +}; diff --git a/gitnexus/src/core/ingestion/mro-processor.ts b/gitnexus/src/core/ingestion/mro-processor.ts index e44fbd3e7..f73da20fc 100644 --- a/gitnexus/src/core/ingestion/mro-processor.ts +++ b/gitnexus/src/core/ingestion/mro-processor.ts @@ -23,6 +23,7 @@ import { KnowledgeGraph } from '../graph/types.js'; import { generateId } from '../../lib/utils.js'; import { SupportedLanguages } from 'gitnexus-shared'; import { getProvider } from './languages/index.js'; +import { c3Linearize, gatherAncestors } from './model/resolve.js'; // --------------------------------------------------------------------------- // Public types @@ -93,115 +94,9 @@ function buildAdjacency(graph: KnowledgeGraph) { return { parentMap, methodMap, parentEdgeType }; } -/** - * Gather all ancestor IDs in BFS / topological order. - * Returns the linearized list of ancestor IDs (excluding the class itself). - */ -function gatherAncestors(classId: string, parentMap: Map): string[] { - const visited = new Set(); - const order: string[] = []; - const queue: string[] = [...(parentMap.get(classId) ?? [])]; - - while (queue.length > 0) { - const id = queue.shift()!; - if (visited.has(id)) continue; - visited.add(id); - order.push(id); - const grandparents = parentMap.get(id); - if (grandparents) { - for (const gp of grandparents) { - if (!visited.has(gp)) queue.push(gp); - } - } - } - - return order; -} - -// --------------------------------------------------------------------------- -// C3 linearization (Python MRO) -// --------------------------------------------------------------------------- - -/** - * Compute C3 linearization for a class given a parentMap. - * Returns an array of ancestor IDs in C3 order (excluding the class itself), - * or null if linearization fails (inconsistent or cyclic hierarchy). - */ -export function c3Linearize( - classId: string, - parentMap: Map, - cache: Map, - inProgress?: Set, -): string[] | null { - if (cache.has(classId)) return cache.get(classId)!; - - // Cycle detection: if we're already computing this class, the hierarchy is cyclic - const visiting = inProgress ?? new Set(); - if (visiting.has(classId)) { - cache.set(classId, null); - return null; - } - visiting.add(classId); - - const directParents = parentMap.get(classId); - if (!directParents || directParents.length === 0) { - visiting.delete(classId); - cache.set(classId, []); - return []; - } - - // Compute linearization for each parent first - const parentLinearizations: string[][] = []; - for (const pid of directParents) { - const pLin = c3Linearize(pid, parentMap, cache, visiting); - if (pLin === null) { - visiting.delete(classId); - cache.set(classId, null); - return null; - } - parentLinearizations.push([pid, ...pLin]); - } - - // Add the direct parents list as the final sequence - const sequences = [...parentLinearizations, [...directParents]]; - const result: string[] = []; - - while (sequences.some((s) => s.length > 0)) { - // Find a good head: one that doesn't appear in the tail of any other sequence - let head: string | null = null; - for (const seq of sequences) { - if (seq.length === 0) continue; - const candidate = seq[0]; - const inTail = sequences.some( - (other) => other.length > 1 && other.indexOf(candidate, 1) !== -1, - ); - if (!inTail) { - head = candidate; - break; - } - } - - if (head === null) { - // Inconsistent hierarchy - visiting.delete(classId); - cache.set(classId, null); - return null; - } - - result.push(head); - - // Remove the chosen head from all sequences - for (const seq of sequences) { - if (seq.length > 0 && seq[0] === head) { - seq.shift(); - } - } - } - - visiting.delete(classId); - cache.set(classId, result); - return result; -} +// `gatherAncestors` and `c3Linearize` live in `./model/resolve.ts` and +// are imported at the top of this file for internal use by `computeMRO` +// and the method-override edge emitter. // --------------------------------------------------------------------------- // Language-specific resolution diff --git a/gitnexus/src/core/ingestion/named-binding-processor.ts b/gitnexus/src/core/ingestion/named-binding-processor.ts deleted file mode 100644 index 4340bf9e4..000000000 --- a/gitnexus/src/core/ingestion/named-binding-processor.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { SymbolTable, SymbolDefinition } from './symbol-table.js'; -import type { NamedImportMap } from './import-processor.js'; - -/** - * Walk a named-binding re-export chain through NamedImportMap. - * - * When file A imports { User } from B, and B re-exports { User } from C, - * the NamedImportMap for A points to B, but B has no User definition. - * This function follows the chain: A→B→C until a definition is found. - * - * Returns the definitions found at the end of the chain, or null if the - * chain breaks (missing binding, circular reference, or depth exceeded). - * Max depth 5 to prevent infinite loops. - */ -export function walkBindingChain( - name: string, - currentFilePath: string, - symbolTable: SymbolTable, - namedImportMap: NamedImportMap, -): SymbolDefinition[] | null { - let lookupFile = currentFilePath; - let lookupName = name; - const visited = new Set(); - - for (let depth = 0; depth < 5; depth++) { - const bindings = namedImportMap.get(lookupFile); - if (!bindings) return null; - - const binding = bindings.get(lookupName); - if (!binding) return null; - - const key = `${binding.sourcePath}:${binding.exportedName}`; - if (visited.has(key)) return null; // circular - visited.add(key); - - const targetName = binding.exportedName; - const resolvedDefs = symbolTable.lookupExactAll(binding.sourcePath, targetName); - - if (resolvedDefs.length > 0) return resolvedDefs; - - // No definition in source file → follow re-export chain - lookupFile = binding.sourcePath; - lookupName = targetName; - } - - return null; -} diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index f37f1a52f..9cf8394fe 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -4,7 +4,9 @@ import Parser from 'tree-sitter'; import { loadParser, loadLanguage, isLanguageAvailable } from '../tree-sitter/parser-loader.js'; import { getProvider } from './languages/index.js'; import { generateId } from '../../lib/utils.js'; -import type { SymbolTable } from './symbol-table.js'; +import type { SymbolTableReader, SymbolTableWriter } from './model/symbol-table.js'; +// SymbolTableReader is used for the FieldExtractorContext stub; the +// parsing functions themselves need Writer because they call .add(). import { ASTCache } from './ast-cache.js'; import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared'; import { extractVueScript, isVueSetupTopLevel } from './vue-sfc-extractor.js'; @@ -36,7 +38,6 @@ import type { ExtractedImport, ExtractedCall, ExtractedAssignment, - ExtractedHeritage, ExtractedRoute, ExtractedFetchCall, ExtractedDecoratorRoute, @@ -45,6 +46,7 @@ import type { FileScopeBindings, ExtractedORMQuery, } from './workers/parse-worker.js'; +import type { ExtractedHeritage } from './model/heritage-map.js'; import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from './constants.js'; export type FileProgressCallback = (current: number, total: number, filePath: string) => void; @@ -70,7 +72,7 @@ export interface WorkerExtractedData { const processParsingWithWorkers = async ( graph: KnowledgeGraph, files: { path: string; content: string }[], - symbolTable: SymbolTable, + symbolTable: SymbolTableWriter, astCache: ASTCache, workerPool: WorkerPool, onFileProgress?: FileProgressCallback, @@ -250,13 +252,19 @@ function seqFindEnclosingClassNode(node: SyntaxNode): SyntaxNode | null { return null; } -/** Minimal no-op SymbolTable stub for FieldExtractorContext (sequential path has a real - * SymbolTable, but it's incomplete at this stage — use the stub for safety). */ -const NOOP_SYMBOL_TABLE_SEQ = { - lookupExactAll: () => [], +/** Minimal no-op SymbolTable stub for FieldExtractorContext (sequential + * path has a real SymbolTable, but it's incomplete at this stage — use + * the stub for safety). Implements the full {@link SymbolTableReader} + * surface so future extractor additions don't silently fall off an + * `as unknown as` cast. */ +const NOOP_SYMBOL_TABLE_SEQ: SymbolTableReader = { lookupExact: () => undefined, lookupExactFull: () => undefined, -} as unknown as SymbolTable; + lookupExactAll: () => [], + lookupCallableByName: () => [], + getFiles: () => [][Symbol.iterator](), + getStats: () => ({ fileCount: 0 }), +}; function seqGetFieldInfo( classNode: SyntaxNode, @@ -278,7 +286,7 @@ function seqGetFieldInfo( const processParsingSequential = async ( graph: KnowledgeGraph, files: { path: string; content: string }[], - symbolTable: SymbolTable, + symbolTable: SymbolTableWriter, astCache: ASTCache, onFileProgress?: FileProgressCallback, ) => { @@ -650,7 +658,7 @@ const processParsingSequential = async ( export const processParsing = async ( graph: KnowledgeGraph, files: { path: string; content: string }[], - symbolTable: SymbolTable, + symbolTable: SymbolTableWriter, astCache: ASTCache, onFileProgress?: FileProgressCallback, workerPool?: WorkerPool, diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 42c630289..c3776d50a 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -27,7 +27,7 @@ import { type ExportedTypeMap, buildExportedTypeMapFromGraph, } from './call-processor.js'; -import { buildHeritageMap } from './heritage-map.js'; +import { buildHeritageMap } from './model/heritage-map.js'; import { nextjsFileToRouteURL, normalizeFetchURL } from './route-extractors/nextjs.js'; import { expoFileToRouteURL } from './route-extractors/expo.js'; import { phpFileToRouteURL } from './route-extractors/php.js'; @@ -47,21 +47,22 @@ import type { ExtractedCall, ExtractedDecoratorRoute, ExtractedFetchCall, - ExtractedHeritage, ExtractedORMQuery, ExtractedRoute, ExtractedToolDef, FileConstructorBindings, } from './workers/parse-worker.js'; +import type { ExtractedHeritage } from './model/heritage-map.js'; import { processHeritage, processHeritageFromExtracted, extractExtractedHeritageFromFiles, + getHeritageStrategyForLanguage, } from './heritage-processor.js'; import { computeMRO } from './mro-processor.js'; import { processCommunities } from './community-processor.js'; import { processProcesses } from './process-processor.js'; -import { createResolutionContext } from './resolution-context.js'; +import { createResolutionContext } from './model/resolution-context.js'; import { createASTCache } from './ast-cache.js'; import { type PipelineProgress, getLanguageFromFilename } from 'gitnexus-shared'; import { PipelineResult } from '../../types/pipeline.js'; @@ -334,7 +335,7 @@ async function runCrossFileBindingPropagation( // For the worker path, buildTypeEnv runs inside workers without SymbolTable, // so exported bindings must be collected from graph + SymbolTable in main thread. if (exportedTypeMap.size === 0 && graph.nodeCount > 0) { - const graphExports = buildExportedTypeMapFromGraph(graph, ctx.symbols); + const graphExports = buildExportedTypeMapFromGraph(graph, ctx.model.symbols); for (const [fp, exports] of graphExports) exportedTypeMap.set(fp, exports); } @@ -361,7 +362,7 @@ async function runCrossFileBindingPropagation( filesWithGaps++; break; } - const def = ctx.symbols.lookupExactFull(binding.sourcePath, binding.exportedName); + const def = ctx.model.symbols.lookupExactFull(binding.sourcePath, binding.exportedName); if (def?.returnType) { filesWithGaps++; break; @@ -413,11 +414,15 @@ async function runCrossFileBindingPropagation( } } - const importedReturns = buildImportedReturnTypes(filePath, ctx.namedImportMap, ctx.symbols); + const importedReturns = buildImportedReturnTypes( + filePath, + ctx.namedImportMap, + ctx.model.symbols, + ); const importedRawReturns = buildImportedRawReturnTypes( filePath, ctx.namedImportMap, - ctx.symbols, + ctx.model.symbols, ); if (seeded.size === 0 && importedReturns.size === 0) continue; if (!allPathSet.has(filePath)) continue; @@ -657,7 +662,7 @@ async function runChunkedParseAndResolve( allORMQueries: ExtractedORMQuery[]; bindingAccumulator: BindingAccumulator; }> { - const symbolTable = ctx.symbols; + const symbolTable = ctx.model.symbols; const parseableScanned = scannedFiles.filter((f) => { const lang = getLanguageFromFilename(f.path); @@ -978,7 +983,9 @@ async function runChunkedParseAndResolve( // Build unified HeritageMap (parent lookup + implementor index) after all chunks. const fullWorkerHeritageMap = - deferredWorkerHeritage.length > 0 ? buildHeritageMap(deferredWorkerHeritage, ctx) : undefined; + deferredWorkerHeritage.length > 0 + ? buildHeritageMap(deferredWorkerHeritage, ctx, getHeritageStrategyForLanguage) + : undefined; if (deferredWorkerCalls.length > 0) { await processCallsFromExtracted( @@ -1058,7 +1065,9 @@ async function runChunkedParseAndResolve( } // Build unified HeritageMap from all sequential heritage (parent lookup + implementor index). const sequentialHeritageMap = - allSequentialHeritage.length > 0 ? buildHeritageMap(allSequentialHeritage, ctx) : undefined; + allSequentialHeritage.length > 0 + ? buildHeritageMap(allSequentialHeritage, ctx, getHeritageStrategyForLanguage) + : undefined; // Pass 2: Process calls, heritage edges, fetch calls, and ORM queries per chunk. // Reuse the file contents cached in Pass 1 instead of re-reading from disk. diff --git a/gitnexus/src/core/ingestion/symbol-table.ts b/gitnexus/src/core/ingestion/symbol-table.ts deleted file mode 100644 index eb7a62079..000000000 --- a/gitnexus/src/core/ingestion/symbol-table.ts +++ /dev/null @@ -1,439 +0,0 @@ -import type { NodeLabel } from 'gitnexus-shared'; - -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', -]); - -/** Callable symbol types indexed in callableByName for Tier 3 resolution - * and D2 widen in call-processor.ts. Single source of truth — do not - * duplicate this set elsewhere. */ -export const CALLABLE_TYPES = new Set([ - 'Function', - 'Method', - 'Constructor', - 'Macro', // C/C++ - 'Delegate', // C# -]); - -export interface SymbolDefinition { - nodeId: string; - filePath: string; - type: NodeLabel; - /** Canonical dot-separated qualified type name for class-like symbols - * (e.g. `App.Models.User`). Falls back to the simple symbol name when no - * package/namespace/module scope exists or no explicit qualified metadata is provided. */ - qualifiedName?: string; - parameterCount?: number; - /** Number of required (non-optional, non-default) parameters. - * Enables range-based arity filtering: argCount >= requiredParameterCount && argCount <= parameterCount. */ - requiredParameterCount?: number; - /** Per-parameter type names for overload disambiguation (e.g. ['int', 'String']). - * Populated when parameter types are resolvable from AST (any typed language). - * Used for disambiguation in overloading languages (Java, Kotlin, C#, C++). */ - parameterTypes?: string[]; - /** Raw return type text extracted from AST (e.g. 'User', 'Promise') */ - returnType?: string; - /** Declared type for non-callable symbols — fields/properties (e.g. 'Address', 'List') */ - declaredType?: string; - /** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */ - ownerId?: string; -} - -export interface SymbolTable { - /** - * Register a new symbol definition - */ - add: ( - filePath: string, - name: string, - nodeId: string, - type: NodeLabel, - metadata?: { - parameterCount?: number; - requiredParameterCount?: number; - parameterTypes?: string[]; - returnType?: string; - declaredType?: string; - ownerId?: string; - qualifiedName?: string; - }, - ) => void; - - /** - * High Confidence: Look for a symbol specifically inside a file - * Returns the Node ID if found - */ - lookupExact: (filePath: string, name: string) => string | undefined; - - /** - * High Confidence: Look for a symbol in a specific file, returning full definition. - * Includes type information needed for heritage resolution (Class vs Interface). - * Returns first matching definition — use lookupExactAll for overloaded methods. - */ - lookupExactFull: (filePath: string, name: string) => SymbolDefinition | undefined; - - /** - * High Confidence: Look for ALL symbols with this name in a specific file. - * Returns all definitions, including overloaded methods with the same name. - * Used by resolution-context to pass all same-file overloads to candidate filtering. - */ - lookupExactAll: (filePath: string, name: string) => SymbolDefinition[]; - - /** - * Look up callable symbols (Function, Method, Constructor, Macro, Delegate) by name. - * O(1) via dedicated eagerly-populated index keyed by symbol name. - * Used by Tier 3 resolution and ReturnTypeLookup to resolve callee → return type. - */ - lookupCallableByName: (name: string) => SymbolDefinition[]; - - /** - * Look up a field/property by its owning class nodeId and field name. - * O(1) via dedicated eagerly-populated index keyed by `ownerNodeId\0fieldName`. - * Returns undefined when no matching property exists or the owner is ambiguous. - */ - lookupFieldByOwner: (ownerNodeId: string, fieldName: string) => SymbolDefinition | undefined; - - /** - * Look up a method by its owning class nodeId and method name. - * O(1) via dedicated eagerly-populated index keyed by `ownerNodeId\0methodName`. - * For overloaded methods (same owner + name): returns the first match when all - * overloads share the same returnType, undefined when return types differ (ambiguous). - * Used by walkMixedChain for deterministic cross-class chain resolution. - */ - /** - * 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. - * O(1) via dedicated eagerly-populated index keyed by symbol name. - * Returns all matching definitions across files (e.g. partial classes). - * Used by Phase 1 semantic-model tasks to replace filtered global lookups. - */ - lookupClassByName: (name: string) => SymbolDefinition[]; - - /** - * Look up class-like definitions by canonical qualified name. - * Qualified names are normalized to dot-separated scope segments across languages, - * e.g. `App.Models.User`, `com.example.User`, or `Admin.User`. - * Top-level class-like symbols with no explicit scope are indexed under their simple name. - */ - lookupClassByQualifiedName: (qualifiedName: string) => SymbolDefinition[]; - - /** - * Look up Impl nodes by name. - * O(1) via dedicated eagerly-populated index keyed by symbol name. - * Used by Tier 3 resolution to include Rust impl blocks alongside - * class-like candidates so method lookups on `impl User { fn save() }` work - * correctly (Rust methods are indexed under the Impl nodeId, not the Struct). - */ - lookupImplByName: (name: string) => SymbolDefinition[]; - - /** - * Iterate all indexed file paths. - * Used by Tier 2b (package-scoped) resolution to walk files matching a - * package directory suffix without a global name scan. - */ - getFiles: () => IterableIterator; - - /** - * Debugging: See how many symbols are tracked - */ - getStats: () => { - fileCount: number; - }; - - /** - * Cleanup memory - */ - clear: () => void; -} - -export const createSymbolTable = (): SymbolTable => { - // 1. File-Specific Index — stores full SymbolDefinition(s) for O(1) lookup. - // Structure: FilePath -> (SymbolName -> SymbolDefinition[]) - // Array allows overloaded methods (same name, different signatures) to coexist. - const fileIndex = new Map>(); - - // 2. Eagerly-populated Callable Index — maintained on add(). - // Structure: SymbolName -> [Callable Definitions] - // Only Function, Method, Constructor, Macro, Delegate symbols are indexed. - const callableByName = new Map(); - - // 3. Eagerly-populated Field/Property Index — keyed by "ownerNodeId\0fieldName". - // Only Property symbols with ownerId and declaredType are indexed. - const fieldByOwner = new Map(); - - // 4. Eagerly-populated Method Index — keyed by "ownerNodeId\0methodName". - // Method symbols with ownerId are indexed. Supports overloads (array values). - const methodByOwner = new Map(); - - // 5. Eagerly-populated Class-type Index — keyed by symbol name. - // Only Class, Struct, Interface, Enum, Record symbols are indexed. - const classByName = new Map(); - const classByQualifiedName = new Map(); - - // 6. Eagerly-populated Impl Index — keyed by symbol name. - // Rust impl blocks (type 'Impl') are stored here to keep them out of - // classByName (which drives heritage resolution) while still being - // reachable from Tier 3 resolution for method lookup. - const implByName = new Map(); - - // Use the module-level CALLABLE_TYPES constant (exported for call-processor.ts). - - const add = ( - filePath: string, - name: string, - nodeId: string, - type: NodeLabel, - metadata?: { - parameterCount?: number; - requiredParameterCount?: number; - parameterTypes?: string[]; - returnType?: string; - declaredType?: string; - ownerId?: string; - qualifiedName?: string; - }, - ) => { - const qualifiedName = CLASS_TYPES.has(type) - ? (metadata?.qualifiedName ?? name) - : metadata?.qualifiedName; - const def: SymbolDefinition = { - nodeId, - filePath, - type, - ...(qualifiedName !== undefined ? { qualifiedName } : {}), - ...(metadata?.parameterCount !== undefined - ? { parameterCount: metadata.parameterCount } - : {}), - ...(metadata?.requiredParameterCount !== undefined - ? { requiredParameterCount: metadata.requiredParameterCount } - : {}), - ...(metadata?.parameterTypes !== undefined - ? { parameterTypes: metadata.parameterTypes } - : {}), - ...(metadata?.returnType !== undefined ? { returnType: metadata.returnType } : {}), - ...(metadata?.declaredType !== undefined ? { declaredType: metadata.declaredType } : {}), - ...(metadata?.ownerId !== undefined ? { ownerId: metadata.ownerId } : {}), - }; - - // A. Add to File Index (shared reference — zero additional memory) - if (!fileIndex.has(filePath)) { - fileIndex.set(filePath, new Map()); - } - const fileMap = fileIndex.get(filePath)!; - if (!fileMap.has(name)) { - fileMap.set(name, [def]); - } else { - fileMap.get(name)!.push(def); - } - - // B. Properties go to fieldByOwner index only — skip other indexes to prevent - // namespace pollution for common names like 'id', 'name', 'type'. - // Index ALL properties (even without declaredType) so write-access tracking - // can resolve field ownership for dynamically-typed languages (Ruby, JS). - if (type === 'Property' && metadata?.ownerId) { - fieldByOwner.set(`${metadata.ownerId}\0${name}`, def); - // Still add to fileIndex above (for lookupExact), but skip other indexes - return; - } - - // C. Methods, constructors, and ownerId-bound Functions go to - // methodByOwner index. - // - // 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 widening. - if ((type === 'Method' || type === 'Constructor' || type === 'Function') && metadata?.ownerId) { - const key = `${metadata.ownerId}\0${name}`; - const existing = methodByOwner.get(key); - if (existing) { - existing.push(def); - } else { - methodByOwner.set(key, [def]); - } - } - - // C2. Class-like types go to classByName index. - if (CLASS_TYPES.has(type)) { - const existing = classByName.get(name); - if (existing) { - existing.push(def); - } else { - classByName.set(name, [def]); - } - - const qualifiedKey = qualifiedName ?? name; - const qualifiedMatches = classByQualifiedName.get(qualifiedKey); - if (qualifiedMatches) { - qualifiedMatches.push(def); - } else { - classByQualifiedName.set(qualifiedKey, [def]); - } - } - - // C3. Rust Impl blocks go to implByName (separate from classByName to avoid - // polluting heritage resolution with Impl nodes as parent candidates). - if (type === 'Impl') { - const existing = implByName.get(name); - if (existing) { - existing.push(def); - } else { - implByName.set(name, [def]); - } - } - - // D. Eagerly maintain callable index (like classByName, implByName). - if (CALLABLE_TYPES.has(type)) { - const existing = callableByName.get(name); - if (existing) { - existing.push(def); - } else { - callableByName.set(name, [def]); - } - } - }; - - const lookupExact = (filePath: string, name: string): string | undefined => { - const defs = fileIndex.get(filePath)?.get(name); - return defs?.[0]?.nodeId; - }; - - const lookupExactFull = (filePath: string, name: string): SymbolDefinition | undefined => { - const defs = fileIndex.get(filePath)?.get(name); - return defs?.[0]; - }; - - const lookupExactAll = (filePath: string, name: string): SymbolDefinition[] => { - return fileIndex.get(filePath)?.get(name) ?? []; - }; - - const lookupCallableByName = (name: string): SymbolDefinition[] => { - return callableByName.get(name) ?? []; - }; - - const lookupFieldByOwner = ( - ownerNodeId: string, - fieldName: string, - ): SymbolDefinition | undefined => { - return fieldByOwner.get(`${ownerNodeId}\0${fieldName}`); - }; - - const lookupMethodByOwner = ( - ownerNodeId: string, - methodName: string, - argCount?: number, - ): SymbolDefinition | undefined => { - const defs = methodByOwner.get(`${ownerNodeId}\0${methodName}`); - if (!defs || defs.length === 0) 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; - } - - 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[] => { - return classByName.get(name) ?? []; - }; - - const lookupClassByQualifiedName = (qualifiedName: string): SymbolDefinition[] => { - return classByQualifiedName.get(qualifiedName) ?? []; - }; - - const lookupImplByName = (name: string): SymbolDefinition[] => { - return implByName.get(name) ?? []; - }; - - /** Returns a live iterator over all indexed file paths (fileIndex.keys()). - * The iterator is invalidated if add() changes fileIndex.size during - * iteration (ES2015 Map spec). Safe in the current pipeline because all - * symbols are added before resolution begins. */ - const getFiles = (): IterableIterator => fileIndex.keys(); - - const getStats = () => ({ - fileCount: fileIndex.size, - }); - - const clear = () => { - fileIndex.clear(); - callableByName.clear(); - fieldByOwner.clear(); - methodByOwner.clear(); - classByName.clear(); - classByQualifiedName.clear(); - implByName.clear(); - }; - - return { - add, - lookupExact, - lookupExactFull, - lookupExactAll, - lookupCallableByName, - lookupFieldByOwner, - lookupMethodByOwner, - lookupClassByName, - lookupClassByQualifiedName, - lookupImplByName, - getFiles, - getStats, - clear, - }; -}; diff --git a/gitnexus/src/core/ingestion/type-env.ts b/gitnexus/src/core/ingestion/type-env.ts index c8eba5819..368de762b 100644 --- a/gitnexus/src/core/ingestion/type-env.ts +++ b/gitnexus/src/core/ingestion/type-env.ts @@ -21,7 +21,7 @@ import { stripNullable, extractReturnTypeName, } from './type-extractors/shared.js'; -import type { SymbolTable } from './symbol-table.js'; +import type { SemanticModel } from './model/semantic-model.js'; import type { NodeLabel } from 'gitnexus-shared'; /** @@ -416,11 +416,8 @@ const findEnclosingScopeKey = ( * Only `.has()` is exposed — the SymbolTable doesn't support iteration. * Results are memoized to avoid redundant class-index scans across declarations. */ -const createClassNameLookup = ( - localNames: Set, - symbolTable?: SymbolTable, -): ClassNameLookup => { - if (!symbolTable) return localNames; +const createClassNameLookup = (localNames: Set, model?: SemanticModel): ClassNameLookup => { + if (!model) return localNames; const memo = new Map(); return { @@ -428,7 +425,7 @@ const createClassNameLookup = ( if (localNames.has(name)) return true; const cached = memo.get(name); if (cached !== undefined) return cached; - const result = symbolTable + const result = model.types .lookupClassByName(name) .some((def) => def.type === 'Class' || def.type === 'Enum' || def.type === 'Struct'); memo.set(name, result); @@ -481,20 +478,20 @@ const CLASS_LIKE_TYPES = new Set(['Class', 'Struct', 'Interface']); type ClassDefRef = { nodeId: string; type: string; filePath: string }; const lookupClassDefsByName = ( - symbolTable: SymbolTable, + model: SemanticModel, name: string, allowedTypes: ReadonlySet = CLASS_LIKE_TYPES, -): ClassDefRef[] => symbolTable.lookupClassByName(name).filter((d) => allowedTypes.has(d.type)); +): ClassDefRef[] => model.types.lookupClassByName(name).filter((d) => allowedTypes.has(d.type)); /** Memoize class definition lookups during fixpoint iteration. * SymbolTable is immutable during type resolution, so results never change. * Eliminates redundant array allocations + filter scans across iterations. */ -const createClassDefCache = (symbolTable?: SymbolTable) => { +const createClassDefCache = (model?: SemanticModel) => { const cache = new Map(); return (typeName: string) => { let result = cache.get(typeName); if (result === undefined) { - result = symbolTable ? lookupClassDefsByName(symbolTable, typeName) : []; + result = model ? lookupClassDefsByName(model, typeName) : []; cache.set(typeName, result); } return result; @@ -615,22 +612,22 @@ const resolveFieldType = ( receiver: string, field: string, scopeEnv: ReadonlyMap, - symbolTable?: SymbolTable, + model?: SemanticModel, getClassDefs?: (typeName: string) => ClassDefRef[], parentMap?: ReadonlyMap, ): string | undefined => { - if (!symbolTable) return undefined; + if (!model) return undefined; const receiverType = scopeEnv.get(receiver); if (!receiverType) return undefined; - const lookup = getClassDefs ?? ((name: string) => lookupClassDefsByName(symbolTable, name)); + const lookup = getClassDefs ?? ((name: string) => lookupClassDefsByName(model, name)); const classDefs = lookup(receiverType); if (classDefs.length !== 1) return undefined; // Direct lookup first - const fieldDef = symbolTable.lookupFieldByOwner(classDefs[0].nodeId, field); + const fieldDef = model.fields.lookupFieldByOwner(classDefs[0].nodeId, field); if (fieldDef?.declaredType) return extractReturnTypeName(fieldDef.declaredType); // MRO parent chain walking on miss const inherited = walkParentChain(receiverType, parentMap, lookup, (nodeId) => { - const f = symbolTable.lookupFieldByOwner(nodeId, field); + const f = model.fields.lookupFieldByOwner(nodeId, field); return f?.declaredType ? extractReturnTypeName(f.declaredType) : undefined; }); return inherited; @@ -644,30 +641,30 @@ const resolveMethodReturnType = ( receiver: string, method: string, scopeEnv: ReadonlyMap, - symbolTable?: SymbolTable, + model?: SemanticModel, getClassDefs?: (typeName: string) => ClassDefRef[], parentMap?: ReadonlyMap, ): string | undefined => { - if (!symbolTable) return undefined; + if (!model) return undefined; let receiverType = scopeEnv.get(receiver); // When substituteThisReceiver replaced $this/self with the enclosing class name, // the receiver IS the type — look it up directly as a class name. if (!receiverType) { - const lookup = getClassDefs ?? ((name: string) => lookupClassDefsByName(symbolTable, name)); + const lookup = getClassDefs ?? ((name: string) => lookupClassDefsByName(model, name)); if (lookup(receiver).length > 0) receiverType = receiver; } if (!receiverType) return undefined; - const lookup = getClassDefs ?? ((name: string) => lookupClassDefsByName(symbolTable, name)); + const lookup = getClassDefs ?? ((name: string) => lookupClassDefsByName(model, name)); const classDefs = lookup(receiverType); if (classDefs.length === 0) return undefined; // Direct lookup first const directMethodLookups = classDefs.map((d) => ({ classDef: d, - methodDef: symbolTable.lookupMethodByOwner(d.nodeId, method), + methodDef: model.methods.lookupMethodByOwner(d.nodeId, method), })); const hasAmbiguousDirectLookup = directMethodLookups.some(({ classDef, methodDef }) => { if (methodDef) return false; - return symbolTable + return model.symbols .lookupExactAll(classDef.filePath, method) .some((d) => d.ownerId === classDef.nodeId); }); @@ -681,7 +678,7 @@ const resolveMethodReturnType = ( // MRO parent chain walking on miss if (methods.length === 0) { const inherited = walkParentChain(receiverType, parentMap, lookup, (nodeId) => { - const parentMethod = symbolTable.lookupMethodByOwner(nodeId, method); + const parentMethod = model.methods.lookupMethodByOwner(nodeId, method); if (!parentMethod?.returnType) return undefined; return extractReturnTypeName(parentMethod.returnType); }); @@ -707,11 +704,11 @@ const resolveFixpointBindings = ( pendingItems: Array<{ scope: string } & PendingAssignment>, env: TypeEnv, returnTypeLookup: ReturnTypeLookup, - symbolTable?: SymbolTable, + model?: SemanticModel, parentMap?: ReadonlyMap, ): void => { if (pendingItems.length === 0) return; - const getClassDefs = createClassDefCache(symbolTable); + const getClassDefs = createClassDefCache(model); const resolved = new Set(); for (let iter = 0; iter < MAX_FIXPOINT_ITERATIONS; iter++) { let changed = false; @@ -740,7 +737,7 @@ const resolveFixpointBindings = ( item.receiver, item.field, scopeEnv, - symbolTable, + model, getClassDefs, parentMap, ); @@ -750,7 +747,7 @@ const resolveFixpointBindings = ( item.receiver, item.method, scopeEnv, - symbolTable, + model, getClassDefs, parentMap, ); @@ -785,7 +782,7 @@ const resolveFixpointBindings = ( * Uses an options object to allow future extensions without positional parameter sprawl. */ export interface BuildTypeEnvOptions { - symbolTable?: SymbolTable; + model?: SemanticModel; parentMap?: ReadonlyMap; /** Pre-resolved bindings from upstream files (Phase 14). * Seeded into FILE_SCOPE after walk() for names with no local binding. @@ -837,7 +834,7 @@ export const buildTypeEnv = ( enclosingClassNameCache.clear(); enclosingParentClassNameCache.clear(); - const symbolTable = options?.symbolTable; + const model = options?.model; const parentMap = options?.parentMap; const extractFuncNameHook = options?.extractFunctionName; const env: TypeEnv = new Map(); @@ -848,7 +845,7 @@ export const buildTypeEnv = ( // e.g., `Animal a = new Dog()` → constructorTypeMap.set('func@42\0a', 'Dog') const constructorTypeMap = new Map(); const localClassNames = new Set(); - const classNames = createClassNameLookup(localClassNames, symbolTable); + const classNames = createClassNameLookup(localClassNames, model); const provider = getProvider(language); const config = provider.typeConfig; const bindings: ConstructorBinding[] = []; @@ -856,29 +853,47 @@ export const buildTypeEnv = ( // Build ReturnTypeLookup: SymbolTable is authoritative when it has an unambiguous match. // Cross-file importedReturnTypes are consulted ONLY when SymbolTable has 0 matches. // Ambiguous (2+) → undefined, no cross-file fallback (conservative, local-first principle). + // Post-A4 Unit 4: callableByName no longer holds Method/Constructor, so + // for-loop binding inference must also consult methodsByName to find + // return types on class methods (e.g. `user.getItems()` iteration). + // Take `model` as an explicit argument so the non-null precondition + // is visible at the type level. Callers must enter these via an + // `if (model)` guard on their side and pass the narrowed reference. + const getCallableUnionCount = (m: SemanticModel, callee: string): number => { + return ( + m.symbols.lookupCallableByName(callee).length + m.methods.lookupMethodByName(callee).length + ); + }; + const getFirstCallable = (m: SemanticModel, callee: string) => { + const free = m.symbols.lookupCallableByName(callee); + if (free.length > 0) return free[0]; + const methods = m.methods.lookupMethodByName(callee); + return methods.length > 0 ? methods[0] : undefined; + }; + const returnTypeLookup: ReturnTypeLookup = { lookupReturnType(callee: string): string | undefined { // SymbolTable is authoritative when it has an unambiguous match - if (symbolTable) { + if (model) { if (provider.isBuiltInName(callee)) return undefined; - const callables = symbolTable.lookupCallableByName(callee); - if (callables.length === 1) { - const rawReturn = callables[0].returnType; + const count = getCallableUnionCount(model, callee); + if (count === 1) { + const rawReturn = getFirstCallable(model, callee)?.returnType; if (rawReturn) return extractReturnTypeName(rawReturn); } // Ambiguous (2+) → return undefined (conservative, no cross-file fallback) - if (callables.length > 1) return undefined; + if (count > 1) return undefined; } // No match (0 results or no symbolTable) → fall back to cross-file return options?.importedReturnTypes?.get(callee); }, lookupRawReturnType(callee: string): string | undefined { - if (symbolTable) { + if (model) { if (provider.isBuiltInName(callee)) return undefined; - const callables = symbolTable.lookupCallableByName(callee); - if (callables.length === 1) return callables[0].returnType; + const count = getCallableUnionCount(model, callee); + if (count === 1) return getFirstCallable(model, callee)?.returnType; // Ambiguous (2+) → return undefined (conservative, no cross-file fallback) - if (callables.length > 1) return undefined; + if (count > 1) return undefined; } // Cross-file fallback uses importedRawReturnTypes (raw declared types, e.g., 'User[]') // NOT importedReturnTypes (which contains processed/simple types via extractReturnTypeName) @@ -1229,7 +1244,7 @@ export const buildTypeEnv = ( seedImportedBindings(env, options.importedBindings); } - resolveFixpointBindings(pendingItems, env, returnTypeLookup, symbolTable, parentMap); + resolveFixpointBindings(pendingItems, env, returnTypeLookup, model, parentMap); // Post-fixpoint for-loop replay (Phase 10 / ex-9B loop-fixpoint bridge): // For-loop nodes whose iterables were unresolved at walk-time may now be @@ -1256,7 +1271,7 @@ export const buildTypeEnv = ( return scopeEnv && !scopeEnv.has(item.lhs); }); if (unresolvedBefore.length > 0) { - resolveFixpointBindings(unresolvedBefore, env, returnTypeLookup, symbolTable); + resolveFixpointBindings(unresolvedBefore, env, returnTypeLookup, model); } } diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index f229b4cad..32dd32aec 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -15,7 +15,8 @@ import { createRequire } from 'node:module'; import { SupportedLanguages } from 'gitnexus-shared'; import { getProvider } from '../languages/index.js'; import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from '../constants.js'; -import type { SymbolTable } from '../symbol-table.js'; +import type { SymbolTableReader } from '../model/symbol-table.js'; +import type { ExtractedHeritage } from '../model/heritage-map.js'; /** Language grammar type accepted by Parser.setLanguage(). */ type TreeSitterLanguage = Parameters[0]; @@ -181,13 +182,8 @@ export interface ExtractedAssignment { receiverTypeName?: string; } -export interface ExtractedHeritage { - filePath: string; - className: string; - parentName: string; - /** 'extends' | 'implements' | 'trait-impl' | 'include' | 'extend' | 'prepend' */ - kind: string; -} +// `ExtractedHeritage` now lives in `../model/heritage-map.ts` and is +// re-exported at the top of this file. export interface ExtractedRoute { filePath: string; @@ -459,14 +455,20 @@ function findClassNodeByQualifiedName(node: SyntaxNode): SyntaxNode | null { /** * Minimal no-op SymbolTable stub for FieldExtractorContext in the worker. - * Field extraction only uses symbolTable.lookupExactAll for optional type resolution — - * returning [] causes the extractor to use the raw type string, which is fine for us. + * Field extraction only uses symbolTable.lookupExactAll for optional type + * resolution — returning [] causes the extractor to use the raw type + * string, which is fine for us. Every other method is a no-op so the + * stub remains safe if a future FieldExtractor consults it through the + * full {@link SymbolTableReader} surface. */ -const NOOP_SYMBOL_TABLE = { - lookupExactAll: () => [], +const NOOP_SYMBOL_TABLE: SymbolTableReader = { lookupExact: () => undefined, lookupExactFull: () => undefined, -} as unknown as SymbolTable; + lookupExactAll: () => [], + lookupCallableByName: () => [], + getFiles: () => [][Symbol.iterator](), + getStats: () => ({ fileCount: 0 }), +}; /** * Get (or extract and cache) field info for a class node. diff --git a/gitnexus/test/integration/ignore-and-skip-e2e.test.ts b/gitnexus/test/integration/ignore-and-skip-e2e.test.ts index e244a094c..da5c49042 100644 --- a/gitnexus/test/integration/ignore-and-skip-e2e.test.ts +++ b/gitnexus/test/integration/ignore-and-skip-e2e.test.ts @@ -8,7 +8,7 @@ import { } from '../../src/core/ingestion/filesystem-walker.js'; import { processParsing } from '../../src/core/ingestion/parsing-processor.js'; import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; -import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js'; +import { createSymbolTable } from '../../src/core/ingestion/model/symbol-table.js'; import { createASTCache } from '../../src/core/ingestion/ast-cache.js'; import { isLanguageAvailable } from '../../src/core/tree-sitter/parser-loader.js'; import { SupportedLanguages } from '../../src/config/supported-languages.js'; diff --git a/gitnexus/test/integration/qualified-class-lookups.test.ts b/gitnexus/test/integration/qualified-class-lookups.test.ts index 1d6fa4fc6..eb628b73a 100644 --- a/gitnexus/test/integration/qualified-class-lookups.test.ts +++ b/gitnexus/test/integration/qualified-class-lookups.test.ts @@ -1,13 +1,18 @@ import { describe, expect, it } from 'vitest'; import { createASTCache } from '../../src/core/ingestion/ast-cache.js'; import { processParsing } from '../../src/core/ingestion/parsing-processor.js'; -import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js'; +import { createSemanticModel } from '../../src/core/ingestion/model/semantic-model.js'; import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; describe('qualified class lookups', () => { it('derives canonical dot-separated names from namespaces, packages, and modules', async () => { const graph = createKnowledgeGraph(); - const symbolTable = createSymbolTable(); + const model = createSemanticModel(); + // model.symbols is the SymbolTable leaf that processParsing writes into. + // Fan-out writes still reach model.types / model.methods / model.fields + // via SemanticModel's wrappedAdd — this alias is purely for convenience + // at call sites that want the SymbolTable-shaped interface. + const symbolTable = model.symbols; const astCache = createASTCache(); await processParsing( @@ -34,33 +39,38 @@ describe('qualified class lookups', () => { astCache, ); - const userMatches = symbolTable.lookupClassByName('User'); + const userMatches = model.types.lookupClassByName('User'); expect(userMatches).toHaveLength(3); expect(userMatches.map((match) => match.qualifiedName).sort()).toEqual( ['Admin.User', 'Data.Auth.User', 'Services.Auth.User'].sort(), ); - const servicesUser = symbolTable.lookupClassByQualifiedName('Services.Auth.User'); + const servicesUser = model.types.lookupClassByQualifiedName('Services.Auth.User'); expect(servicesUser).toHaveLength(1); expect(servicesUser[0].filePath).toBe('src/Services/User.cs'); expect(servicesUser[0].qualifiedName).toBe('Services.Auth.User'); - const dataUser = symbolTable.lookupClassByQualifiedName('Data.Auth.User'); + const dataUser = model.types.lookupClassByQualifiedName('Data.Auth.User'); expect(dataUser).toHaveLength(1); expect(dataUser[0].filePath).toBe('src/Data/User.cs'); - const javaConfig = symbolTable.lookupClassByQualifiedName('com.example.models.Config'); + const javaConfig = model.types.lookupClassByQualifiedName('com.example.models.Config'); expect(javaConfig).toHaveLength(1); expect(javaConfig[0].qualifiedName).toBe('com.example.models.Config'); - const rubyUser = symbolTable.lookupClassByQualifiedName('Admin.User'); + const rubyUser = model.types.lookupClassByQualifiedName('Admin.User'); expect(rubyUser).toHaveLength(1); expect(rubyUser[0].qualifiedName).toBe('Admin.User'); }); it('falls back to the simple name for top-level class-like symbols', async () => { const graph = createKnowledgeGraph(); - const symbolTable = createSymbolTable(); + const model = createSemanticModel(); + // model.symbols is the SymbolTable leaf that processParsing writes into. + // Fan-out writes still reach model.types / model.methods / model.fields + // via SemanticModel's wrappedAdd — this alias is purely for convenience + // at call sites that want the SymbolTable-shaped interface. + const symbolTable = model.symbols; const astCache = createASTCache(); await processParsing( @@ -70,11 +80,11 @@ describe('qualified class lookups', () => { astCache, ); - const simpleMatches = symbolTable.lookupClassByName('User'); + const simpleMatches = model.types.lookupClassByName('User'); expect(simpleMatches).toHaveLength(1); expect(simpleMatches[0].qualifiedName).toBe('User'); - const matches = symbolTable.lookupClassByQualifiedName('User'); + const matches = model.types.lookupClassByQualifiedName('User'); expect(matches).toHaveLength(1); expect(matches[0].qualifiedName).toBe('User'); }); diff --git a/gitnexus/test/unit/call-form.test.ts b/gitnexus/test/unit/call-form.test.ts index 17e42897e..01b707c13 100644 --- a/gitnexus/test/unit/call-form.test.ts +++ b/gitnexus/test/unit/call-form.test.ts @@ -4,7 +4,7 @@ import { extractReceiverName, } from '../../src/core/ingestion/utils/call-analysis.js'; import type { SyntaxNode } from '../../src/core/ingestion/utils/ast-helpers.js'; -import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js'; +import { createSymbolTable } from '../../src/core/ingestion/model/symbol-table.js'; import Parser from 'tree-sitter'; import TypeScript from 'tree-sitter-typescript'; import Python from 'tree-sitter-python'; @@ -452,9 +452,13 @@ describe('ownerId on SymbolDefinition', () => { expect(def!.ownerId).toBeUndefined(); }); - it('propagates ownerId through lookupCallableByName', () => { + it('propagates ownerId through a free Function registration', () => { + // Post-A4 Unit 4, Method is no longer in FREE_CALLABLE_TYPES so this test + // exercises ownerId propagation through the free-callable index using + // a Function label. Method-with-ownerId propagation is covered via + // methodsByName in method-registry.test.ts. const st = createSymbolTable(); - st.add('src/foo.ts', 'save', 'Method:src/foo.ts:save', 'Method', { + st.add('src/foo.ts', 'save', 'Function:src/foo.ts:save', 'Function', { ownerId: 'Class:src/foo.ts:User', }); diff --git a/gitnexus/test/unit/call-processor.test.ts b/gitnexus/test/unit/call-processor.test.ts index e69250ceb..4bbc5d4b0 100644 --- a/gitnexus/test/unit/call-processor.test.ts +++ b/gitnexus/test/unit/call-processor.test.ts @@ -7,22 +7,22 @@ import { extractConsumerAccessedKeys, processNextjsFetchRoutes, } from '../../src/core/ingestion/call-processor.js'; -import { buildHeritageMap } from '../../src/core/ingestion/heritage-map.js'; +import { buildHeritageMap } from '../../src/core/ingestion/model/heritage-map.js'; import { createASTCache } from '../../src/core/ingestion/ast-cache.js'; import { extractReturnTypeName } from '../../src/core/ingestion/type-extractors/shared.js'; import { createResolutionContext, type ResolutionContext, -} from '../../src/core/ingestion/resolution-context.js'; +} from '../../src/core/ingestion/model/resolution-context.js'; import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; import { BindingAccumulator } from '../../src/core/ingestion/binding-accumulator.js'; import type { ExtractedAssignment, ExtractedCall, ExtractedFetchCall, - ExtractedHeritage, FileConstructorBindings, } from '../../src/core/ingestion/workers/parse-worker.js'; +import type { ExtractedHeritage } from '../../src/core/ingestion/model/heritage-map.js'; describe('processCallsFromExtracted', () => { let graph: ReturnType; @@ -34,7 +34,7 @@ describe('processCallsFromExtracted', () => { }); it('creates CALLS relationship for same-file resolution', async () => { - ctx.symbols.add('src/index.ts', 'helper', 'Function:src/index.ts:helper', 'Function'); + ctx.model.symbols.add('src/index.ts', 'helper', 'Function:src/index.ts:helper', 'Function'); const calls: ExtractedCall[] = [ { @@ -55,7 +55,7 @@ describe('processCallsFromExtracted', () => { }); it('creates CALLS relationship for import-resolved resolution', async () => { - ctx.symbols.add('src/utils.ts', 'format', 'Function:src/utils.ts:format', 'Function'); + ctx.model.symbols.add('src/utils.ts', 'format', 'Function:src/utils.ts:format', 'Function'); ctx.importMap.set('src/index.ts', new Set(['src/utils.ts'])); const calls: ExtractedCall[] = [ @@ -75,7 +75,12 @@ describe('processCallsFromExtracted', () => { }); it('resolves unique global symbol with moderate confidence', async () => { - ctx.symbols.add('src/other.ts', 'uniqueFunc', 'Function:src/other.ts:uniqueFunc', 'Function'); + ctx.model.symbols.add( + 'src/other.ts', + 'uniqueFunc', + 'Function:src/other.ts:uniqueFunc', + 'Function', + ); const calls: ExtractedCall[] = [ { @@ -94,8 +99,8 @@ describe('processCallsFromExtracted', () => { }); it('refuses ambiguous global symbols — no CALLS edge created', async () => { - ctx.symbols.add('src/a.ts', 'render', 'Function:src/a.ts:render', 'Function'); - ctx.symbols.add('src/b.ts', 'render', 'Function:src/b.ts:render', 'Function'); + ctx.model.symbols.add('src/a.ts', 'render', 'Function:src/a.ts:render', 'Function'); + ctx.model.symbols.add('src/b.ts', 'render', 'Function:src/b.ts:render', 'Function'); const calls: ExtractedCall[] = [ { @@ -125,7 +130,7 @@ describe('processCallsFromExtracted', () => { }); it('refuses non-callable symbols even when the name resolves', async () => { - ctx.symbols.add('src/index.ts', 'Widget', 'Class:src/index.ts:Widget', 'Class'); + ctx.model.symbols.add('src/index.ts', 'Widget', 'Class:src/index.ts:Widget', 'Class'); const calls: ExtractedCall[] = [ { @@ -140,7 +145,7 @@ describe('processCallsFromExtracted', () => { }); it('refuses CALLS edges to Interface symbols', async () => { - ctx.symbols.add( + ctx.model.symbols.add( 'src/types.ts', 'Serializable', 'Interface:src/types.ts:Serializable', @@ -161,7 +166,7 @@ describe('processCallsFromExtracted', () => { }); it('refuses CALLS edges to Enum symbols', async () => { - ctx.symbols.add('src/status.ts', 'Status', 'Enum:src/status.ts:Status', 'Enum'); + ctx.model.symbols.add('src/status.ts', 'Status', 'Enum:src/status.ts:Status', 'Enum'); ctx.importMap.set('src/index.ts', new Set(['src/status.ts'])); const calls: ExtractedCall[] = [ @@ -177,8 +182,8 @@ describe('processCallsFromExtracted', () => { }); it('prefers same-file over import-resolved', async () => { - ctx.symbols.add('src/index.ts', 'render', 'Function:src/index.ts:render', 'Function'); - ctx.symbols.add('src/utils.ts', 'render', 'Function:src/utils.ts:render', 'Function'); + ctx.model.symbols.add('src/index.ts', 'render', 'Function:src/index.ts:render', 'Function'); + ctx.model.symbols.add('src/utils.ts', 'render', 'Function:src/utils.ts:render', 'Function'); ctx.importMap.set('src/index.ts', new Set(['src/utils.ts'])); const calls: ExtractedCall[] = [ @@ -198,8 +203,8 @@ describe('processCallsFromExtracted', () => { }); it('handles multiple calls from the same file', async () => { - ctx.symbols.add('src/index.ts', 'foo', 'Function:src/index.ts:foo', 'Function'); - ctx.symbols.add('src/index.ts', 'bar', 'Function:src/index.ts:bar', 'Function'); + ctx.model.symbols.add('src/index.ts', 'foo', 'Function:src/index.ts:foo', 'Function'); + ctx.model.symbols.add('src/index.ts', 'bar', 'Function:src/index.ts:bar', 'Function'); const calls: ExtractedCall[] = [ { filePath: 'src/index.ts', calledName: 'foo', sourceId: 'Function:src/index.ts:main' }, @@ -211,10 +216,10 @@ describe('processCallsFromExtracted', () => { }); it('uses arity to disambiguate import-scoped callable candidates', async () => { - ctx.symbols.add('src/logger.ts', 'log', 'Function:src/logger.ts:log', 'Function', { + ctx.model.symbols.add('src/logger.ts', 'log', 'Function:src/logger.ts:log', 'Function', { parameterCount: 0, }); - ctx.symbols.add('src/formatter.ts', 'log', 'Function:src/formatter.ts:log', 'Function', { + ctx.model.symbols.add('src/formatter.ts', 'log', 'Function:src/formatter.ts:log', 'Function', { parameterCount: 1, }); ctx.importMap.set('src/index.ts', new Set(['src/logger.ts', 'src/formatter.ts'])); @@ -237,10 +242,10 @@ describe('processCallsFromExtracted', () => { }); it('refuses ambiguous call targets when arity does not produce a unique match', async () => { - ctx.symbols.add('src/logger.ts', 'log', 'Function:src/logger.ts:log', 'Function', { + ctx.model.symbols.add('src/logger.ts', 'log', 'Function:src/logger.ts:log', 'Function', { parameterCount: 1, }); - ctx.symbols.add('src/formatter.ts', 'log', 'Function:src/formatter.ts:log', 'Function', { + ctx.model.symbols.add('src/formatter.ts', 'log', 'Function:src/formatter.ts:log', 'Function', { parameterCount: 1, }); ctx.importMap.set('src/index.ts', new Set(['src/logger.ts', 'src/formatter.ts'])); @@ -259,7 +264,7 @@ describe('processCallsFromExtracted', () => { }); it('calls progress callback', async () => { - ctx.symbols.add('src/index.ts', 'foo', 'Function:src/index.ts:foo', 'Function'); + ctx.model.symbols.add('src/index.ts', 'foo', 'Function:src/index.ts:foo', 'Function'); const calls: ExtractedCall[] = [ { filePath: 'src/index.ts', calledName: 'foo', sourceId: 'Function:src/index.ts:main' }, @@ -279,7 +284,7 @@ describe('processCallsFromExtracted', () => { // ---- Constructor-aware resolution (Phase 2) ---- it('resolves constructor call to Class when no Constructor node exists', async () => { - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); ctx.importMap.set('src/index.ts', new Set(['src/models.ts'])); const calls: ExtractedCall[] = [ @@ -300,10 +305,16 @@ describe('processCallsFromExtracted', () => { }); it('resolves constructor call to Constructor node over Class node', async () => { - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'User', 'Constructor:src/models.ts:User', 'Constructor', { - parameterCount: 1, - }); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add( + 'src/models.ts', + 'User', + 'Constructor:src/models.ts:User', + 'Constructor', + { + parameterCount: 1, + }, + ); ctx.importMap.set('src/index.ts', new Set(['src/models.ts'])); const calls: ExtractedCall[] = [ @@ -324,7 +335,7 @@ describe('processCallsFromExtracted', () => { }); it('refuses Class target without callForm=constructor (existing behavior)', async () => { - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); ctx.importMap.set('src/index.ts', new Set(['src/models.ts'])); const calls: ExtractedCall[] = [ @@ -342,7 +353,7 @@ describe('processCallsFromExtracted', () => { }); it('constructor call falls back to callable types when no Constructor/Class found', async () => { - ctx.symbols.add('src/utils.ts', 'Widget', 'Function:src/utils.ts:Widget', 'Function'); + ctx.model.symbols.add('src/utils.ts', 'Widget', 'Function:src/utils.ts:Widget', 'Function'); ctx.importMap.set('src/index.ts', new Set(['src/utils.ts'])); const calls: ExtractedCall[] = [ @@ -362,12 +373,24 @@ describe('processCallsFromExtracted', () => { }); it('constructor arity filtering narrows overloaded constructors', async () => { - ctx.symbols.add('src/models.ts', 'User', 'Constructor:src/models.ts:User(0)', 'Constructor', { - parameterCount: 0, - }); - ctx.symbols.add('src/models.ts', 'User', 'Constructor:src/models.ts:User(2)', 'Constructor', { - parameterCount: 2, - }); + ctx.model.symbols.add( + 'src/models.ts', + 'User', + 'Constructor:src/models.ts:User(0)', + 'Constructor', + { + parameterCount: 0, + }, + ); + ctx.model.symbols.add( + 'src/models.ts', + 'User', + 'Constructor:src/models.ts:User(2)', + 'Constructor', + { + parameterCount: 2, + }, + ); ctx.importMap.set('src/index.ts', new Set(['src/models.ts'])); const calls: ExtractedCall[] = [ @@ -388,10 +411,10 @@ describe('processCallsFromExtracted', () => { }); it('cannot discriminate same-arity overloads by parameter type (known limitation)', async () => { - ctx.symbols.add('src/UserDao.ts', 'save', 'Function:src/UserDao.ts:save', 'Function', { + ctx.model.symbols.add('src/UserDao.ts', 'save', 'Function:src/UserDao.ts:save', 'Function', { parameterCount: 1, }); - ctx.symbols.add('src/RepoDao.ts', 'save', 'Function:src/RepoDao.ts:save', 'Function', { + ctx.model.symbols.add('src/RepoDao.ts', 'save', 'Function:src/RepoDao.ts:save', 'Function', { parameterCount: 1, }); ctx.importMap.set('src/index.ts', new Set(['src/UserDao.ts', 'src/RepoDao.ts'])); @@ -414,11 +437,11 @@ describe('processCallsFromExtracted', () => { it('return type inference: binds variable to return type of callee', async () => { // getUser() returns User, and User has a save() method - ctx.symbols.add('src/utils.ts', 'getUser', 'Function:src/utils.ts:getUser', 'Function', { + ctx.model.symbols.add('src/utils.ts', 'getUser', 'Function:src/utils.ts:getUser', 'Function', { returnType: 'User', }); - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User', }); ctx.importMap.set('src/index.ts', new Set(['src/utils.ts', 'src/models.ts'])); @@ -450,11 +473,11 @@ describe('processCallsFromExtracted', () => { }); it('return type inference: unwraps Promise to User', async () => { - ctx.symbols.add('src/api.ts', 'fetchUser', 'Function:src/api.ts:fetchUser', 'Function', { + ctx.model.symbols.add('src/api.ts', 'fetchUser', 'Function:src/api.ts:fetchUser', 'Function', { returnType: 'Promise', }); - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User', }); ctx.importMap.set('src/index.ts', new Set(['src/api.ts', 'src/models.ts'])); @@ -484,9 +507,15 @@ describe('processCallsFromExtracted', () => { }); it('return type inference: skips when return type is primitive', async () => { - ctx.symbols.add('src/utils.ts', 'getCount', 'Function:src/utils.ts:getCount', 'Function', { - returnType: 'number', - }); + ctx.model.symbols.add( + 'src/utils.ts', + 'getCount', + 'Function:src/utils.ts:getCount', + 'Function', + { + returnType: 'number', + }, + ); ctx.importMap.set('src/index.ts', new Set(['src/utils.ts'])); const constructorBindings: FileConstructorBindings[] = [ @@ -514,10 +543,10 @@ describe('processCallsFromExtracted', () => { }); it('return type inference: skips ambiguous callees (multiple definitions)', async () => { - ctx.symbols.add('src/a.ts', 'getData', 'Function:src/a.ts:getData', 'Function', { + ctx.model.symbols.add('src/a.ts', 'getData', 'Function:src/a.ts:getData', 'Function', { returnType: 'User', }); - ctx.symbols.add('src/b.ts', 'getData', 'Function:src/b.ts:getData', 'Function', { + ctx.model.symbols.add('src/b.ts', 'getData', 'Function:src/b.ts:getData', 'Function', { returnType: 'Repo', }); @@ -547,8 +576,8 @@ describe('processCallsFromExtracted', () => { it('return type inference: prefers constructor binding over return type', async () => { // If the callee IS a class, constructor binding wins (existing behavior) - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User', }); ctx.importMap.set('src/index.ts', new Set(['src/models.ts'])); @@ -583,11 +612,11 @@ describe('processCallsFromExtracted', () => { // getUser is in the SymbolTable but WITHOUT a returnType (e.g., inferred return type // that the structure processor did not capture). The BindingAccumulator for // src/api.ts has getUser → User as a file-scope binding. - ctx.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function', { + ctx.model.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function', { // No returnType provided — simulates a structure-processor gap }); - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User', }); ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts', 'src/models.ts'])); @@ -635,11 +664,11 @@ describe('processCallsFromExtracted', () => { it('Phase 9: BindingAccumulator fallback — SymbolTable return type takes precedence', async () => { // When the SymbolTable DOES have a returnType, the accumulator should not override it. - ctx.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function', { + ctx.model.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function', { returnType: 'User', }); - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User', }); ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts', 'src/models.ts'])); @@ -688,8 +717,8 @@ describe('processCallsFromExtracted', () => { it('Phase 9: BindingAccumulator fallback — skips when callee not in namedImportMap', async () => { // Callee is not tracked in namedImportMap (e.g. a local function), so accumulator // lookup is skipped. No CALLS edge expected since there is no binding source. - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User', }); // No namedImportMap entry for getUser @@ -708,8 +737,8 @@ describe('processCallsFromExtracted', () => { // but also exists on multiple types so fuzzy lookup is ambiguous without a // receiver type. Add a second owner so that unconstrained fuzzy lookup won't // match unambiguously. - ctx.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class'); - ctx.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', { + ctx.model.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class'); + ctx.model.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', { ownerId: 'Class:src/other.ts:OtherClass', }); @@ -741,9 +770,9 @@ describe('processCallsFromExtracted', () => { it('Phase 9: BindingAccumulator fallback — unwraps Promise type from accumulator', async () => { // Accumulator stores raw type with Promise wrapper — extractReturnTypeName should unwrap it. - ctx.symbols.add('src/api.ts', 'fetchUser', 'Function:src/api.ts:fetchUser', 'Function'); - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ctx.model.symbols.add('src/api.ts', 'fetchUser', 'Function:src/api.ts:fetchUser', 'Function'); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User', }); ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts', 'src/models.ts'])); @@ -790,7 +819,7 @@ describe('processCallsFromExtracted', () => { it('Phase 9: BindingAccumulator fallback — skips primitive types from accumulator', async () => { // Accumulator stores a primitive type — should not create a CALLS edge. - ctx.symbols.add('src/api.ts', 'getCount', 'Function:src/api.ts:getCount', 'Function'); + ctx.model.symbols.add('src/api.ts', 'getCount', 'Function:src/api.ts:getCount', 'Function'); ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts'])); ctx.namedImportMap.set( 'src/consumer.ts', @@ -834,9 +863,9 @@ describe('processCallsFromExtracted', () => { it('Phase 9: BindingAccumulator fallback — handles aliased import (localName ≠ exportedName)', async () => { // import { getUser as fetchUser } from './api' — namedImportMap maps localName to exportedName - ctx.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function'); - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ctx.model.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function'); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User', }); ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts', 'src/models.ts'])); @@ -889,16 +918,21 @@ describe('processCallsFromExtracted', () => { // The local definition has no returnType annotation. The accumulator has // getUser → User from api.ts. The fallback must NOT fire because the // same-file definition is authoritative (tier: 'same-file'). - ctx.symbols.add('src/consumer.ts', 'getUser', 'Function:src/consumer.ts:getUser', 'Function'); - ctx.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function'); + ctx.model.symbols.add( + 'src/consumer.ts', + 'getUser', + 'Function:src/consumer.ts:getUser', + 'Function', + ); + ctx.model.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function'); // Place User and save in non-imported files so import-scoped member-call resolution // can't resolve save without a receiver type. - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User', }); - ctx.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class'); - ctx.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', { + ctx.model.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class'); + ctx.model.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', { ownerId: 'Class:src/other.ts:OtherClass', }); // Only import api.ts — NOT models.ts, so save can't be found via import scope. @@ -952,19 +986,19 @@ describe('processCallsFromExtracted', () => { // x has no receiver type at all, and save is ambiguous (two owners) → 0 edges. // Either way, no CALLS edge. But we verify the accumulator's wrong type did NOT leak // by checking that no ACCESSES edge to BadType is created. - ctx.symbols.add('src/api-v1.ts', 'getUser', 'Function:src/api-v1.ts:getUser', 'Function'); - ctx.symbols.add('src/api-v2.ts', 'getUser', 'Function:src/api-v2.ts:getUser', 'Function'); - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ctx.model.symbols.add('src/api-v1.ts', 'getUser', 'Function:src/api-v1.ts:getUser', 'Function'); + ctx.model.symbols.add('src/api-v2.ts', 'getUser', 'Function:src/api-v2.ts:getUser', 'Function'); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User', }); - ctx.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class'); - ctx.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', { + ctx.model.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class'); + ctx.model.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', { ownerId: 'Class:src/other.ts:OtherClass', }); // BadType has no methods — if the accumulator wrongly types x as BadType, // the receiver type is set but save won't resolve at all. - ctx.symbols.add('src/bad.ts', 'BadType', 'Class:src/bad.ts:BadType', 'Class'); + ctx.model.symbols.add('src/bad.ts', 'BadType', 'Class:src/bad.ts:BadType', 'Class'); ctx.importMap.set( 'src/consumer.ts', new Set(['src/api-v1.ts', 'src/api-v2.ts', 'src/models.ts']), @@ -1017,8 +1051,8 @@ describe('processCallsFromExtracted', () => { it('Phase 9 tier gating: no callable candidates but named import — fallback fires', async () => { // getUser is not in the SymbolTable at all (e.g. definition not parsed). // namedImportMap has the import, accumulator has the type. Fallback should fire. - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User', }); ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts', 'src/models.ts'])); @@ -1067,14 +1101,19 @@ describe('processCallsFromExtracted', () => { // consumer.ts has a local getUser() without returnType annotation. // No import of getUser exists. The accumulator has getUser → User from api.ts. // Tier is 'same-file' so fallback must NOT fire. - ctx.symbols.add('src/consumer.ts', 'getUser', 'Function:src/consumer.ts:getUser', 'Function'); - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ctx.model.symbols.add( + 'src/consumer.ts', + 'getUser', + 'Function:src/consumer.ts:getUser', + 'Function', + ); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User', }); // Add a second 'save' so fuzzy lookup is ambiguous without receiver type - ctx.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class'); - ctx.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', { + ctx.model.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class'); + ctx.model.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', { ownerId: 'Class:src/other.ts:OtherClass', }); @@ -1120,12 +1159,23 @@ describe('processCallsFromExtracted', () => { // User.save@100 and Repo.save@200 are two methods named "save" in different classes. // Each has a local variable "db" pointing to a different type. // Without @startIndex in the key, the second binding would overwrite the first. - ctx.symbols.add('src/db/Database.ts', 'Database', 'Class:src/db/Database.ts:Database', 'Class'); - ctx.symbols.add('src/db/Cache.ts', 'Cache', 'Class:src/db/Cache.ts:Cache', 'Class'); - ctx.symbols.add('src/db/Database.ts', 'query', 'Method:src/db/Database.ts:query', 'Method', { - ownerId: 'Class:src/db/Database.ts:Database', - }); - ctx.symbols.add('src/db/Cache.ts', 'query', 'Method:src/db/Cache.ts:query', 'Method', { + ctx.model.symbols.add( + 'src/db/Database.ts', + 'Database', + 'Class:src/db/Database.ts:Database', + 'Class', + ); + ctx.model.symbols.add('src/db/Cache.ts', 'Cache', 'Class:src/db/Cache.ts:Cache', 'Class'); + ctx.model.symbols.add( + 'src/db/Database.ts', + 'query', + 'Method:src/db/Database.ts:query', + 'Method', + { + ownerId: 'Class:src/db/Database.ts:Database', + }, + ); + ctx.model.symbols.add('src/db/Cache.ts', 'query', 'Method:src/db/Cache.ts:query', 'Method', { ownerId: 'Class:src/db/Cache.ts:Cache', }); ctx.importMap.set('src/models/User.ts', new Set(['src/db/Database.ts'])); @@ -1178,10 +1228,21 @@ describe('processCallsFromExtracted', () => { it('receiverKey collision: same scope funcName + same varName + same type resolves (non-ambiguous)', async () => { // Two save@* scopes both bind "db" to the same type — not ambiguous, should resolve. - ctx.symbols.add('src/db/Database.ts', 'Database', 'Class:src/db/Database.ts:Database', 'Class'); - ctx.symbols.add('src/db/Database.ts', 'query', 'Method:src/db/Database.ts:query', 'Method', { - ownerId: 'Class:src/db/Database.ts:Database', - }); + ctx.model.symbols.add( + 'src/db/Database.ts', + 'Database', + 'Class:src/db/Database.ts:Database', + 'Class', + ); + ctx.model.symbols.add( + 'src/db/Database.ts', + 'query', + 'Method:src/db/Database.ts:query', + 'Method', + { + ownerId: 'Class:src/db/Database.ts:Database', + }, + ); ctx.importMap.set('src/service.ts', new Set(['src/db/Database.ts'])); const constructorBindings: FileConstructorBindings[] = [ @@ -1213,12 +1274,23 @@ describe('processCallsFromExtracted', () => { it('receiverKey collision: same scope funcName + same varName + different types → ambiguous, no CALLS edge', async () => { // Two save@* scopes in the same file bind "db" to different types — truly ambiguous. - ctx.symbols.add('src/db/Database.ts', 'Database', 'Class:src/db/Database.ts:Database', 'Class'); - ctx.symbols.add('src/db/Cache.ts', 'Cache', 'Class:src/db/Cache.ts:Cache', 'Class'); - ctx.symbols.add('src/db/Database.ts', 'query', 'Method:src/db/Database.ts:query', 'Method', { - ownerId: 'Class:src/db/Database.ts:Database', - }); - ctx.symbols.add('src/db/Cache.ts', 'query', 'Method:src/db/Cache.ts:query', 'Method', { + ctx.model.symbols.add( + 'src/db/Database.ts', + 'Database', + 'Class:src/db/Database.ts:Database', + 'Class', + ); + ctx.model.symbols.add('src/db/Cache.ts', 'Cache', 'Class:src/db/Cache.ts:Cache', 'Class'); + ctx.model.symbols.add( + 'src/db/Database.ts', + 'query', + 'Method:src/db/Database.ts:query', + 'Method', + { + ownerId: 'Class:src/db/Database.ts:Database', + }, + ); + ctx.model.symbols.add('src/db/Cache.ts', 'query', 'Method:src/db/Cache.ts:query', 'Method', { ownerId: 'Class:src/db/Cache.ts:Cache', }); ctx.importMap.set('src/service.ts', new Set(['src/db/Database.ts', 'src/db/Cache.ts'])); @@ -1251,9 +1323,9 @@ describe('processCallsFromExtracted', () => { }); it('scope-aware bindings: same varName in different functions resolves to correct type', async () => { - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'Repo', 'Class:src/models.ts:Repo', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Function:src/models.ts:save', 'Function'); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'Repo', 'Class:src/models.ts:Repo', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Function:src/models.ts:save', 'Function'); ctx.importMap.set('src/index.ts', new Set(['src/models.ts'])); const constructorBindings: FileConstructorBindings[] = [ @@ -1312,12 +1384,16 @@ describe('processCalls — Phase P class lookup fallback', () => { const dogId = 'Class:models/Dog.java:Dog'; const fetchBallId = 'Method:models/Dog.java:fetchBall'; - ctx.symbols.add(contractFile, 'Pet', petId, 'Interface'); - ctx.symbols.add(dogFile, 'Dog', dogId, 'Class'); - ctx.symbols.add(dogFile, 'fetchBall', fetchBallId, 'Method', { ownerId: dogId }); + ctx.model.symbols.add(contractFile, 'Pet', petId, 'Interface'); + ctx.model.symbols.add(dogFile, 'Dog', dogId, 'Class'); + ctx.model.symbols.add(dogFile, 'fetchBall', fetchBallId, 'Method', { ownerId: dogId }); ctx.importMap.set(appFile, new Set([contractFile, dogFile])); - const classLookupSpy = vi.spyOn(ctx.symbols, 'lookupClassByName'); + // SM-20 wire-up: resolveMemberCall's constructor-override branch queries + // the model directly (ctx.model.types.lookupClassByName), not the + // legacy SymbolTable wrapper. Spy on the model method to preserve the + // test's intent: verify which class names are looked up during override. + const classLookupSpy = vi.spyOn(ctx.model.types, 'lookupClassByName'); await processCalls( graph, @@ -1358,16 +1434,26 @@ class App { const otherDogFile = 'models/OtherDog.java'; const petId = 'Interface:models/Pet.java:Pet'; - ctx.symbols.add(contractFile, 'Pet', petId, 'Interface'); - ctx.symbols.add(dogFile, 'fetchBall', 'Method:models/Dog.java:fetchBall', 'Method', { + ctx.model.symbols.add(contractFile, 'Pet', petId, 'Interface'); + ctx.model.symbols.add(dogFile, 'fetchBall', 'Method:models/Dog.java:fetchBall', 'Method', { ownerId: 'Class:models/Dog.java:Dog', }); - ctx.symbols.add(otherDogFile, 'fetchBall', 'Method:models/OtherDog.java:fetchBall', 'Method', { - ownerId: 'Class:models/OtherDog.java:OtherDog', - }); + ctx.model.symbols.add( + otherDogFile, + 'fetchBall', + 'Method:models/OtherDog.java:fetchBall', + 'Method', + { + ownerId: 'Class:models/OtherDog.java:OtherDog', + }, + ); ctx.importMap.set(appFile, new Set([contractFile, dogFile, otherDogFile])); - const classLookupSpy = vi.spyOn(ctx.symbols, 'lookupClassByName'); + // SM-20 wire-up: resolveMemberCall's constructor-override branch queries + // the model directly (ctx.model.types.lookupClassByName), not the + // legacy SymbolTable wrapper. Spy on the model method to preserve the + // test's intent: verify which class names are looked up during override. + const classLookupSpy = vi.spyOn(ctx.model.types, 'lookupClassByName'); await processCalls( graph, @@ -2061,10 +2147,12 @@ describe('processCallsFromExtracted — interface dispatch', () => { const implAExecuteId = 'Method:impl/A.java:execute'; const implBExecuteId = 'Method:impl/B.java:execute'; - ctx.symbols.add(ifaceFile, 'Action', actionIfaceId, 'Interface'); - ctx.symbols.add(ifaceFile, 'execute', ifaceExecuteId, 'Method', { ownerId: actionIfaceId }); - ctx.symbols.add(implA, 'execute', implAExecuteId, 'Method'); - ctx.symbols.add(implB, 'execute', implBExecuteId, 'Method'); + ctx.model.symbols.add(ifaceFile, 'Action', actionIfaceId, 'Interface'); + ctx.model.symbols.add(ifaceFile, 'execute', ifaceExecuteId, 'Method', { + ownerId: actionIfaceId, + }); + ctx.model.symbols.add(implA, 'execute', implAExecuteId, 'Method'); + ctx.model.symbols.add(implB, 'execute', implBExecuteId, 'Method'); ctx.importMap.set(runnerFile, new Set([ifaceFile])); graph.addNode({ @@ -2100,8 +2188,8 @@ describe('processCallsFromExtracted — interface dispatch', () => { { filePath: 'impl/B.java', className: 'B', parentName: 'Action', kind: 'implements' }, ]; // Need class symbols for heritage map to resolve implementors - ctx.symbols.add('impl/A.java', 'A', 'Class:impl/A.java:A', 'Class'); - ctx.symbols.add('impl/B.java', 'B', 'Class:impl/B.java:B', 'Class'); + ctx.model.symbols.add('impl/A.java', 'A', 'Class:impl/A.java:A', 'Class'); + ctx.model.symbols.add('impl/B.java', 'B', 'Class:impl/B.java:B', 'Class'); const heritageMap = buildHeritageMap(heritage, ctx); const calls: ExtractedCall[] = [ @@ -2153,9 +2241,9 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { 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', { + ctx.model.symbols.add(parentFile, 'Parent', parentId, 'Class'); + ctx.model.symbols.add(childFile, 'Child', childId, 'Class'); + ctx.model.symbols.add(parentFile, 'parentMethod', parentMethodId, 'Method', { ownerId: parentId, returnType: 'String', }); @@ -2211,26 +2299,28 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { }); 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 lookupCallableByName; - // D3 file-filter picks the only candidate in Obj's file. + // Setup: Class Obj exists in the same file as a `doWork` Method. The + // Method is registered under a DIFFERENT ownerId (`class:OtherOwner`) + // so lookupMethodByOwner('class:Obj', 'doWork') misses on the direct + // lookup. heritageMap is empty for class:Obj, so MRO walk yields no + // parents. Expected flow: + // D0: lookupMethodByOwner + MRO walk both miss → D0 fallthrough + // D1-D4: receiver type resolves to Obj; D3 file-filter picks the + // `doWork` candidate via its co-located file path. // 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', { + ctx.model.symbols.add(classFile, 'Obj', classId, 'Class'); + // Post-A4: Method+ownerId routes through methodsByName. Using a + // different ownerId than the receiver type forces the direct + // lookupMethodByOwner miss that the test exercises. + ctx.model.symbols.add(classFile, 'doWork', doWorkId, 'Method', { returnType: 'void', parameterCount: 0, + ownerId: 'class:models/Obj.java:OtherOwner', }); ctx.importMap.set(appFile, new Set([classFile])); @@ -2321,15 +2411,15 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { const methodIntId = 'method:models/Obj.java:method(int)'; const methodStringId = 'method:models/Obj.java:method(String)'; - ctx.symbols.add(classFile, 'Obj', classId, 'Class'); + ctx.model.symbols.add(classFile, 'Obj', classId, 'Class'); // int overload added FIRST so lookupMethodByOwner would return it. - ctx.symbols.add(classFile, 'method', methodIntId, 'Method', { + ctx.model.symbols.add(classFile, 'method', methodIntId, 'Method', { ownerId: classId, returnType: 'String', parameterCount: 1, parameterTypes: ['int'], }); - ctx.symbols.add(classFile, 'method', methodStringId, 'Method', { + ctx.model.symbols.add(classFile, 'method', methodStringId, 'Method', { ownerId: classId, returnType: 'String', parameterCount: 1, @@ -2384,16 +2474,16 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { const methodIntId = 'method:models/Obj.java:method(int)'; const methodStringId = 'method:models/Obj.java:method(String)'; - ctx.symbols.add(classFile, 'Obj', classId, 'Class'); + ctx.model.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', { + ctx.model.symbols.add(classFile, 'method', methodIntId, 'Method', { ownerId: classId, returnType: 'String', parameterCount: 1, parameterTypes: ['int'], }); - ctx.symbols.add(classFile, 'method', methodStringId, 'Method', { + ctx.model.symbols.add(classFile, 'method', methodStringId, 'Method', { ownerId: classId, returnType: 'String', parameterCount: 1, @@ -2439,13 +2529,13 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { 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', { + ctx.model.symbols.add(authModFile, 'User', authUserId, 'Class'); + ctx.model.symbols.add(userModFile, 'User', userUserId, 'Class'); + ctx.model.symbols.add(authModFile, 'save', authSaveId, 'Method', { ownerId: authUserId, returnType: 'bool', }); - ctx.symbols.add(userModFile, 'save', userSaveId, 'Method', { + ctx.model.symbols.add(userModFile, 'save', userSaveId, 'Method', { ownerId: userUserId, returnType: 'bool', }); @@ -2509,13 +2599,13 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { const authSaveId = 'method:src/auth_mod.py:save'; const userSaveId = 'method:src/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', { + ctx.model.symbols.add(authModFile, 'User', authUserId, 'Class'); + ctx.model.symbols.add(userModFile, 'User', userUserId, 'Class'); + ctx.model.symbols.add(authModFile, 'save', authSaveId, 'Method', { ownerId: authUserId, returnType: 'bool', }); - ctx.symbols.add(userModFile, 'save', userSaveId, 'Method', { + ctx.model.symbols.add(userModFile, 'save', userSaveId, 'Method', { ownerId: userUserId, returnType: 'bool', }); @@ -2566,13 +2656,13 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { const modelsSaveId = 'method:src/models.py:User:save'; const authSaveId = 'method:src/auth.py:Widget:save'; - ctx.symbols.add(modelsFile, 'User', modelsUserId, 'Class'); - ctx.symbols.add(authFile, 'Widget', authWidgetId, 'Class'); - ctx.symbols.add(modelsFile, 'save', modelsSaveId, 'Method', { + ctx.model.symbols.add(modelsFile, 'User', modelsUserId, 'Class'); + ctx.model.symbols.add(authFile, 'Widget', authWidgetId, 'Class'); + ctx.model.symbols.add(modelsFile, 'save', modelsSaveId, 'Method', { ownerId: modelsUserId, returnType: 'None', }); - ctx.symbols.add(authFile, 'save', authSaveId, 'Method', { + ctx.model.symbols.add(authFile, 'save', authSaveId, 'Method', { ownerId: authWidgetId, returnType: 'None', }); @@ -2616,10 +2706,10 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { const authWidgetId = 'class:src/auth.py:Widget'; const authSaveId = 'method:src/auth.py:Widget:save'; - ctx.symbols.add(modelsFile, 'User', modelsUserId, 'Class'); - ctx.symbols.add(authFile, 'Widget', authWidgetId, 'Class'); + ctx.model.symbols.add(modelsFile, 'User', modelsUserId, 'Class'); + ctx.model.symbols.add(authFile, 'Widget', authWidgetId, 'Class'); // NO save on User — deliberately absent to force null-route. - ctx.symbols.add(authFile, 'save', authSaveId, 'Method', { + ctx.model.symbols.add(authFile, 'save', authSaveId, 'Method', { ownerId: authWidgetId, returnType: 'None', }); @@ -2658,8 +2748,8 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { const modelsUserId = 'class:src/models.py:User'; const modelsSaveId = 'method:src/models.py:User:save'; - ctx.symbols.add(modelsFile, 'User', modelsUserId, 'Class'); - ctx.symbols.add(modelsFile, 'save', modelsSaveId, 'Method', { + ctx.model.symbols.add(modelsFile, 'User', modelsUserId, 'Class'); + ctx.model.symbols.add(modelsFile, 'save', modelsSaveId, 'Method', { ownerId: modelsUserId, returnType: 'None', }); @@ -2697,8 +2787,8 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { const modelsUserId = 'class:src/models.py:User'; const modelsSaveId = 'method:src/models.py:User:save'; - ctx.symbols.add(modelsFile, 'User', modelsUserId, 'Class'); - ctx.symbols.add(modelsFile, 'save', modelsSaveId, 'Method', { + ctx.model.symbols.add(modelsFile, 'User', modelsUserId, 'Class'); + ctx.model.symbols.add(modelsFile, 'save', modelsSaveId, 'Method', { ownerId: modelsUserId, returnType: 'None', }); @@ -2740,14 +2830,14 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { const userCtorId = 'Constructor:src/models/User.ts:User(string)'; const repoCtorId = 'Constructor:src/models/Repo.ts:User(number)'; - ctx.symbols.add(userFile, 'User', userClassId, 'Class'); - ctx.symbols.add(repoFile, 'User', repoClassId, 'Class'); - ctx.symbols.add(userFile, 'User', userCtorId, 'Constructor', { + ctx.model.symbols.add(userFile, 'User', userClassId, 'Class'); + ctx.model.symbols.add(repoFile, 'User', repoClassId, 'Class'); + ctx.model.symbols.add(userFile, 'User', userCtorId, 'Constructor', { ownerId: userClassId, parameterCount: 1, parameterTypes: ['string'], }); - ctx.symbols.add(repoFile, 'User', repoCtorId, 'Constructor', { + ctx.model.symbols.add(repoFile, 'User', repoCtorId, 'Constructor', { ownerId: repoClassId, parameterCount: 1, parameterTypes: ['number'], @@ -2784,15 +2874,15 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { const userCtorId = 'Constructor:src/models/User.ts:User(string)'; const repoCtorId = 'Constructor:src/models/Repo.ts:User(string)'; - ctx.symbols.add(userFile, 'User', userClassId, 'Class'); - ctx.symbols.add(repoFile, 'User', repoClassId, 'Class'); + ctx.model.symbols.add(userFile, 'User', userClassId, 'Class'); + ctx.model.symbols.add(repoFile, 'User', repoClassId, 'Class'); // Both constructors take `string` — genuinely ambiguous. - ctx.symbols.add(userFile, 'User', userCtorId, 'Constructor', { + ctx.model.symbols.add(userFile, 'User', userCtorId, 'Constructor', { ownerId: userClassId, parameterCount: 1, parameterTypes: ['string'], }); - ctx.symbols.add(repoFile, 'User', repoCtorId, 'Constructor', { + ctx.model.symbols.add(repoFile, 'User', repoCtorId, 'Constructor', { ownerId: repoClassId, parameterCount: 1, parameterTypes: ['string'], @@ -2833,11 +2923,17 @@ describe('processAssignmentsFromExtracted', () => { // carries getUser → User from the source file. The constructor binding // binds x = getUser(). The assignment x.address = value should produce // an ACCESSES write edge to User.address via the accumulator fallback. - ctx.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function'); - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'address', 'Property:src/models.ts:address', 'Property', { - ownerId: 'Class:src/models.ts:User', - }); + ctx.model.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function'); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add( + 'src/models.ts', + 'address', + 'Property:src/models.ts:address', + 'Property', + { + ownerId: 'Class:src/models.ts:User', + }, + ); ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts', 'src/models.ts'])); ctx.namedImportMap.set( 'src/consumer.ts', @@ -2889,9 +2985,9 @@ describe('D2 widen path: lookupCallableByName via module alias', () => { // pointing to auth.py. login() is defined only in auth.py (not imported // by consumer.py). The D2 widen path should find login via the global // callable index filtered to the aliased module file. - ctx.symbols.add('src/auth.py', 'login', 'Function:src/auth.py:login', 'Function'); + ctx.model.symbols.add('src/auth.py', 'login', 'Function:src/auth.py:login', 'Function'); // Consumer has a same-file function that shadows 'login' at Tier 1 - ctx.symbols.add('src/consumer.py', 'login', 'Function:src/consumer.py:login', 'Function'); + ctx.model.symbols.add('src/consumer.py', 'login', 'Function:src/consumer.py:login', 'Function'); // Module alias: consumer.py → auth → src/auth.py ctx.moduleAliasMap.set('src/consumer.py', new Map([['auth', 'src/auth.py']])); diff --git a/gitnexus/test/unit/field-extraction.test.ts b/gitnexus/test/unit/field-extraction.test.ts index 751618146..2795f5833 100644 --- a/gitnexus/test/unit/field-extraction.test.ts +++ b/gitnexus/test/unit/field-extraction.test.ts @@ -8,7 +8,7 @@ import { cppConfig } from '../../src/core/ingestion/field-extractors/configs/c-c import { rubyConfig } from '../../src/core/ingestion/field-extractors/configs/ruby.js'; import type { FieldExtractorContext } from '../../src/core/ingestion/field-types.js'; import type { TypeEnvironment } from '../../src/core/ingestion/type-env.js'; -import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js'; +import { createSemanticModel } from '../../src/core/ingestion/model/semantic-model.js'; import Parser from 'tree-sitter'; import TypeScript from 'tree-sitter-typescript'; import Python from 'tree-sitter-python'; @@ -26,7 +26,13 @@ const parse = (code: string) => { return parser.parse(code); }; -// Mock context for tests +// Mock context for tests. symbolTable comes from createSemanticModel().symbols +// (the facade) rather than createSymbolTable() (the raw leaf) — this mirrors +// production, where FieldExtractorContext always receives the SemanticModel- +// wrapped facade so any .add() write dispatches through the owner-scoped +// registries. No current field extractor calls symbolTable.add(), but +// matching the production shape prevents silent drift if a future extractor +// starts registering dynamically-discovered properties. const createMockContext = (): FieldExtractorContext => ({ typeEnv: { lookup: () => undefined, @@ -35,7 +41,7 @@ const createMockContext = (): FieldExtractorContext => ({ allScopes: () => new Map(), constructorTypeMap: new Map(), } as TypeEnvironment, - symbolTable: createSymbolTable(), + symbolTable: createSemanticModel().symbols, filePath: 'test.ts', language: SupportedLanguages.TypeScript, }); diff --git a/gitnexus/test/unit/heritage-map.test.ts b/gitnexus/test/unit/heritage-map.test.ts index b4a6b1c4f..1b206bfe5 100644 --- a/gitnexus/test/unit/heritage-map.test.ts +++ b/gitnexus/test/unit/heritage-map.test.ts @@ -1,10 +1,11 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { buildHeritageMap } from '../../src/core/ingestion/heritage-map.js'; +import { buildHeritageMap } from '../../src/core/ingestion/model/heritage-map.js'; import { createResolutionContext, type ResolutionContext, -} from '../../src/core/ingestion/resolution-context.js'; -import type { ExtractedHeritage } from '../../src/core/ingestion/workers/parse-worker.js'; +} from '../../src/core/ingestion/model/resolution-context.js'; +import type { ExtractedHeritage } from '../../src/core/ingestion/model/heritage-map.js'; +import { getHeritageStrategyForLanguage } from '../../src/core/ingestion/heritage-processor.js'; describe('buildHeritageMap', () => { let ctx: ResolutionContext; @@ -17,8 +18,8 @@ describe('buildHeritageMap', () => { describe('getParents', () => { it('returns direct parents for a single extends relationship', () => { - ctx.symbols.add('src/child.ts', 'Child', 'class:Child', 'Class'); - ctx.symbols.add('src/parent.ts', 'Parent', 'class:Parent', 'Class'); + ctx.model.symbols.add('src/child.ts', 'Child', 'class:Child', 'Class'); + ctx.model.symbols.add('src/parent.ts', 'Parent', 'class:Parent', 'Class'); const heritage: ExtractedHeritage[] = [ { filePath: 'src/child.ts', className: 'Child', parentName: 'Parent', kind: 'extends' }, @@ -29,8 +30,8 @@ describe('buildHeritageMap', () => { }); it('returns direct parents for implements relationship', () => { - ctx.symbols.add('src/service.ts', 'Service', 'class:Service', 'Class'); - ctx.symbols.add('src/iface.ts', 'IService', 'iface:IService', 'Interface'); + ctx.model.symbols.add('src/service.ts', 'Service', 'class:Service', 'Class'); + ctx.model.symbols.add('src/iface.ts', 'IService', 'iface:IService', 'Interface'); const heritage: ExtractedHeritage[] = [ { @@ -46,8 +47,8 @@ describe('buildHeritageMap', () => { }); it('returns direct parents for trait-impl relationship', () => { - ctx.symbols.add('src/point.rs', 'Point', 'struct:Point', 'Struct'); - ctx.symbols.add('src/display.rs', 'Display', 'trait:Display', 'Interface'); + ctx.model.symbols.add('src/point.rs', 'Point', 'struct:Point', 'Struct'); + ctx.model.symbols.add('src/display.rs', 'Display', 'trait:Display', 'Interface'); const heritage: ExtractedHeritage[] = [ { @@ -63,9 +64,14 @@ describe('buildHeritageMap', () => { }); it('returns multiple parents when class extends and implements', () => { - ctx.symbols.add('src/admin.ts', 'Admin', 'class:Admin', 'Class'); - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/serializable.ts', 'Serializable', 'iface:Serializable', 'Interface'); + ctx.model.symbols.add('src/admin.ts', 'Admin', 'class:Admin', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add( + 'src/serializable.ts', + 'Serializable', + 'iface:Serializable', + 'Interface', + ); const heritage: ExtractedHeritage[] = [ { filePath: 'src/admin.ts', className: 'Admin', parentName: 'User', kind: 'extends' }, @@ -90,7 +96,7 @@ describe('buildHeritageMap', () => { }); it('skips heritage records where child class is not in symbol table', () => { - ctx.symbols.add('src/parent.ts', 'Parent', 'class:Parent', 'Class'); + ctx.model.symbols.add('src/parent.ts', 'Parent', 'class:Parent', 'Class'); const heritage: ExtractedHeritage[] = [ { @@ -107,7 +113,7 @@ describe('buildHeritageMap', () => { }); it('skips heritage records where parent class is not in symbol table', () => { - ctx.symbols.add('src/child.ts', 'Child', 'class:Child', 'Class'); + ctx.model.symbols.add('src/child.ts', 'Child', 'class:Child', 'Class'); const heritage: ExtractedHeritage[] = [ { @@ -123,7 +129,7 @@ describe('buildHeritageMap', () => { }); it('skips self-references', () => { - ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); const heritage: ExtractedHeritage[] = [ { filePath: 'src/a.ts', className: 'A', parentName: 'A', kind: 'extends' }, @@ -134,8 +140,8 @@ describe('buildHeritageMap', () => { }); it('deduplicates cross-chunk duplicates', () => { - ctx.symbols.add('src/child.ts', 'Child', 'class:Child', 'Class'); - ctx.symbols.add('src/parent.ts', 'Parent', 'class:Parent', 'Class'); + ctx.model.symbols.add('src/child.ts', 'Child', 'class:Child', 'Class'); + ctx.model.symbols.add('src/parent.ts', 'Parent', 'class:Parent', 'Class'); const heritage: ExtractedHeritage[] = [ { filePath: 'src/child.ts', className: 'Child', parentName: 'Parent', kind: 'extends' }, @@ -151,9 +157,9 @@ describe('buildHeritageMap', () => { describe('getAncestors', () => { it('returns full ancestor chain for multi-level inheritance', () => { - ctx.symbols.add('src/c.ts', 'C', 'class:C', 'Class'); - ctx.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); - ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/c.ts', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); const heritage: ExtractedHeritage[] = [ { filePath: 'src/c.ts', className: 'C', parentName: 'B', kind: 'extends' }, @@ -173,10 +179,10 @@ describe('buildHeritageMap', () => { // B C // \ / // D - ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); - ctx.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); - ctx.symbols.add('src/c.ts', 'C', 'class:C', 'Class'); - ctx.symbols.add('src/d.ts', 'D', 'class:D', 'Class'); + ctx.model.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/c.ts', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('src/d.ts', 'D', 'class:D', 'Class'); const heritage: ExtractedHeritage[] = [ { filePath: 'src/d.ts', className: 'D', parentName: 'B', kind: 'extends' }, @@ -194,8 +200,8 @@ describe('buildHeritageMap', () => { }); it('protects against cycles', () => { - ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); - ctx.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); const heritage: ExtractedHeritage[] = [ { filePath: 'src/a.ts', className: 'A', parentName: 'B', kind: 'extends' }, @@ -212,9 +218,9 @@ describe('buildHeritageMap', () => { }); it('protects against multi-node cycles (A→B→C→A)', () => { - ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); - ctx.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); - ctx.symbols.add('src/c.ts', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/c.ts', 'C', 'class:C', 'Class'); // A → B → C → A (3-node cycle) const heritage: ExtractedHeritage[] = [ @@ -232,7 +238,7 @@ describe('buildHeritageMap', () => { }); it('returns empty array for node with no parents', () => { - ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); const map = buildHeritageMap([], ctx); expect(map.getAncestors('class:A')).toEqual([]); @@ -249,9 +255,9 @@ describe('buildHeritageMap', () => { for (let i = 0; i < 40; i++) { const childName = `Level${i}`; const parentName = `Level${i + 1}`; - ctx.symbols.add(`src/${childName}.ts`, childName, `class:${childName}`, 'Class'); + ctx.model.symbols.add(`src/${childName}.ts`, childName, `class:${childName}`, 'Class'); if (i === 39) { - ctx.symbols.add(`src/${parentName}.ts`, parentName, `class:${parentName}`, 'Class'); + ctx.model.symbols.add(`src/${parentName}.ts`, parentName, `class:${parentName}`, 'Class'); } heritage.push({ filePath: `src/${childName}.ts`, @@ -289,9 +295,9 @@ describe('buildHeritageMap', () => { describe('getImplementorFiles', () => { it('records direct implements edges per interface name', () => { - ctx.symbols.add('a.java', 'C', 'class:C', 'Class'); - ctx.symbols.add('b.java', 'D', 'class:D', 'Class'); - ctx.symbols.add('iface.java', 'Runnable', 'iface:Runnable', 'Interface'); + ctx.model.symbols.add('a.java', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('b.java', 'D', 'class:D', 'Class'); + ctx.model.symbols.add('iface.java', 'Runnable', 'iface:Runnable', 'Interface'); const heritage: ExtractedHeritage[] = [ { filePath: 'a.java', className: 'C', parentName: 'Runnable', kind: 'implements' }, @@ -302,9 +308,9 @@ describe('buildHeritageMap', () => { }); it('only records implementors for interface parents, not class parents', () => { - ctx.symbols.add('a.java', 'C', 'class:C', 'Class'); - ctx.symbols.add('base.java', 'Base', 'class:Base', 'Class'); - ctx.symbols.add('iface.java', 'I', 'iface:I', 'Interface'); + ctx.model.symbols.add('a.java', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('base.java', 'Base', 'class:Base', 'Class'); + ctx.model.symbols.add('iface.java', 'I', 'iface:I', 'Interface'); const heritage: ExtractedHeritage[] = [ { filePath: 'a.java', className: 'C', parentName: 'Base', kind: 'extends' }, @@ -326,7 +332,7 @@ describe('buildHeritageMap', () => { // Only the child class is registered; the parent interface has no symbol. // resolveExtendsType must fall through to the provider heuristic and // classify `IDisposable` as IMPLEMENTS. - ctx.symbols.add('src/Service.cs', 'Service', 'class:Service', 'Class'); + ctx.model.symbols.add('src/Service.cs', 'Service', 'class:Service', 'Class'); const heritage: ExtractedHeritage[] = [ { @@ -336,14 +342,14 @@ describe('buildHeritageMap', () => { kind: 'extends', }, ]; - const map = buildHeritageMap(heritage, ctx); + const map = buildHeritageMap(heritage, ctx, getHeritageStrategyForLanguage); expect(map.getImplementorFiles('IDisposable')).toEqual(new Set(['src/Service.cs'])); }); it('records Swift extends→IMPLEMENTS via heritageDefaultEdge when parent is unresolved', () => { // Swift provider has heritageDefaultEdge: 'IMPLEMENTS'. // Unresolved parents should default to IMPLEMENTS (protocol conformance). - ctx.symbols.add('src/MyView.swift', 'MyView', 'class:MyView', 'Class'); + ctx.model.symbols.add('src/MyView.swift', 'MyView', 'class:MyView', 'Class'); const heritage: ExtractedHeritage[] = [ { @@ -353,7 +359,7 @@ describe('buildHeritageMap', () => { kind: 'extends', }, ]; - const map = buildHeritageMap(heritage, ctx); + const map = buildHeritageMap(heritage, ctx, getHeritageStrategyForLanguage); expect(map.getImplementorFiles('SomeProtocol')).toEqual(new Set(['src/MyView.swift'])); }); @@ -361,8 +367,8 @@ describe('buildHeritageMap', () => { // Java/C# path: when ctx.resolve finds a matching symbol whose type is // Interface, resolveExtendsType returns IMPLEMENTS via the symbol lookup // (not the interfaceNamePattern fallback). - ctx.symbols.add('src/Impl.java', 'Impl', 'class:Impl', 'Class'); - ctx.symbols.add('src/MyContract.java', 'MyContract', 'iface:MyContract', 'Interface'); + ctx.model.symbols.add('src/Impl.java', 'Impl', 'class:Impl', 'Class'); + ctx.model.symbols.add('src/MyContract.java', 'MyContract', 'iface:MyContract', 'Interface'); const heritage: ExtractedHeritage[] = [ { @@ -377,8 +383,8 @@ describe('buildHeritageMap', () => { }); it('records Kotlin implements edges', () => { - ctx.symbols.add('src/Impl.kt', 'Impl', 'class:Impl', 'Class'); - ctx.symbols.add('src/Iface.kt', 'Iface', 'iface:Iface', 'Interface'); + ctx.model.symbols.add('src/Impl.kt', 'Impl', 'class:Impl', 'Class'); + ctx.model.symbols.add('src/Iface.kt', 'Iface', 'iface:Iface', 'Interface'); const heritage: ExtractedHeritage[] = [ { @@ -393,8 +399,8 @@ describe('buildHeritageMap', () => { }); it('records TypeScript implements edges', () => { - ctx.symbols.add('src/Service.ts', 'UserService', 'class:UserService', 'Class'); - ctx.symbols.add('src/IService.ts', 'IUserService', 'iface:IUserService', 'Interface'); + ctx.model.symbols.add('src/Service.ts', 'UserService', 'class:UserService', 'Class'); + ctx.model.symbols.add('src/IService.ts', 'IUserService', 'iface:IUserService', 'Interface'); const heritage: ExtractedHeritage[] = [ { @@ -409,8 +415,8 @@ describe('buildHeritageMap', () => { }); it('records PHP implements edges', () => { - ctx.symbols.add('src/Impl.php', 'Impl', 'class:Impl', 'Class'); - ctx.symbols.add('src/Iface.php', 'Iface', 'iface:Iface', 'Interface'); + ctx.model.symbols.add('src/Impl.php', 'Impl', 'class:Impl', 'Class'); + ctx.model.symbols.add('src/Iface.php', 'Iface', 'iface:Iface', 'Interface'); const heritage: ExtractedHeritage[] = [ { @@ -427,8 +433,8 @@ describe('buildHeritageMap', () => { it('does not record Rust trait-impl entries in the implementor index', () => { // Documented limitation: trait-impl is intentionally not added to the // implementor index — interface dispatch does not traverse trait objects. - ctx.symbols.add('src/point.rs', 'Point', 'struct:Point', 'Struct'); - ctx.symbols.add('src/display.rs', 'Display', 'trait:Display', 'Interface'); + ctx.model.symbols.add('src/point.rs', 'Point', 'struct:Point', 'Struct'); + ctx.model.symbols.add('src/display.rs', 'Display', 'trait:Display', 'Interface'); const heritage: ExtractedHeritage[] = [ { @@ -445,9 +451,9 @@ describe('buildHeritageMap', () => { }); it('heritage merged across chunks matches single-pass (chunk-order invariant)', () => { - ctx.symbols.add('a.java', 'A', 'class:A', 'Class'); - ctx.symbols.add('b.java', 'B', 'class:B', 'Class'); - ctx.symbols.add('iface.java', 'Iface', 'iface:Iface', 'Interface'); + ctx.model.symbols.add('a.java', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('b.java', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('iface.java', 'Iface', 'iface:Iface', 'Interface'); const chunk1: ExtractedHeritage[] = [ { filePath: 'a.java', className: 'A', parentName: 'Iface', kind: 'implements' }, @@ -464,10 +470,10 @@ describe('buildHeritageMap', () => { describe('chunk-order invariant', () => { it('produces same result regardless of heritage record order', () => { - ctx.symbols.add('src/d.ts', 'D', 'class:D', 'Class'); - ctx.symbols.add('src/c.ts', 'C', 'class:C', 'Class'); - ctx.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); - ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/d.ts', 'D', 'class:D', 'Class'); + ctx.model.symbols.add('src/c.ts', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); const heritage1: ExtractedHeritage[] = [ { filePath: 'src/d.ts', className: 'D', parentName: 'C', kind: 'extends' }, diff --git a/gitnexus/test/unit/heritage-processor.test.ts b/gitnexus/test/unit/heritage-processor.test.ts index 9c007e268..e4b4be211 100644 --- a/gitnexus/test/unit/heritage-processor.test.ts +++ b/gitnexus/test/unit/heritage-processor.test.ts @@ -4,8 +4,8 @@ import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; import { createResolutionContext, type ResolutionContext, -} from '../../src/core/ingestion/resolution-context.js'; -import type { ExtractedHeritage } from '../../src/core/ingestion/workers/parse-worker.js'; +} from '../../src/core/ingestion/model/resolution-context.js'; +import type { ExtractedHeritage } from '../../src/core/ingestion/model/heritage-map.js'; describe('processHeritageFromExtracted', () => { let graph: ReturnType; @@ -18,8 +18,8 @@ describe('processHeritageFromExtracted', () => { describe('extends', () => { it('creates EXTENDS relationship between classes', async () => { - ctx.symbols.add('src/admin.ts', 'AdminUser', 'Class:src/admin.ts:AdminUser', 'Class'); - ctx.symbols.add('src/user.ts', 'User', 'Class:src/user.ts:User', 'Class'); + ctx.model.symbols.add('src/admin.ts', 'AdminUser', 'Class:src/admin.ts:AdminUser', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'Class:src/user.ts:User', 'Class'); const heritage: ExtractedHeritage[] = [ { @@ -58,7 +58,7 @@ describe('processHeritageFromExtracted', () => { }); it('skips self-inheritance', async () => { - ctx.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); + ctx.model.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); const heritage: ExtractedHeritage[] = [ { @@ -76,8 +76,13 @@ describe('processHeritageFromExtracted', () => { describe('implements', () => { it('creates IMPLEMENTS relationship', async () => { - ctx.symbols.add('src/service.ts', 'UserService', 'Class:src/service.ts:UserService', 'Class'); - ctx.symbols.add( + ctx.model.symbols.add( + 'src/service.ts', + 'UserService', + 'Class:src/service.ts:UserService', + 'Class', + ); + ctx.model.symbols.add( 'src/interfaces.ts', 'IService', 'Interface:src/interfaces.ts:IService', @@ -103,8 +108,8 @@ describe('processHeritageFromExtracted', () => { describe('trait-impl (Rust)', () => { it('creates IMPLEMENTS relationship for trait impl', async () => { - ctx.symbols.add('src/point.rs', 'Point', 'Struct:src/point.rs:Point', 'Struct'); - ctx.symbols.add('src/display.rs', 'Display', 'Trait:src/display.rs:Display', 'Trait'); + ctx.model.symbols.add('src/point.rs', 'Point', 'Struct:src/point.rs:Point', 'Struct'); + ctx.model.symbols.add('src/display.rs', 'Display', 'Trait:src/display.rs:Display', 'Trait'); const heritage: ExtractedHeritage[] = [ { @@ -125,8 +130,13 @@ describe('processHeritageFromExtracted', () => { describe('C# interface resolution from extends captures', () => { it('emits IMPLEMENTS when parent is an Interface in symbol table', async () => { - ctx.symbols.add('src/Service.cs', 'UserService', 'Class:src/Service.cs:UserService', 'Class'); - ctx.symbols.add( + ctx.model.symbols.add( + 'src/Service.cs', + 'UserService', + 'Class:src/Service.cs:UserService', + 'Class', + ); + ctx.model.symbols.add( 'src/IService.cs', 'IService', 'Interface:src/IService.cs:IService', @@ -153,8 +163,8 @@ describe('processHeritageFromExtracted', () => { }); it('emits EXTENDS when parent is a Class in symbol table', async () => { - ctx.symbols.add('src/Admin.cs', 'AdminUser', 'Class:src/Admin.cs:AdminUser', 'Class'); - ctx.symbols.add('src/User.cs', 'User', 'Class:src/User.cs:User', 'Class'); + ctx.model.symbols.add('src/Admin.cs', 'AdminUser', 'Class:src/Admin.cs:AdminUser', 'Class'); + ctx.model.symbols.add('src/User.cs', 'User', 'Class:src/User.cs:User', 'Class'); const heritage: ExtractedHeritage[] = [ { @@ -246,15 +256,20 @@ describe('processHeritageFromExtracted', () => { }); it('handles mixed class + interface base_list from C#', async () => { - ctx.symbols.add('src/Repo.cs', 'UserRepo', 'Class:src/Repo.cs:UserRepo', 'Class'); - ctx.symbols.add('src/Base.cs', 'BaseRepository', 'Class:src/Base.cs:BaseRepository', 'Class'); - ctx.symbols.add( + ctx.model.symbols.add('src/Repo.cs', 'UserRepo', 'Class:src/Repo.cs:UserRepo', 'Class'); + ctx.model.symbols.add( + 'src/Base.cs', + 'BaseRepository', + 'Class:src/Base.cs:BaseRepository', + 'Class', + ); + ctx.model.symbols.add( 'src/IRepo.cs', 'IRepository', 'Interface:src/IRepo.cs:IRepository', 'Interface', ); - ctx.symbols.add( + ctx.model.symbols.add( 'src/IDisp.cs', 'IDisposable', 'Interface:src/IDisp.cs:IDisposable', @@ -314,7 +329,7 @@ describe('processHeritageFromExtracted', () => { it('still uses symbol table authoritatively for Swift (Tier 1 takes precedence)', async () => { // When the parent is in the symbol table as a Class, EXTENDS wins even in Swift - ctx.symbols.add('src/Animal.swift', 'Animal', 'Class:src/Animal.swift:Animal', 'Class'); + ctx.model.symbols.add('src/Animal.swift', 'Animal', 'Class:src/Animal.swift:Animal', 'Class'); const heritage: ExtractedHeritage[] = [ { diff --git a/gitnexus/test/unit/import-processor.test.ts b/gitnexus/test/unit/import-processor.test.ts index eb5fe5c19..0396be2cb 100644 --- a/gitnexus/test/unit/import-processor.test.ts +++ b/gitnexus/test/unit/import-processor.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { buildImportResolutionContext } from '../../src/core/ingestion/import-processor.js'; import type { ImportResolutionContext } from '../../src/core/ingestion/import-resolvers/types.js'; -import { createResolutionContext } from '../../src/core/ingestion/resolution-context.js'; +import { createResolutionContext } from '../../src/core/ingestion/model/resolution-context.js'; describe('ResolutionContext.importMap', () => { it('creates an empty Map', () => { diff --git a/gitnexus/test/unit/model/field-registry.test.ts b/gitnexus/test/unit/model/field-registry.test.ts new file mode 100644 index 000000000..4c6e093d7 --- /dev/null +++ b/gitnexus/test/unit/model/field-registry.test.ts @@ -0,0 +1,74 @@ +/** + * Unit tests for FieldRegistry (SM-20). + * + * FieldRegistry is the simplest of the three owner-scoped registries — + * one flat Map keyed on `ownerNodeId\0fieldName`. These tests pin the + * basic register/lookup/clear contract and the owner-scope isolation. + */ + +import { describe, it, expect } from 'vitest'; +import { createFieldRegistry } from '../../../src/core/ingestion/model/field-registry.js'; +import type { SymbolDefinition } from '../../../src/core/ingestion/model/symbol-table.js'; +import { makeDef as makeBaseDef } from './helpers.js'; + +const makeDef = (overrides: Partial = {}): SymbolDefinition => + makeBaseDef({ nodeId: 'prop:test', type: 'Property', ...overrides }); + +describe('FieldRegistry', () => { + it('lookupFieldByOwner returns undefined when the registry is empty', () => { + const reg = createFieldRegistry(); + expect(reg.lookupFieldByOwner('class:User', 'name')).toBeUndefined(); + }); + + it('register + lookup round-trips the exact def reference', () => { + const reg = createFieldRegistry(); + const def = makeDef({ nodeId: 'prop:User.name', declaredType: 'string' }); + + reg.register('class:User', 'name', def); + + expect(reg.lookupFieldByOwner('class:User', 'name')).toBe(def); + }); + + it('isolates fields by ownerNodeId — same field name on two classes does not collide', () => { + const reg = createFieldRegistry(); + const userName = makeDef({ nodeId: 'prop:User.name' }); + const orderName = makeDef({ nodeId: 'prop:Order.name' }); + + reg.register('class:User', 'name', userName); + reg.register('class:Order', 'name', orderName); + + expect(reg.lookupFieldByOwner('class:User', 'name')?.nodeId).toBe('prop:User.name'); + expect(reg.lookupFieldByOwner('class:Order', 'name')?.nodeId).toBe('prop:Order.name'); + }); + + it('last-wins on duplicate (ownerNodeId, fieldName) — registry is flat, not an overload list', () => { + const reg = createFieldRegistry(); + const first = makeDef({ nodeId: 'prop:User.name#first' }); + const second = makeDef({ nodeId: 'prop:User.name#second' }); + + reg.register('class:User', 'name', first); + reg.register('class:User', 'name', second); + + expect(reg.lookupFieldByOwner('class:User', 'name')?.nodeId).toBe('prop:User.name#second'); + }); + + it('clear() empties the registry', () => { + const reg = createFieldRegistry(); + reg.register('class:User', 'name', makeDef()); + reg.register('class:Order', 'total', makeDef()); + + reg.clear(); + + expect(reg.lookupFieldByOwner('class:User', 'name')).toBeUndefined(); + expect(reg.lookupFieldByOwner('class:Order', 'total')).toBeUndefined(); + }); + + it('allows re-registration after clear', () => { + const reg = createFieldRegistry(); + reg.register('class:User', 'name', makeDef({ nodeId: 'prop:first' })); + reg.clear(); + reg.register('class:User', 'name', makeDef({ nodeId: 'prop:second' })); + + expect(reg.lookupFieldByOwner('class:User', 'name')?.nodeId).toBe('prop:second'); + }); +}); diff --git a/gitnexus/test/unit/model/helpers.ts b/gitnexus/test/unit/model/helpers.ts new file mode 100644 index 000000000..04856f8d3 --- /dev/null +++ b/gitnexus/test/unit/model/helpers.ts @@ -0,0 +1,27 @@ +/** + * Shared test helpers for the model/ unit tests. + * + * Keep this file minimal — just the factory functions that every + * registry/table test needs. Anything domain-specific belongs in the + * test file that uses it. + */ + +import type { SymbolDefinition } from '../../../src/core/ingestion/model/symbol-table.js'; + +/** + * Build a {@link SymbolDefinition} with sensible defaults. Every field + * is overridable. Defaults produce a Method-typed def so the caller + * only has to override for other shapes. + */ +export const makeDef = (overrides: Partial = {}): SymbolDefinition => ({ + nodeId: 'def:test', + filePath: 'src/test.ts', + type: 'Method', + ...overrides, +}); + +/** + * Alias for {@link makeDef} kept for readability in method-registry + * tests where "makeMethod" reads more naturally at the call site. + */ +export const makeMethod = makeDef; diff --git a/gitnexus/test/unit/model/method-registry.test.ts b/gitnexus/test/unit/model/method-registry.test.ts new file mode 100644 index 000000000..bdb534202 --- /dev/null +++ b/gitnexus/test/unit/model/method-registry.test.ts @@ -0,0 +1,374 @@ +/** + * Unit tests for MethodRegistry (SM-20). + * + * MethodRegistry is the most complex of the three owner-scoped registries + * because it supports C++/Java/C# overloads. Lookup does two layers of + * narrowing after the primary `ownerNodeId + methodName` key match: + * + * 1. Arity filter: when `argCount` is provided and there are multiple + * overloads, keep only those whose parameterCount range can match. + * Variadic candidates (`parameterCount === undefined`) are retained. + * If arity excludes EVERY candidate, fall back to the full pool so + * fuzzy resolution still has something to work with (the "arity + * fallback" branch — flagged as an untested branch by the testing + * reviewer). + * + * 2. Return-type dedup: among the remaining candidates, if every def + * shares the same defined returnType, return the first. If return + * types differ, return undefined (truly ambiguous). + */ + +import { describe, it, expect } from 'vitest'; +import { createMethodRegistry } from '../../../src/core/ingestion/model/method-registry.js'; +import { makeMethod } from './helpers.js'; + +describe('MethodRegistry — basic lookup', () => { + it('returns undefined when the registry is empty', () => { + const reg = createMethodRegistry(); + expect(reg.lookupMethodByOwner('class:User', 'save')).toBeUndefined(); + }); + + it('register + lookup round-trips the def reference', () => { + const reg = createMethodRegistry(); + const def = makeMethod({ nodeId: 'method:User.save' }); + + reg.register('class:User', 'save', def); + + expect(reg.lookupMethodByOwner('class:User', 'save')).toBe(def); + }); + + it('isolates methods by ownerNodeId — same method name on two classes does not collide', () => { + const reg = createMethodRegistry(); + const userSave = makeMethod({ nodeId: 'method:User.save' }); + const orderSave = makeMethod({ nodeId: 'method:Order.save' }); + + reg.register('class:User', 'save', userSave); + reg.register('class:Order', 'save', orderSave); + + expect(reg.lookupMethodByOwner('class:User', 'save')?.nodeId).toBe('method:User.save'); + expect(reg.lookupMethodByOwner('class:Order', 'save')?.nodeId).toBe('method:Order.save'); + }); +}); + +describe('MethodRegistry — arity narrowing', () => { + it('narrows overloads by parameterCount when argCount is provided', () => { + const reg = createMethodRegistry(); + const greetEmpty = makeMethod({ nodeId: 'method:greet#0', parameterCount: 0 }); + const greetString = makeMethod({ + nodeId: 'method:greet#1', + parameterCount: 1, + returnType: 'void', + }); + + reg.register('class:User', 'greet', greetEmpty); + reg.register('class:User', 'greet', greetString); + + // argCount 0 matches only the 0-arg overload + expect(reg.lookupMethodByOwner('class:User', 'greet', 0)?.nodeId).toBe('method:greet#0'); + // argCount 1 matches only the 1-arg overload + expect(reg.lookupMethodByOwner('class:User', 'greet', 1)?.nodeId).toBe('method:greet#1'); + }); + + it('arity fallback — when no overload matches argCount, returns from the full pool (testing reviewer T-01)', () => { + // This is the explicit arity-fallback branch flagged as untested. + // Without the fallback, `save(1)` / `save(2)` with argCount=3 would + // return undefined. With the fallback, it returns one of them so the + // caller's fuzzy resolution path can still make progress. + const reg = createMethodRegistry(); + const save1 = makeMethod({ nodeId: 'method:save#1', parameterCount: 1, returnType: 'void' }); + const save2 = makeMethod({ nodeId: 'method:save#2', parameterCount: 2, returnType: 'void' }); + + reg.register('class:User', 'save', save1); + reg.register('class:User', 'save', save2); + + // argCount 3 matches neither; fallback returns one of them (first + // wins because both share the same returnType 'void'). + const result = reg.lookupMethodByOwner('class:User', 'save', 3); + expect(result).toBeDefined(); + expect(result?.nodeId).toBe('method:save#1'); + }); + + it('requiredParameterCount range — argCount between required and total is accepted (testing reviewer T-02)', () => { + // Default parameters: `bar(a, b=1, c=2)` has requiredParameterCount: 1, + // parameterCount: 3. Calls with argCount 1, 2, and 3 must all match. + const reg = createMethodRegistry(); + const bar = makeMethod({ + nodeId: 'method:bar', + parameterCount: 3, + requiredParameterCount: 1, + returnType: 'int', + }); + // Add a second overload so arity filtering engages (defs.length > 1). + const barOther = makeMethod({ + nodeId: 'method:bar#other', + parameterCount: 5, + requiredParameterCount: 5, + returnType: 'int', + }); + + reg.register('class:Calc', 'bar', bar); + reg.register('class:Calc', 'bar', barOther); + + expect(reg.lookupMethodByOwner('class:Calc', 'bar', 1)?.nodeId).toBe('method:bar'); + expect(reg.lookupMethodByOwner('class:Calc', 'bar', 2)?.nodeId).toBe('method:bar'); + expect(reg.lookupMethodByOwner('class:Calc', 'bar', 3)?.nodeId).toBe('method:bar'); + // argCount 5 matches the second overload only + expect(reg.lookupMethodByOwner('class:Calc', 'bar', 5)?.nodeId).toBe('method:bar#other'); + }); + + it('variadic fallback — defs with parameterCount=undefined are retained during arity narrowing', () => { + const reg = createMethodRegistry(); + const fixed = makeMethod({ + nodeId: 'method:print#fixed', + parameterCount: 1, + returnType: 'void', + }); + const variadic = makeMethod({ + nodeId: 'method:print#variadic', + parameterCount: undefined, + returnType: 'void', + }); + + reg.register('class:Logger', 'print', fixed); + reg.register('class:Logger', 'print', variadic); + + // argCount 5 excludes fixed (5 > parameterCount 1) but retains + // variadic (parameterCount=undefined bypasses the range check). + // Result: variadic is the only surviving candidate. + const result = reg.lookupMethodByOwner('class:Logger', 'print', 5); + expect(result?.nodeId).toBe('method:print#variadic'); + }); + + it('variadic + matching fixed — argCount in fixed range keeps both, first wins on shared returnType', () => { + const reg = createMethodRegistry(); + const fixed = makeMethod({ + nodeId: 'method:print#fixed', + parameterCount: 2, + returnType: 'void', + }); + const variadic = makeMethod({ + nodeId: 'method:print#variadic', + parameterCount: undefined, + returnType: 'void', + }); + + reg.register('class:Logger', 'print', fixed); + reg.register('class:Logger', 'print', variadic); + + // argCount 2 satisfies fixed's range AND keeps variadic. + // Both share returnType 'void', so first-registered wins. + const result = reg.lookupMethodByOwner('class:Logger', 'print', 2); + expect(result?.nodeId).toBe('method:print#fixed'); + }); +}); + +describe('MethodRegistry — return-type dedup', () => { + it('returns first when all overloads share the same returnType', () => { + const reg = createMethodRegistry(); + const a = makeMethod({ nodeId: 'method:a', parameterCount: 1, returnType: 'int' }); + const b = makeMethod({ nodeId: 'method:b', parameterCount: 1, returnType: 'int' }); + + reg.register('class:X', 'compute', a); + reg.register('class:X', 'compute', b); + + // Two overloads with same arity & same returnType → first wins + expect(reg.lookupMethodByOwner('class:X', 'compute', 1)?.nodeId).toBe('method:a'); + }); + + it('returns undefined when overloads differ in returnType (truly ambiguous)', () => { + const reg = createMethodRegistry(); + const intVersion = makeMethod({ + nodeId: 'method:int', + parameterCount: 1, + returnType: 'int', + }); + const stringVersion = makeMethod({ + nodeId: 'method:string', + parameterCount: 1, + returnType: 'string', + }); + + reg.register('class:X', 'compute', intVersion); + reg.register('class:X', 'compute', stringVersion); + + // Same arity, different returnType → undefined (ambiguous) + expect(reg.lookupMethodByOwner('class:X', 'compute', 1)).toBeUndefined(); + }); + + it('returns undefined when firstReturnType is itself undefined', () => { + const reg = createMethodRegistry(); + const a = makeMethod({ nodeId: 'method:a', parameterCount: 1, returnType: undefined }); + const b = makeMethod({ nodeId: 'method:b', parameterCount: 1, returnType: 'int' }); + + reg.register('class:X', 'compute', a); + reg.register('class:X', 'compute', b); + + // First def has no declared returnType → bail out as undefined + expect(reg.lookupMethodByOwner('class:X', 'compute', 1)).toBeUndefined(); + }); + + it('single-overload methods skip the dedup path', () => { + const reg = createMethodRegistry(); + reg.register( + 'class:X', + 'only', + makeMethod({ nodeId: 'method:only', parameterCount: 1, returnType: undefined }), + ); + + // Only one candidate → returned directly regardless of returnType + expect(reg.lookupMethodByOwner('class:X', 'only', 1)?.nodeId).toBe('method:only'); + }); +}); + +describe('MethodRegistry — clear()', () => { + it('empties the registry', () => { + const reg = createMethodRegistry(); + reg.register('class:User', 'save', makeMethod()); + reg.register('class:Order', 'update', makeMethod()); + + reg.clear(); + + expect(reg.lookupMethodByOwner('class:User', 'save')).toBeUndefined(); + expect(reg.lookupMethodByOwner('class:Order', 'update')).toBeUndefined(); + }); + + it('allows re-registration after clear', () => { + const reg = createMethodRegistry(); + reg.register('class:User', 'save', makeMethod({ nodeId: 'method:first' })); + reg.clear(); + reg.register('class:User', 'save', makeMethod({ nodeId: 'method:second' })); + + expect(reg.lookupMethodByOwner('class:User', 'save')?.nodeId).toBe('method:second'); + }); +}); + +// --------------------------------------------------------------------------- +// lookupMethodByName — flat-by-name secondary index (A4 / plan 006) +// --------------------------------------------------------------------------- + +describe('MethodRegistry — lookupMethodByName', () => { + it('returns an empty array when no method with that name is registered', () => { + const reg = createMethodRegistry(); + expect(reg.lookupMethodByName('save')).toEqual([]); + }); + + it('returns a singleton array after one registration', () => { + const reg = createMethodRegistry(); + const def = makeMethod({ nodeId: 'method:User.save' }); + + reg.register('class:User', 'save', def); + + const result = reg.lookupMethodByName('save'); + expect(result).toHaveLength(1); + expect(result[0]).toBe(def); + }); + + it('accumulates homonym registrations across different owners in order', () => { + const reg = createMethodRegistry(); + const userSave = makeMethod({ nodeId: 'method:User.save' }); + const orderSave = makeMethod({ nodeId: 'method:Order.save' }); + + reg.register('class:User', 'save', userSave); + reg.register('class:Order', 'save', orderSave); + + const result = reg.lookupMethodByName('save'); + expect(result).toHaveLength(2); + expect(result).toEqual([userSave, orderSave]); + }); + + it('accumulates overloads under the same owner', () => { + const reg = createMethodRegistry(); + const overload1 = makeMethod({ nodeId: 'method:User.save#0', parameterCount: 0 }); + const overload2 = makeMethod({ nodeId: 'method:User.save#1', parameterCount: 1 }); + + reg.register('class:User', 'save', overload1); + reg.register('class:User', 'save', overload2); + + const result = reg.lookupMethodByName('save'); + expect(result).toHaveLength(2); + expect(result).toEqual([overload1, overload2]); + }); + + it('returns an empty array after clear()', () => { + const reg = createMethodRegistry(); + reg.register('class:User', 'save', makeMethod({ nodeId: 'method:old' })); + + reg.clear(); + + expect(reg.lookupMethodByName('save')).toEqual([]); + }); + + it('re-registering after clear only returns post-clear defs', () => { + const reg = createMethodRegistry(); + reg.register('class:User', 'save', makeMethod({ nodeId: 'method:old' })); + reg.clear(); + const fresh = makeMethod({ nodeId: 'method:fresh' }); + reg.register('class:User', 'save', fresh); + + const result = reg.lookupMethodByName('save'); + expect(result).toHaveLength(1); + expect(result[0]).toBe(fresh); + }); + + it('returns the same SymbolDefinition reference as lookupMethodByOwner (dual-index identity)', () => { + const reg = createMethodRegistry(); + const def = makeMethod({ nodeId: 'method:User.save' }); + + reg.register('class:User', 'save', def); + + const byOwner = reg.lookupMethodByOwner('class:User', 'save'); + const byName = reg.lookupMethodByName('save'); + + expect(byName).toHaveLength(1); + expect(Object.is(byName[0], byOwner)).toBe(true); + }); + + it('does not return methods with different names', () => { + const reg = createMethodRegistry(); + reg.register('class:User', 'save', makeMethod({ nodeId: 'method:User.save' })); + reg.register('class:User', 'load', makeMethod({ nodeId: 'method:User.load' })); + + expect(reg.lookupMethodByName('save')).toHaveLength(1); + expect(reg.lookupMethodByName('load')).toHaveLength(1); + expect(reg.lookupMethodByName('missing')).toEqual([]); + }); +}); + +describe('hasFunctionMethods flag', () => { + it('is false for a fresh registry', () => { + const reg = createMethodRegistry(); + expect(reg.hasFunctionMethods).toBe(false); + }); + + it('stays false after registering only strict-Method defs', () => { + const reg = createMethodRegistry(); + reg.register('class:User', 'save', makeMethod({ nodeId: 'method:User.save', type: 'Method' })); + reg.register( + 'class:User', + 'load', + makeMethod({ nodeId: 'method:User.load', type: 'Constructor' }), + ); + expect(reg.hasFunctionMethods).toBe(false); + }); + + it('flips to true when a Function-typed def (Python/Rust/Kotlin class method) is registered', () => { + const reg = createMethodRegistry(); + reg.register('class:User', 'greet', makeMethod({ nodeId: 'fn:User.greet', type: 'Function' })); + expect(reg.hasFunctionMethods).toBe(true); + }); + + it('stays true after further strict-Method registrations', () => { + const reg = createMethodRegistry(); + reg.register('class:User', 'greet', makeMethod({ nodeId: 'fn:User.greet', type: 'Function' })); + reg.register('class:Dog', 'bark', makeMethod({ nodeId: 'method:Dog.bark', type: 'Method' })); + expect(reg.hasFunctionMethods).toBe(true); + }); + + it('resets to false after clear()', () => { + const reg = createMethodRegistry(); + reg.register('class:User', 'greet', makeMethod({ nodeId: 'fn:User.greet', type: 'Function' })); + expect(reg.hasFunctionMethods).toBe(true); + reg.clear(); + expect(reg.hasFunctionMethods).toBe(false); + }); +}); diff --git a/gitnexus/test/unit/model/registration-table.test.ts b/gitnexus/test/unit/model/registration-table.test.ts new file mode 100644 index 000000000..067b7fc20 --- /dev/null +++ b/gitnexus/test/unit/model/registration-table.test.ts @@ -0,0 +1,267 @@ +import { describe, it, expect } from 'vitest'; +import { + createRegistrationTable, + CALLABLE_ONLY_LABELS, + INERT_LABELS, + DISPATCH_LABELS, +} from '../../../src/core/ingestion/model/registration-table.js'; +import { createTypeRegistry } from '../../../src/core/ingestion/model/type-registry.js'; +import { createMethodRegistry } from '../../../src/core/ingestion/model/method-registry.js'; +import { createFieldRegistry } from '../../../src/core/ingestion/model/field-registry.js'; +import { ALL_NODE_LABELS } from '../../../src/core/ingestion/model/index.js'; +import type { SymbolDefinition } from '../../../src/core/ingestion/model/symbol-table.js'; +import { makeDef as makeBaseDef } from './helpers.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const makeDeps = () => ({ + types: createTypeRegistry(), + methods: createMethodRegistry(), + fields: createFieldRegistry(), +}); + +const makeDef = (overrides: Partial = {}): SymbolDefinition => + makeBaseDef({ nodeId: 'node:test', type: 'Class', ...overrides }); + +// --------------------------------------------------------------------------- +// Basic factory + table shape +// --------------------------------------------------------------------------- + +describe('createRegistrationTable', () => { + it('returns a Map with one entry per DISPATCH_LABELS value', () => { + const table = createRegistrationTable(makeDeps()); + expect(table.size).toBe(DISPATCH_LABELS.size); + for (const label of DISPATCH_LABELS) { + expect(table.has(label)).toBe(true); + } + }); + + it('every DISPATCH_LABELS entry maps to a hook function', () => { + const table = createRegistrationTable(makeDeps()); + for (const [, hook] of table) { + expect(typeof hook).toBe('function'); + } + }); +}); + +// --------------------------------------------------------------------------- +// Kind taxonomy exhaustiveness +// --------------------------------------------------------------------------- + +describe('NodeLabel taxonomy coverage', () => { + // ALL_NODE_LABELS is imported from model/index.ts (re-exported from + // semantic-model.ts) so that the production list and the test list + // cannot drift. If the shared NodeLabel union gains a new member, add + // it to the single list in semantic-model.ts AND to one of the + // registration-table allowlists in the same commit. + + it('every NodeLabel appears in exactly one of DISPATCH / CALLABLE_ONLY / INERT', () => { + for (const label of ALL_NODE_LABELS) { + const inDispatch = DISPATCH_LABELS.has(label); + const inCallableOnly = CALLABLE_ONLY_LABELS.has(label); + const inInert = INERT_LABELS.has(label); + const count = Number(inDispatch) + Number(inCallableOnly) + Number(inInert); + expect(count, `label ${label} must be in exactly one category`).toBe(1); + } + }); + + it('CALLABLE_ONLY_LABELS includes Function, Macro, Delegate', () => { + expect(CALLABLE_ONLY_LABELS.has('Function')).toBe(true); + expect(CALLABLE_ONLY_LABELS.has('Macro')).toBe(true); + expect(CALLABLE_ONLY_LABELS.has('Delegate')).toBe(true); + }); + + it('DISPATCH_LABELS includes all 10 routed kinds', () => { + const expected = [ + 'Class', + 'Struct', + 'Interface', + 'Enum', + 'Record', + 'Trait', + 'Method', + 'Constructor', + 'Property', + 'Impl', + ] as const; + for (const label of expected) { + expect(DISPATCH_LABELS.has(label)).toBe(true); + } + expect(DISPATCH_LABELS.size).toBe(expected.length); + }); + + it('INERT_LABELS includes metadata-only node kinds', () => { + expect(INERT_LABELS.has('File')).toBe(true); + expect(INERT_LABELS.has('Folder')).toBe(true); + expect(INERT_LABELS.has('Namespace')).toBe(true); + expect(INERT_LABELS.has('Variable')).toBe(true); + expect(INERT_LABELS.has('Import')).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Behavior group coverage — every label in a behavior group routes to the +// group's registry write, regardless of how hooks are implemented (shared +// closure, per-label closure, etc.). These tests survive an internal +// refactor to per-label closures for tracing/metrics — unlike +// reference-equality assertions on the hook functions themselves. +// --------------------------------------------------------------------------- + +describe('class-like behavior group — all 6 labels route to types.registerClass', () => { + const CLASS_LIKE_LABELS = ['Class', 'Struct', 'Interface', 'Enum', 'Record', 'Trait'] as const; + + for (const label of CLASS_LIKE_LABELS) { + it(`${label} writes to types.registerClass`, () => { + const deps = makeDeps(); + const table = createRegistrationTable(deps); + const def = makeDef({ + nodeId: `${label.toLowerCase()}:User`, + type: label, + qualifiedName: `app.User`, + }); + table.get(label)!('User', def); + expect(deps.types.lookupClassByName('User')).toHaveLength(1); + }); + } +}); + +describe('method-like behavior group — Method and Constructor route to methods.register', () => { + for (const label of ['Method', 'Constructor'] as const) { + it(`${label} writes to methods.register when ownerId is set`, () => { + const deps = makeDeps(); + const table = createRegistrationTable(deps); + const def = makeDef({ + nodeId: `${label.toLowerCase()}:save`, + type: label, + ownerId: 'class:User', + }); + table.get(label)!('save', def); + expect(deps.methods.lookupMethodByOwner('class:User', 'save')?.nodeId).toBe( + `${label.toLowerCase()}:save`, + ); + }); + } +}); + +describe('behavior group isolation', () => { + it('class-like hooks never touch methods or fields', () => { + const deps = makeDeps(); + const table = createRegistrationTable(deps); + const def = makeDef({ + nodeId: 'class:User', + type: 'Class', + ownerId: 'unrelated', + }); + table.get('Class')!('User', def); + // No method or field registered — class hook is isolated to types. + expect(deps.methods.lookupMethodByOwner('unrelated', 'User')).toBeUndefined(); + expect(deps.fields.lookupFieldByOwner('unrelated', 'User')).toBeUndefined(); + }); + + it('Impl hooks write to types.registerImpl, never to types.registerClass', () => { + const deps = makeDeps(); + const table = createRegistrationTable(deps); + const def = makeDef({ nodeId: 'impl:User', type: 'Impl' }); + table.get('Impl')!('User', def); + expect(deps.types.lookupImplByName('User')).toHaveLength(1); + expect(deps.types.lookupClassByName('User')).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// End-to-end hook behavior with real registries +// --------------------------------------------------------------------------- + +describe('hook behavior (real registries, no mocks)', () => { + it('classLikeHook writes to types.registerClass', () => { + const deps = makeDeps(); + const table = createRegistrationTable(deps); + const def = makeDef({ nodeId: 'class:User', type: 'Class', qualifiedName: 'app.User' }); + table.get('Class')!('User', def); + expect(deps.types.lookupClassByName('User')).toHaveLength(1); + expect(deps.types.lookupClassByQualifiedName('app.User')).toHaveLength(1); + }); + + it('classLikeHook falls back to the simple name when qualifiedName is absent', () => { + const deps = makeDeps(); + const table = createRegistrationTable(deps); + const def = makeDef({ nodeId: 'class:User', type: 'Class' }); + table.get('Class')!('User', def); + expect(deps.types.lookupClassByQualifiedName('User')).toHaveLength(1); + }); + + it('methodHook writes to methods.register when ownerId is set', () => { + const deps = makeDeps(); + const table = createRegistrationTable(deps); + const def = makeDef({ + nodeId: 'mtd:save', + type: 'Method', + ownerId: 'class:User', + }); + table.get('Method')!('save', def); + expect(deps.methods.lookupMethodByOwner('class:User', 'save')?.nodeId).toBe('mtd:save'); + }); + + it('methodHook silently skips registration when ownerId is missing', () => { + const deps = makeDeps(); + const table = createRegistrationTable(deps); + const def = makeDef({ nodeId: 'mtd:free', type: 'Method' }); + table.get('Method')!('free', def); + expect(deps.methods.lookupMethodByOwner('', 'free')).toBeUndefined(); + }); + + it('propertyHook writes to fields.register when ownerId is set', () => { + const deps = makeDeps(); + const table = createRegistrationTable(deps); + const def = makeDef({ + nodeId: 'prop:name', + type: 'Property', + ownerId: 'class:User', + declaredType: 'string', + }); + table.get('Property')!('name', def); + expect(deps.fields.lookupFieldByOwner('class:User', 'name')?.nodeId).toBe('prop:name'); + }); + + it('propertyHook silently skips registration when ownerId is missing', () => { + const deps = makeDeps(); + const table = createRegistrationTable(deps); + const def = makeDef({ nodeId: 'prop:orphan', type: 'Property' }); + table.get('Property')!('orphan', def); + expect(deps.fields.lookupFieldByOwner('', 'orphan')).toBeUndefined(); + }); + + it('implHook writes to types.registerImpl, NOT types.registerClass', () => { + const deps = makeDeps(); + const table = createRegistrationTable(deps); + const def = makeDef({ nodeId: 'impl:User', type: 'Impl' }); + table.get('Impl')!('User', def); + expect(deps.types.lookupImplByName('User')).toHaveLength(1); + // Critical: Impl must not pollute classByName — heritage resolution + // would otherwise treat an Impl as a parent type candidate. + expect(deps.types.lookupClassByName('User')).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// Factory-per-instance isolation +// --------------------------------------------------------------------------- + +describe('factory-per-instance isolation', () => { + it('two independent tables write to their own registries only', () => { + const depsA = makeDeps(); + const depsB = makeDeps(); + const tableA = createRegistrationTable(depsA); + const tableB = createRegistrationTable(depsB); + + tableA.get('Class')!('UserA', makeDef({ nodeId: 'class:UserA', type: 'Class' })); + tableB.get('Class')!('UserB', makeDef({ nodeId: 'class:UserB', type: 'Class' })); + + expect(depsA.types.lookupClassByName('UserA')).toHaveLength(1); + expect(depsA.types.lookupClassByName('UserB')).toHaveLength(0); + expect(depsB.types.lookupClassByName('UserB')).toHaveLength(1); + expect(depsB.types.lookupClassByName('UserA')).toHaveLength(0); + }); +}); diff --git a/gitnexus/test/unit/model/resolution-context.test.ts b/gitnexus/test/unit/model/resolution-context.test.ts new file mode 100644 index 000000000..aae0a5073 --- /dev/null +++ b/gitnexus/test/unit/model/resolution-context.test.ts @@ -0,0 +1,173 @@ +/** + * Unit tests for `ResolutionContext.resolve()` — the tiered name + * resolution that backs call-processor's Tier 1 / 2a-named / 2a / 2b / 3 + * pipeline. These tests pin invariants that TypeScript cannot prove at + * build time: tier precedence, cross-index dedup, and the + * walkBindingChain cycle/depth guards. + */ + +import { describe, it, expect } from 'vitest'; +import { createResolutionContext } from '../../../src/core/ingestion/model/resolution-context.js'; + +describe('ResolutionContext.resolve() — tier precedence', () => { + it('Tier 2a-named binding chain takes precedence over Tier 2a import-scoped', () => { + // Setup: A imports { User as U } from B. B defines both User (the + // real one) and U (an unrelated same-name symbol). A resolve('U') in + // file A must prefer the aliased binding chain (U → User in B), + // NOT the raw Tier 2a lookup that would find B's own 'U'. + const ctx = createResolutionContext(); + ctx.model.symbols.add('src/b.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/b.ts', 'U', 'class:U_decoy', 'Class'); + + // Register the import A → B and the aliased binding A.U → B.User. + ctx.importMap.set('src/a.ts', new Set(['src/b.ts'])); + const aliasBindings = new Map(); + aliasBindings.set('U', { sourcePath: 'src/b.ts', exportedName: 'User' }); + ctx.namedImportMap.set('src/a.ts', aliasBindings); + + const result = ctx.resolve('U', 'src/a.ts'); + expect(result).not.toBeNull(); + expect(result!.tier).toBe('import-scoped'); + // The named-binding chain resolves U → User, not U → U_decoy. + expect(result!.candidates.map((c) => c.nodeId)).toEqual(['class:User']); + }); + + it('Tier 1 (same-file) beats Tier 2a even when an aliased import exists', () => { + // Belt-and-suspenders check: if the caller's own file has a matching + // symbol, it wins — aliased bindings only fire when Tier 1 misses. + const ctx = createResolutionContext(); + ctx.model.symbols.add('src/a.ts', 'U', 'fn:local:U', 'Function'); + ctx.model.symbols.add('src/b.ts', 'User', 'class:User', 'Class'); + + const aliasBindings = new Map(); + aliasBindings.set('U', { sourcePath: 'src/b.ts', exportedName: 'User' }); + ctx.namedImportMap.set('src/a.ts', aliasBindings); + + const result = ctx.resolve('U', 'src/a.ts'); + expect(result!.tier).toBe('same-file'); + expect(result!.candidates[0].nodeId).toBe('fn:local:U'); + }); +}); + +describe('ResolutionContext.resolve() — Tier 3 dedup for Function+ownerId', () => { + it('Python/Rust/Kotlin class methods emitted as Function+ownerId land in only one Tier 3 result', () => { + // Simulate the Python worker path: a class method is emitted with + // type='Function' and ownerId set. `rawSymbols.add` lands it in + // callableByName (via the Function callable-index gate) AND + // `wrappedAdd` normalizes the dispatch key to 'Method' so it also + // lands in methodRegistry. The same SymbolDefinition reference is + // reachable via two Tier 3 lookups. + const ctx = createResolutionContext(); + ctx.model.symbols.add('src/user.py', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.py', 'greet', 'fn:User.greet', 'Function', { + ownerId: 'class:User', + returnType: 'str', + }); + + // Sanity check the setup: the same def is in both indexes. + expect(ctx.model.symbols.lookupCallableByName('greet')).toHaveLength(1); + expect(ctx.model.methods.lookupMethodByName('greet')).toHaveLength(1); + expect(ctx.model.methods.hasFunctionMethods).toBe(true); + + // Resolve a free 'greet' call from an unrelated file — Tier 1 / 2a / + // 2b all miss, so Tier 3 fires. The dedup pass must collapse the + // two index hits into a single candidate. + const result = ctx.resolve('greet', 'src/caller.py'); + expect(result).not.toBeNull(); + expect(result!.tier).toBe('global'); + expect(result!.candidates).toHaveLength(1); + expect(result!.candidates[0].nodeId).toBe('fn:User.greet'); + }); + + it('Tier 3 fast path fires when no Function+ownerId was ever registered', () => { + // Pure TypeScript-style: methods are emitted as strict Method labels, + // so callableByName and methodRegistry are disjoint and the dedup + // fast path can concat without a Set allocation. + const ctx = createResolutionContext(); + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'greet', 'method:User.greet', 'Method', { + ownerId: 'class:User', + returnType: 'string', + }); + ctx.model.symbols.add('src/utils.ts', 'greet', 'fn:utils.greet', 'Function'); + + expect(ctx.model.methods.hasFunctionMethods).toBe(false); + + // Tier 3 for 'greet' from an unrelated file returns both the free + // function and the class method; neither overlaps so no dedup. + const result = ctx.resolve('greet', 'src/caller.ts'); + expect(result).not.toBeNull(); + expect(result!.tier).toBe('global'); + expect(result!.candidates.map((c) => c.nodeId).sort()).toEqual([ + 'fn:utils.greet', + 'method:User.greet', + ]); + }); +}); + +describe('ResolutionContext.resolve() — walkBindingChain guards', () => { + it('circular re-export returns null (cycle detection fires)', () => { + // A imports { X } from B, B re-exports { X } from A. + // walkBindingChain must detect the cycle via the visited Set and + // return null instead of looping until depth exceeded. + const ctx = createResolutionContext(); + // Intentionally leave X undefined in both files — the walker only + // follows re-export edges, not definitions. + const aBindings = new Map(); + aBindings.set('X', { sourcePath: 'src/b.ts', exportedName: 'X' }); + ctx.namedImportMap.set('src/a.ts', aBindings); + const bBindings = new Map(); + bBindings.set('X', { sourcePath: 'src/a.ts', exportedName: 'X' }); + ctx.namedImportMap.set('src/b.ts', bBindings); + + const result = ctx.resolve('X', 'src/a.ts'); + // No definition anywhere in the chain → Tier 2a-named returns null, + // nothing else matches, overall result is null. + expect(result).toBeNull(); + }); + + it('chain deeper than MAX_BINDING_CHAIN_DEPTH drops the named-binding path', () => { + // Build a six-hop re-export chain where every hop just forwards the + // binding. walkBindingChain iterates 5 times and hits the depth cap + // before the sixth hop, returning null. No other tier can resolve + // 'X' either (no X is registered anywhere), so the overall + // `ctx.resolve` call returns null. + const ctx = createResolutionContext(); + const chain = [ + 'src/a.ts', + 'src/b.ts', + 'src/c.ts', + 'src/d.ts', + 'src/e.ts', + 'src/f.ts', + 'src/g.ts', + ]; + for (let i = 0; i < chain.length - 1; i++) { + const bindings = new Map(); + bindings.set('X', { sourcePath: chain[i + 1], exportedName: 'X' }); + ctx.namedImportMap.set(chain[i], bindings); + } + // No symbol registered in any file — the chain walk is the only + // possible resolution path, and the depth cap silently kills it. + const result = ctx.resolve('X', 'src/a.ts'); + expect(result).toBeNull(); + }); + + it('chain of exactly five hops resolves successfully at the boundary', () => { + // Five hops from A is exactly MAX_BINDING_CHAIN_DEPTH — the final + // lookup on the fifth hop must succeed. + const ctx = createResolutionContext(); + ctx.model.symbols.add('src/e.ts', 'X', 'class:X', 'Class'); + const chain = ['src/a.ts', 'src/b.ts', 'src/c.ts', 'src/d.ts', 'src/e.ts']; + for (let i = 0; i < chain.length - 1; i++) { + const bindings = new Map(); + bindings.set('X', { sourcePath: chain[i + 1], exportedName: 'X' }); + ctx.namedImportMap.set(chain[i], bindings); + } + + const result = ctx.resolve('X', 'src/a.ts'); + expect(result).not.toBeNull(); + expect(result!.tier).toBe('import-scoped'); + expect(result!.candidates[0].nodeId).toBe('class:X'); + }); +}); diff --git a/gitnexus/test/unit/model/semantic-model.test.ts b/gitnexus/test/unit/model/semantic-model.test.ts new file mode 100644 index 000000000..1e86ee5d3 --- /dev/null +++ b/gitnexus/test/unit/model/semantic-model.test.ts @@ -0,0 +1,124 @@ +/** + * Unit tests for SemanticModel factory and lifecycle. + * + * Focused on behaviors that are NOT covered by the transitive + * ingestion-pipeline tests in symbol-table.test.ts: + * + * 1. model.clear() must cascade to all four stores (types, methods, + * fields, rawSymbols). Post-A2 (plan 006 Unit 7), this is the only + * path that resets the leaf AND the registries. External consumers + * hold a SymbolTableReader which has no `clear()` method, so the + * phantom-resolution failure mode is statically impossible. + * + * 2. createSemanticModel() must construct successfully against the + * real ALL_NODE_LABELS and current registration-table allowlists. + * A failure here means the dev-time exhaustiveness guard is + * flagging real drift that needs a registration-table fix. + */ + +import { describe, it, expect } from 'vitest'; +import { createSemanticModel } from '../../../src/core/ingestion/model/semantic-model.js'; + +describe('createSemanticModel', () => { + it('constructs successfully — no drift between ALL_NODE_LABELS and the registration-table allowlists', () => { + expect(() => createSemanticModel()).not.toThrow(); + }); +}); + +describe('model.clear() cascade (A2 / Unit 7)', () => { + it('clears the type registry', () => { + const model = createSemanticModel(); + model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + + expect(model.types.lookupClassByName('User')).toHaveLength(1); + + model.clear(); + + expect(model.types.lookupClassByName('User')).toHaveLength(0); + }); + + it('clears the field registry', () => { + const model = createSemanticModel(); + model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + model.symbols.add('src/user.ts', 'name', 'prop:User.name', 'Property', { + ownerId: 'class:User', + declaredType: 'string', + }); + + expect(model.fields.lookupFieldByOwner('class:User', 'name')).toBeDefined(); + + model.clear(); + + expect(model.fields.lookupFieldByOwner('class:User', 'name')).toBeUndefined(); + }); + + it('clears the method registry', () => { + const model = createSemanticModel(); + model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + model.symbols.add('src/user.ts', 'greet', 'method:User.greet', 'Method', { + ownerId: 'class:User', + }); + + expect(model.methods.lookupMethodByOwner('class:User', 'greet')).toBeDefined(); + + model.clear(); + + expect(model.methods.lookupMethodByOwner('class:User', 'greet')).toBeUndefined(); + }); + + it('clears the file and callable indexes', () => { + const model = createSemanticModel(); + model.symbols.add('src/utils.ts', 'format', 'fn:format', 'Function'); + + expect(model.symbols.lookupCallableByName('format')).toHaveLength(1); + expect(Array.from(model.symbols.getFiles())).toContain('src/utils.ts'); + + model.clear(); + + expect(model.symbols.lookupCallableByName('format')).toHaveLength(0); + expect(Array.from(model.symbols.getFiles())).not.toContain('src/utils.ts'); + }); + + it('is idempotent — calling twice leaves every store empty', () => { + const model = createSemanticModel(); + model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + model.symbols.add('src/user.ts', 'name', 'prop:User.name', 'Property', { + ownerId: 'class:User', + }); + + model.clear(); + model.clear(); + + expect(model.types.lookupClassByName('User')).toHaveLength(0); + expect(model.fields.lookupFieldByOwner('class:User', 'name')).toBeUndefined(); + expect(model.symbols.lookupCallableByName('User')).toHaveLength(0); + }); + + it('post-A2: model.symbols exposes no clear() method', () => { + // Static guarantee enforced by the SymbolTableReader interface — this + // runtime assertion documents the contract. + const model = createSemanticModel(); + expect('clear' in model.symbols).toBe(false); + }); +}); + +describe('model.clear() cascade', () => { + it('clears every store — types, methods, fields, symbols', () => { + const model = createSemanticModel(); + model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + model.symbols.add('src/user.ts', 'name', 'prop:User.name', 'Property', { + ownerId: 'class:User', + }); + model.symbols.add('src/user.ts', 'greet', 'method:User.greet', 'Method', { + ownerId: 'class:User', + }); + + model.clear(); + + expect(model.types.lookupClassByName('User')).toHaveLength(0); + expect(model.fields.lookupFieldByOwner('class:User', 'name')).toBeUndefined(); + expect(model.methods.lookupMethodByOwner('class:User', 'greet')).toBeUndefined(); + expect(model.symbols.lookupCallableByName('User')).toHaveLength(0); + expect(Array.from(model.symbols.getFiles())).not.toContain('src/user.ts'); + }); +}); diff --git a/gitnexus/test/unit/model/type-registry.test.ts b/gitnexus/test/unit/model/type-registry.test.ts new file mode 100644 index 000000000..8f0eee838 --- /dev/null +++ b/gitnexus/test/unit/model/type-registry.test.ts @@ -0,0 +1,146 @@ +/** + * Unit tests for TypeRegistry (SM-20). + * + * TypeRegistry owns three indexes: classByName (simple name → defs), + * classByQualifiedName (FQN → defs), and implByName (Rust impl blocks). + * All three use array values to support homonym classes across files + * (e.g. two `User` classes in different packages) and Rust's multiple + * impl blocks per type. + */ + +import { describe, it, expect } from 'vitest'; +import { createTypeRegistry } from '../../../src/core/ingestion/model/type-registry.js'; +import type { SymbolDefinition } from '../../../src/core/ingestion/model/symbol-table.js'; +import { makeDef as makeBaseDef } from './helpers.js'; + +const makeDef = (overrides: Partial = {}): SymbolDefinition => + makeBaseDef({ nodeId: 'class:test', type: 'Class', ...overrides }); + +describe('TypeRegistry — classByName lookup', () => { + it('returns an empty array when the class is not registered', () => { + const reg = createTypeRegistry(); + expect(reg.lookupClassByName('Nonexistent')).toEqual([]); + }); + + it('returns the def reference after register', () => { + const reg = createTypeRegistry(); + const def = makeDef({ nodeId: 'class:User' }); + + reg.registerClass('User', 'app.User', def); + + expect(reg.lookupClassByName('User')).toEqual([def]); + }); + + it('accumulates homonym classes across files — second register appends, does not clobber', () => { + const reg = createTypeRegistry(); + const userApp = makeDef({ nodeId: 'class:app.User', filePath: 'src/app/user.ts' }); + const userAdmin = makeDef({ nodeId: 'class:admin.User', filePath: 'src/admin/user.ts' }); + + reg.registerClass('User', 'app.User', userApp); + reg.registerClass('User', 'admin.User', userAdmin); + + const result = reg.lookupClassByName('User'); + expect(result).toHaveLength(2); + expect(result.map((d) => d.nodeId)).toEqual(['class:app.User', 'class:admin.User']); + }); +}); + +describe('TypeRegistry — classByQualifiedName lookup', () => { + it('returns empty when the FQN is not registered', () => { + const reg = createTypeRegistry(); + expect(reg.lookupClassByQualifiedName('app.User')).toEqual([]); + }); + + it('returns the def after register', () => { + const reg = createTypeRegistry(); + const def = makeDef({ nodeId: 'class:app.User' }); + + reg.registerClass('User', 'app.User', def); + + expect(reg.lookupClassByQualifiedName('app.User')).toEqual([def]); + }); + + it('disambiguates homonym classes — same simple name, different FQNs resolve independently', () => { + const reg = createTypeRegistry(); + const userApp = makeDef({ nodeId: 'class:app.User' }); + const userAdmin = makeDef({ nodeId: 'class:admin.User' }); + + reg.registerClass('User', 'app.User', userApp); + reg.registerClass('User', 'admin.User', userAdmin); + + // Simple name returns both; qualified lookups split cleanly. + expect(reg.lookupClassByName('User')).toHaveLength(2); + expect(reg.lookupClassByQualifiedName('app.User')).toEqual([userApp]); + expect(reg.lookupClassByQualifiedName('admin.User')).toEqual([userAdmin]); + }); + + it('partial classes — two defs with the same FQN accumulate in both indexes', () => { + // C#-style partial classes: same simple and qualified name in different + // files. Both classByName and classByQualifiedName should return both. + const reg = createTypeRegistry(); + const partialA = makeDef({ nodeId: 'class:User#a', filePath: 'src/User.Core.cs' }); + const partialB = makeDef({ nodeId: 'class:User#b', filePath: 'src/User.Api.cs' }); + + reg.registerClass('User', 'app.User', partialA); + reg.registerClass('User', 'app.User', partialB); + + expect(reg.lookupClassByName('User')).toHaveLength(2); + expect(reg.lookupClassByQualifiedName('app.User')).toHaveLength(2); + }); +}); + +describe('TypeRegistry — implByName (Rust impl blocks)', () => { + it('returns empty when no impls registered', () => { + const reg = createTypeRegistry(); + expect(reg.lookupImplByName('User')).toEqual([]); + }); + + it('registerImpl stores Rust impl blocks separately from classes', () => { + const reg = createTypeRegistry(); + const userClass = makeDef({ nodeId: 'class:User', type: 'Struct' }); + const userImpl = makeDef({ nodeId: 'impl:User', type: 'Impl' }); + + reg.registerClass('User', 'crate::User', userClass); + reg.registerImpl('User', userImpl); + + expect(reg.lookupClassByName('User')).toEqual([userClass]); + expect(reg.lookupImplByName('User')).toEqual([userImpl]); + }); + + it('accumulates multiple impl blocks for the same type (Rust allows several)', () => { + const reg = createTypeRegistry(); + const implA = makeDef({ nodeId: 'impl:User#inherent', type: 'Impl' }); + const implB = makeDef({ nodeId: 'impl:User#Display', type: 'Impl' }); + + reg.registerImpl('User', implA); + reg.registerImpl('User', implB); + + const impls = reg.lookupImplByName('User'); + expect(impls).toHaveLength(2); + expect(impls.map((d) => d.nodeId)).toEqual(['impl:User#inherent', 'impl:User#Display']); + }); +}); + +describe('TypeRegistry — clear()', () => { + it('empties all three indexes', () => { + const reg = createTypeRegistry(); + reg.registerClass('User', 'app.User', makeDef()); + reg.registerImpl('User', makeDef({ type: 'Impl' })); + + reg.clear(); + + expect(reg.lookupClassByName('User')).toEqual([]); + expect(reg.lookupClassByQualifiedName('app.User')).toEqual([]); + expect(reg.lookupImplByName('User')).toEqual([]); + }); + + it('allows re-registration after clear', () => { + const reg = createTypeRegistry(); + reg.registerClass('User', 'app.User', makeDef({ nodeId: 'class:first' })); + reg.clear(); + reg.registerClass('User', 'app.User', makeDef({ nodeId: 'class:second' })); + + expect(reg.lookupClassByName('User')).toHaveLength(1); + expect(reg.lookupClassByName('User')[0].nodeId).toBe('class:second'); + }); +}); diff --git a/gitnexus/test/unit/sequential-language-availability.test.ts b/gitnexus/test/unit/sequential-language-availability.test.ts index 5b84f6702..3d804f93e 100644 --- a/gitnexus/test/unit/sequential-language-availability.test.ts +++ b/gitnexus/test/unit/sequential-language-availability.test.ts @@ -14,7 +14,7 @@ import { createASTCache } from '../../src/core/ingestion/ast-cache.js'; import { processImports } from '../../src/core/ingestion/import-processor.js'; import { processCalls } from '../../src/core/ingestion/call-processor.js'; import { processHeritage } from '../../src/core/ingestion/heritage-processor.js'; -import { createResolutionContext } from '../../src/core/ingestion/resolution-context.js'; +import { createResolutionContext } from '../../src/core/ingestion/model/resolution-context.js'; import * as parserLoader from '../../src/core/tree-sitter/parser-loader.js'; describe('sequential native parser availability', () => { diff --git a/gitnexus/test/unit/symbol-resolver.test.ts b/gitnexus/test/unit/symbol-resolver.test.ts index 67797e61f..dee9cdecc 100644 --- a/gitnexus/test/unit/symbol-resolver.test.ts +++ b/gitnexus/test/unit/symbol-resolver.test.ts @@ -2,9 +2,10 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { createResolutionContext, type ResolutionContext, -} from '../../src/core/ingestion/resolution-context.js'; -import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js'; -import { isFileInPackageDir } from '../../src/core/ingestion/import-processor.js'; +} from '../../src/core/ingestion/model/resolution-context.js'; +import { createSymbolTable } from '../../src/core/ingestion/model/symbol-table.js'; +import { createSemanticModel } from '../../src/core/ingestion/model/semantic-model.js'; +import { isFileInPackageDir } from '../../src/core/ingestion/model/resolution-context.js'; /** Helper: resolve to single best definition (refuses ambiguous global) */ const resolveOne = (ctx: ResolutionContext, name: string, fromFile: string) => { @@ -35,7 +36,7 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => { describe('Tier 1: Same-file resolution', () => { it('resolves symbol defined in the same file', () => { - ctx.symbols.add('src/models/user.ts', 'User', 'Class:src/models/user.ts:User', 'Class'); + ctx.model.symbols.add('src/models/user.ts', 'User', 'Class:src/models/user.ts:User', 'Class'); const result = resolveOne(ctx, 'User', 'src/models/user.ts'); @@ -46,8 +47,8 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => { }); it('prefers same-file over imported definition', () => { - ctx.symbols.add('src/local.ts', 'Config', 'Class:src/local.ts:Config', 'Class'); - ctx.symbols.add('src/shared.ts', 'Config', 'Class:src/shared.ts:Config', 'Class'); + ctx.model.symbols.add('src/local.ts', 'Config', 'Class:src/local.ts:Config', 'Class'); + ctx.model.symbols.add('src/shared.ts', 'Config', 'Class:src/shared.ts:Config', 'Class'); ctx.importMap.set('src/local.ts', new Set(['src/shared.ts'])); const result = resolveOne(ctx, 'Config', 'src/local.ts'); @@ -59,7 +60,7 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => { describe('Tier 2: Import-scoped resolution', () => { it('resolves symbol from an imported file', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'src/services/auth.ts', 'AuthService', 'Class:src/services/auth.ts:AuthService', @@ -75,13 +76,13 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => { }); it('prefers imported definition over non-imported with same name', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'src/services/logger.ts', 'Logger', 'Class:src/services/logger.ts:Logger', 'Class', ); - ctx.symbols.add( + ctx.model.symbols.add( 'src/testing/mock-logger.ts', 'Logger', 'Class:src/testing/mock-logger.ts:Logger', @@ -96,7 +97,7 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => { }); it('handles file with no imports — unique global falls through', () => { - ctx.symbols.add('src/utils.ts', 'Helper', 'Class:src/utils.ts:Helper', 'Class'); + ctx.model.symbols.add('src/utils.ts', 'Helper', 'Class:src/utils.ts:Helper', 'Class'); const result = resolveOne(ctx, 'Helper', 'src/app.ts'); @@ -107,7 +108,7 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => { describe('Tier 3: Global resolution', () => { it('resolves unique global when not in imports', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'src/external/base.ts', 'BaseModel', 'Class:src/external/base.ts:BaseModel', @@ -122,8 +123,8 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => { }); it('refuses ambiguous global — returns null when multiple candidates exist', () => { - ctx.symbols.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class'); - ctx.symbols.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class'); + ctx.model.symbols.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class'); + ctx.model.symbols.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class'); const result = resolveOne(ctx, 'Config', 'src/other.ts'); @@ -131,8 +132,8 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => { }); it('ctx.resolve returns all candidates at global tier (consumers decide)', () => { - ctx.symbols.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class'); - ctx.symbols.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class'); + ctx.model.symbols.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class'); + ctx.model.symbols.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class'); const tiered = ctx.resolve('Config', 'src/other.ts'); @@ -156,7 +157,7 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => { describe('type preservation', () => { it('preserves Interface type for heritage resolution', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'src/interfaces.ts', 'ILogger', 'Interface:src/interfaces.ts:ILogger', @@ -170,7 +171,7 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => { }); it('preserves Class type for heritage resolution', () => { - ctx.symbols.add('src/base.ts', 'BaseService', 'Class:src/base.ts:BaseService', 'Class'); + ctx.model.symbols.add('src/base.ts', 'BaseService', 'Class:src/base.ts:BaseService', 'Class'); ctx.importMap.set('src/app.ts', new Set(['src/base.ts'])); const result = resolveOne(ctx, 'BaseService', 'src/app.ts'); @@ -181,13 +182,13 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => { describe('heritage-specific scenarios', () => { it('resolves C# interface vs class ambiguity via imports', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'src/logging/ilogger.cs', 'ILogger', 'Interface:src/logging/ilogger.cs:ILogger', 'Interface', ); - ctx.symbols.add( + ctx.model.symbols.add( 'src/testing/ilogger.cs', 'ILogger', 'Class:src/testing/ilogger.cs:ILogger', @@ -202,13 +203,13 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => { }); it('resolves parent class from imported file for extends', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'src/api/controller.ts', 'UserController', 'Class:src/api/controller.ts:UserController', 'Class', ); - ctx.symbols.add( + ctx.model.symbols.add( 'src/base/controller.ts', 'BaseController', 'Class:src/base/controller.ts:BaseController', @@ -231,7 +232,7 @@ describe('ResolutionContext.resolve — tier metadata', () => { }); it('returns same-file tier for Tier 1 match', () => { - ctx.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); + ctx.model.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); const result = resolveInternal(ctx, 'Foo', 'src/a.ts'); @@ -242,8 +243,8 @@ describe('ResolutionContext.resolve — tier metadata', () => { }); it('returns import-scoped tier for Tier 2 match', () => { - ctx.symbols.add('src/logger.ts', 'Logger', 'Class:src/logger.ts:Logger', 'Class'); - ctx.symbols.add('src/mock.ts', 'Logger', 'Class:src/mock.ts:Logger', 'Class'); + ctx.model.symbols.add('src/logger.ts', 'Logger', 'Class:src/logger.ts:Logger', 'Class'); + ctx.model.symbols.add('src/mock.ts', 'Logger', 'Class:src/mock.ts:Logger', 'Class'); ctx.importMap.set('src/app.ts', new Set(['src/logger.ts'])); const result = resolveInternal(ctx, 'Logger', 'src/app.ts'); @@ -253,7 +254,7 @@ describe('ResolutionContext.resolve — tier metadata', () => { }); it('returns global tier for Tier 3 match', () => { - ctx.symbols.add('src/only.ts', 'Singleton', 'Class:src/only.ts:Singleton', 'Class'); + ctx.model.symbols.add('src/only.ts', 'Singleton', 'Class:src/only.ts:Singleton', 'Class'); const result = resolveInternal(ctx, 'Singleton', 'src/other.ts'); @@ -263,8 +264,8 @@ describe('ResolutionContext.resolve — tier metadata', () => { }); it('returns null for ambiguous global — refuses to guess', () => { - ctx.symbols.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class'); - ctx.symbols.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class'); + ctx.model.symbols.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class'); + ctx.model.symbols.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class'); const result = resolveInternal(ctx, 'Config', 'src/other.ts'); @@ -277,8 +278,8 @@ describe('ResolutionContext.resolve — tier metadata', () => { }); it('Tier 1 wins over Tier 2 — same-file takes priority', () => { - ctx.symbols.add('src/app.ts', 'Util', 'Function:src/app.ts:Util', 'Function'); - ctx.symbols.add('src/lib.ts', 'Util', 'Function:src/lib.ts:Util', 'Function'); + ctx.model.symbols.add('src/app.ts', 'Util', 'Function:src/app.ts:Util', 'Function'); + ctx.model.symbols.add('src/lib.ts', 'Util', 'Function:src/lib.ts:Util', 'Function'); ctx.importMap.set('src/app.ts', new Set(['src/lib.ts'])); const result = resolveInternal(ctx, 'Util', 'src/app.ts'); @@ -296,13 +297,13 @@ describe('negative tests — ambiguous refusal per language family', () => { }); it('TS/JS: two Logger definitions with no import → returns null', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'src/services/logger.ts', 'Logger', 'Class:src/services/logger.ts:Logger', 'Class', ); - ctx.symbols.add( + ctx.model.symbols.add( 'src/testing/logger.ts', 'Logger', 'Class:src/testing/logger.ts:Logger', @@ -314,13 +315,13 @@ describe('negative tests — ambiguous refusal per language family', () => { }); it('Java: same-named class in different packages, no import → returns null', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'com/example/models/User.java', 'User', 'Class:com/example/models/User.java:User', 'Class', ); - ctx.symbols.add( + ctx.model.symbols.add( 'com/example/dto/User.java', 'User', 'Class:com/example/dto/User.java:User', @@ -332,8 +333,8 @@ describe('negative tests — ambiguous refusal per language family', () => { }); it('C/C++: type defined in transitively-included header → returns null (not reachable via direct import)', () => { - ctx.symbols.add('src/c.h', 'Widget', 'Struct:src/c.h:Widget', 'Struct'); - ctx.symbols.add('src/d.h', 'Widget', 'Struct:src/d.h:Widget', 'Struct'); + ctx.model.symbols.add('src/c.h', 'Widget', 'Struct:src/c.h:Widget', 'Struct'); + ctx.model.symbols.add('src/d.h', 'Widget', 'Struct:src/d.h:Widget', 'Struct'); ctx.importMap.set('src/a.c', new Set(['src/b.h'])); const result = resolveOne(ctx, 'Widget', 'src/a.c'); @@ -341,13 +342,13 @@ describe('negative tests — ambiguous refusal per language family', () => { }); it('C#: two IService interfaces in different namespaces, no import → returns null', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'src/Services/IService.cs', 'IService', 'Interface:src/Services/IService.cs:IService', 'Interface', ); - ctx.symbols.add( + ctx.model.symbols.add( 'src/Testing/IService.cs', 'IService', 'Interface:src/Testing/IService.cs:IService', @@ -367,13 +368,13 @@ describe('heritage false-positive guard', () => { }); it('null from resolve prevents false edge — generateId fallback produces synthetic ID, not wrong match', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'src/api/base.ts', 'BaseController', 'Class:src/api/base.ts:BaseController', 'Class', ); - ctx.symbols.add( + ctx.model.symbols.add( 'src/testing/base.ts', 'BaseController', 'Class:src/testing/base.ts:BaseController', @@ -390,6 +391,14 @@ describe('heritage false-positive guard', () => { }); }); +// These two describe blocks (`lookupExactFull` and `SM-16: SymbolTable.getFiles()`) +// intentionally use `createSymbolTable()` directly instead of going through +// `createSemanticModel()`. The behaviors under test belong to the pure DAG +// leaf — file/callable indexes, getFiles iterator — and do not involve the +// owner-scoped registries. Testing them on the bare leaf keeps the unit +// isolated. Do not migrate these blocks to createSemanticModel() "for +// consistency" — that would add unused registry setup and weaken the +// isolation property. describe('lookupExactFull', () => { it('returns full SymbolDefinition for same-file lookup via O(1) direct storage', () => { const symbolTable = createSymbolTable(); @@ -476,7 +485,7 @@ describe('Tier 2b: PackageMap resolution (Go)', () => { }); it('resolves symbol via PackageMap when not in ImportMap', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'internal/auth/handler.go', 'HandleLogin', 'Function:internal/auth/handler.go:HandleLogin', @@ -492,7 +501,7 @@ describe('Tier 2b: PackageMap resolution (Go)', () => { }); it('does not resolve symbol from wrong package', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'internal/db/connection.go', 'Connect', 'Function:internal/db/connection.go:Connect', @@ -508,13 +517,13 @@ describe('Tier 2b: PackageMap resolution (Go)', () => { }); it('Tier 2a (ImportMap) takes precedence over Tier 2b (PackageMap)', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'internal/auth/handler.go', 'Validate', 'Function:internal/auth/handler.go:Validate', 'Function', ); - ctx.symbols.add( + ctx.model.symbols.add( 'internal/db/validator.go', 'Validate', 'Function:internal/db/validator.go:Validate', @@ -532,13 +541,13 @@ describe('Tier 2b: PackageMap resolution (Go)', () => { }); it('resolves both symbols in same imported package', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'internal/auth/handler.go', 'Run', 'Function:internal/auth/handler.go:Run', 'Function', ); - ctx.symbols.add( + ctx.model.symbols.add( 'internal/auth/worker.go', 'Run', 'Function:internal/auth/worker.go:Run', @@ -554,13 +563,18 @@ describe('Tier 2b: PackageMap resolution (Go)', () => { }); it('returns global without packageMap when ambiguous', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'internal/auth/handler.go', 'X', 'Function:internal/auth/handler.go:X', 'Function', ); - ctx.symbols.add('internal/db/handler.go', 'X', 'Function:internal/db/handler.go:X', 'Function'); + ctx.model.symbols.add( + 'internal/db/handler.go', + 'X', + 'Function:internal/db/handler.go:X', + 'Function', + ); const result = resolveInternal(ctx, 'X', 'cmd/main.go'); @@ -577,7 +591,7 @@ describe('per-file cache', () => { }); it('caches results per file', () => { - ctx.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); + ctx.model.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); ctx.enableCache('src/a.ts'); const r1 = ctx.resolve('Foo', 'src/a.ts'); @@ -591,7 +605,7 @@ describe('per-file cache', () => { }); it('resolve works without cache enabled', () => { - ctx.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); + ctx.model.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); const result = ctx.resolve('Foo', 'src/a.ts'); @@ -601,7 +615,7 @@ describe('per-file cache', () => { }); it('cache does not leak across files', () => { - ctx.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); + ctx.model.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); ctx.enableCache('src/a.ts'); ctx.resolve('Foo', 'src/a.ts'); // cached for a.ts @@ -627,8 +641,8 @@ describe('SM-16: Tier 2a — iterate importedFiles with lookupExactAll', () => { }); it('collects definitions from all imported files', () => { - ctx.symbols.add('src/a.ts', 'Widget', 'Class:src/a.ts:Widget', 'Class'); - ctx.symbols.add('src/b.ts', 'Widget', 'Class:src/b.ts:Widget', 'Class'); + ctx.model.symbols.add('src/a.ts', 'Widget', 'Class:src/a.ts:Widget', 'Class'); + ctx.model.symbols.add('src/b.ts', 'Widget', 'Class:src/b.ts:Widget', 'Class'); ctx.importMap.set('src/app.ts', new Set(['src/a.ts', 'src/b.ts'])); const result = ctx.resolve('Widget', 'src/app.ts'); @@ -640,8 +654,8 @@ describe('SM-16: Tier 2a — iterate importedFiles with lookupExactAll', () => { }); it('skips files with no matching symbol — no false positives', () => { - ctx.symbols.add('src/a.ts', 'Widget', 'Class:src/a.ts:Widget', 'Class'); - ctx.symbols.add('src/b.ts', 'Button', 'Class:src/b.ts:Button', 'Class'); + ctx.model.symbols.add('src/a.ts', 'Widget', 'Class:src/a.ts:Widget', 'Class'); + ctx.model.symbols.add('src/b.ts', 'Button', 'Class:src/b.ts:Button', 'Class'); ctx.importMap.set('src/app.ts', new Set(['src/a.ts', 'src/b.ts'])); const result = ctx.resolve('Widget', 'src/app.ts'); @@ -652,8 +666,8 @@ describe('SM-16: Tier 2a — iterate importedFiles with lookupExactAll', () => { it('returns all overloads from a single imported file', () => { // Same-name method overloads in one file - ctx.symbols.add('src/math.ts', 'add', 'fn:math:add:0', 'Function', { parameterCount: 1 }); - ctx.symbols.add('src/math.ts', 'add', 'fn:math:add:2', 'Function', { parameterCount: 2 }); + ctx.model.symbols.add('src/math.ts', 'add', 'fn:math:add:0', 'Function', { parameterCount: 1 }); + ctx.model.symbols.add('src/math.ts', 'add', 'fn:math:add:2', 'Function', { parameterCount: 2 }); ctx.importMap.set('src/app.ts', new Set(['src/math.ts'])); const result = ctx.resolve('add', 'src/app.ts'); @@ -663,7 +677,7 @@ describe('SM-16: Tier 2a — iterate importedFiles with lookupExactAll', () => { }); it('Java: resolves class from import via lookupExactAll per file', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'com/example/models/User.java', 'User', 'Class:com/example/models/User.java:User', @@ -681,7 +695,7 @@ describe('SM-16: Tier 2a — iterate importedFiles with lookupExactAll', () => { }); it('Python: resolves function from imported module file', () => { - ctx.symbols.add('models.py', 'User', 'Class:models.py:User', 'Class'); + ctx.model.symbols.add('models.py', 'User', 'Class:models.py:User', 'Class'); ctx.importMap.set('app.py', new Set(['models.py'])); const result = ctx.resolve('User', 'app.py'); @@ -691,7 +705,7 @@ describe('SM-16: Tier 2a — iterate importedFiles with lookupExactAll', () => { }); it('C#: resolves interface from imported file', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'src/Services/IService.cs', 'IService', 'Interface:src/Services/IService.cs:IService', @@ -707,7 +721,7 @@ describe('SM-16: Tier 2a — iterate importedFiles with lookupExactAll', () => { it('TypeScript: resolves re-exported class via named binding chain', () => { // index.ts re-exports User from models.ts - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); ctx.namedImportMap.set( 'src/index.ts', new Map([['User', { sourcePath: 'src/models.ts', exportedName: 'User' }]]), @@ -733,13 +747,13 @@ describe('SM-16: Tier 2b — iterate getFiles() + isFileInPackageDir', () => { }); it('Go: resolves symbol in package dir via file iteration', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'internal/auth/handler.go', 'Authenticate', 'Function:internal/auth/handler.go:Authenticate', 'Function', ); - ctx.symbols.add( + ctx.model.symbols.add( 'internal/db/repo.go', 'Authenticate', 'Function:internal/db/repo.go:Authenticate', @@ -755,8 +769,13 @@ describe('SM-16: Tier 2b — iterate getFiles() + isFileInPackageDir', () => { }); it('C#: resolves class from namespace directory', () => { - ctx.symbols.add('MyApp/Models/User.cs', 'User', 'Class:MyApp/Models/User.cs:User', 'Class'); - ctx.symbols.add('MyApp/Other/User.cs', 'User', 'Class:MyApp/Other/User.cs:User', 'Class'); + ctx.model.symbols.add( + 'MyApp/Models/User.cs', + 'User', + 'Class:MyApp/Models/User.cs:User', + 'Class', + ); + ctx.model.symbols.add('MyApp/Other/User.cs', 'User', 'Class:MyApp/Other/User.cs:User', 'Class'); ctx.packageMap.set('MyApp/Controllers/UserController.cs', new Set(['/MyApp/Models/'])); const result = ctx.resolve('User', 'MyApp/Controllers/UserController.cs'); @@ -767,13 +786,13 @@ describe('SM-16: Tier 2b — iterate getFiles() + isFileInPackageDir', () => { }); it('Tier 2a (ImportMap) still takes precedence over Tier 2b (PackageMap)', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'internal/auth/handler.go', 'Validate', 'Function:internal/auth/handler.go:Validate', 'Function', ); - ctx.symbols.add( + ctx.model.symbols.add( 'internal/db/validator.go', 'Validate', 'Function:internal/db/validator.go:Validate', @@ -797,7 +816,7 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup }); it('returns class-like symbol (Class) at global tier', () => { - ctx.symbols.add('src/user.ts', 'User', 'Class:src/user.ts:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'Class:src/user.ts:User', 'Class'); const result = ctx.resolve('User', 'src/app.ts'); @@ -806,7 +825,12 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup }); it('returns callable symbol (Function) at global tier', () => { - ctx.symbols.add('src/utils.ts', 'parseDate', 'Function:src/utils.ts:parseDate', 'Function'); + ctx.model.symbols.add( + 'src/utils.ts', + 'parseDate', + 'Function:src/utils.ts:parseDate', + 'Function', + ); const result = ctx.resolve('parseDate', 'src/app.ts'); @@ -815,8 +839,8 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup }); it('returns both Class and Function with the same name at global tier', () => { - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/factories.ts', 'User', 'Function:src/factories.ts:User', 'Function'); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/factories.ts', 'User', 'Function:src/factories.ts:User', 'Function'); const result = ctx.resolve('User', 'src/app.ts'); @@ -827,8 +851,8 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup }); it('Rust: returns Impl node at global tier (needed for method resolution)', () => { - ctx.symbols.add('src/user.rs', 'User', 'Struct:src/user.rs:User', 'Struct'); - ctx.symbols.add('src/user.rs', 'User', 'Impl:src/user.rs:User', 'Impl'); + ctx.model.symbols.add('src/user.rs', 'User', 'Struct:src/user.rs:User', 'Struct'); + ctx.model.symbols.add('src/user.rs', 'User', 'Impl:src/user.rs:User', 'Impl'); const result = ctx.resolve('User', 'src/main.rs'); @@ -839,22 +863,24 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup }); it('Rust: Impl is separate from Class-like types — does not affect heritage (lookupClassByName)', () => { - const table = createSymbolTable(); - table.add('src/user.rs', 'User', 'Struct:src/user.rs:User', 'Struct'); - table.add('src/user.rs', 'User', 'Impl:src/user.rs:User', 'Impl'); + // SM-23 DAG: registry lookups go through SemanticModel; SymbolTable + // is a pure leaf with no registry knowledge. + const model = createSemanticModel(); + model.symbols.add('src/user.rs', 'User', 'Struct:src/user.rs:User', 'Struct'); + model.symbols.add('src/user.rs', 'User', 'Impl:src/user.rs:User', 'Impl'); // lookupClassByName excludes Impl (preserves heritage resolution correctness) - const classDefs = table.lookupClassByName('User'); + const classDefs = model.types.lookupClassByName('User'); expect(classDefs.map((d) => d.type)).toEqual(['Struct']); // lookupImplByName returns only Impl nodes - const implDefs = table.lookupImplByName('User'); + const implDefs = model.types.lookupImplByName('User'); expect(implDefs.map((d) => d.type)).toEqual(['Impl']); }); it('ambiguous global returns all candidates (consumers decide)', () => { - ctx.symbols.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class'); - ctx.symbols.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class'); + ctx.model.symbols.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class'); + ctx.model.symbols.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class'); const result = ctx.resolve('Config', 'src/other.ts'); @@ -862,13 +888,30 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup expect(result!.candidates.length).toBe(2); }); + it('A4 intermediate: Method reachable via both callable and method indexes dedups to one Tier 3 candidate', () => { + // A method with an owner lands in callableByName (because Method is + // still in FREE_CALLABLE_TYPES during the Unit 3 intermediate state) AND in + // methodsByName (because A4 Unit 2 dual-indexes every method + // registration). Tier 3 must dedup by nodeId so consumers see each + // method exactly once. + ctx.model.symbols.add('src/user.ts', 'save', 'Method:src/user.ts:User.save', 'Method', { + ownerId: 'Class:src/user.ts:User', + }); + + const result = ctx.resolve('save', 'src/app.ts'); + + expect(result!.tier).toBe('global'); + const nodeIds = result!.candidates.map((c) => c.nodeId); + expect(nodeIds).toEqual(['Method:src/user.ts:User.save']); + }); + it('returns null when no symbol exists at any tier', () => { const result = ctx.resolve('NonExistent', 'src/app.ts'); expect(result).toBeNull(); }); it('TypeScript: resolves Enum at global tier', () => { - ctx.symbols.add('src/status.ts', 'Status', 'Enum:src/status.ts:Status', 'Enum'); + ctx.model.symbols.add('src/status.ts', 'Status', 'Enum:src/status.ts:Status', 'Enum'); const result = ctx.resolve('Status', 'src/app.ts'); @@ -877,7 +920,7 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup }); it('Kotlin: resolves data class (Record) at global tier', () => { - ctx.symbols.add('src/User.kt', 'User', 'Record:src/User.kt:User', 'Record'); + ctx.model.symbols.add('src/User.kt', 'User', 'Record:src/User.kt:User', 'Record'); const result = ctx.resolve('User', 'src/Main.kt'); @@ -886,7 +929,12 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup }); it('PHP: resolves Trait at global tier', () => { - ctx.symbols.add('src/Loggable.php', 'Loggable', 'Trait:src/Loggable.php:Loggable', 'Trait'); + ctx.model.symbols.add( + 'src/Loggable.php', + 'Loggable', + 'Trait:src/Loggable.php:Loggable', + 'Trait', + ); const result = ctx.resolve('Loggable', 'src/App.php'); @@ -895,7 +943,7 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup }); it('Java: resolves Interface at global tier', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'com/example/IService.java', 'IService', 'Interface:com/example/IService.java:IService', @@ -909,7 +957,7 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup }); it('Go: resolves Struct at global tier', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'internal/model/user.go', 'User', 'Struct:internal/model/user.go:User', @@ -954,7 +1002,7 @@ describe('SM-16: SymbolTable.getFiles()', () => { describe('SM-16: walkBindingChain — no allDefs parameter', () => { it('resolves non-aliased import via lookupExactAll at depth=0', () => { const ctx = createResolutionContext(); - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); ctx.namedImportMap.set( 'src/app.ts', new Map([['User', { sourcePath: 'src/models.ts', exportedName: 'User' }]]), @@ -968,7 +1016,7 @@ describe('SM-16: walkBindingChain — no allDefs parameter', () => { it('resolves aliased import (U → User) via chain walk', () => { const ctx = createResolutionContext(); - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); ctx.namedImportMap.set( 'src/app.ts', new Map([['U', { sourcePath: 'src/models.ts', exportedName: 'User' }]]), @@ -982,7 +1030,7 @@ describe('SM-16: walkBindingChain — no allDefs parameter', () => { it('follows re-export chain A → B → C', () => { const ctx = createResolutionContext(); - ctx.symbols.add('src/models.ts', 'Widget', 'Class:src/models.ts:Widget', 'Class'); + ctx.model.symbols.add('src/models.ts', 'Widget', 'Class:src/models.ts:Widget', 'Class'); // B re-exports Widget from C ctx.namedImportMap.set( 'src/index.ts', @@ -1011,26 +1059,31 @@ describe('SM-16: Tier 3 — TypeAlias, Const, Variable are NOT returned', () => }); it('TypeAlias is not reachable at Tier 3', () => { - ctx.symbols.add('src/types.ts', 'Handler', 'TypeAlias:src/types.ts:Handler', 'TypeAlias'); + ctx.model.symbols.add('src/types.ts', 'Handler', 'TypeAlias:src/types.ts:Handler', 'TypeAlias'); const result = ctx.resolve('Handler', 'src/app.ts'); expect(result).toBeNull(); }); it('Const is not reachable at Tier 3', () => { - ctx.symbols.add('src/config.ts', 'MAX_RETRIES', 'Const:src/config.ts:MAX_RETRIES', 'Const'); + ctx.model.symbols.add( + 'src/config.ts', + 'MAX_RETRIES', + 'Const:src/config.ts:MAX_RETRIES', + 'Const', + ); const result = ctx.resolve('MAX_RETRIES', 'src/app.ts'); expect(result).toBeNull(); }); it('Variable is not reachable at Tier 3', () => { - ctx.symbols.add('src/state.ts', 'counter', 'Variable:src/state.ts:counter', 'Variable'); + ctx.model.symbols.add('src/state.ts', 'counter', 'Variable:src/state.ts:counter', 'Variable'); const result = ctx.resolve('counter', 'src/app.ts'); expect(result).toBeNull(); }); it('Class-like and callable ARE reachable at Tier 3 (control)', () => { - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/utils.ts', 'getUser', 'Function:src/utils.ts:getUser', 'Function'); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/utils.ts', 'getUser', 'Function:src/utils.ts:getUser', 'Function'); const classResult = ctx.resolve('User', 'src/app.ts'); expect(classResult).not.toBeNull(); @@ -1042,7 +1095,7 @@ describe('SM-16: Tier 3 — TypeAlias, Const, Variable are NOT returned', () => }); it('Macro (C/C++) is reachable at Tier 3 via callable index', () => { - ctx.symbols.add('src/macros.h', 'ASSERT', 'Macro:src/macros.h:ASSERT', 'Macro'); + ctx.model.symbols.add('src/macros.h', 'ASSERT', 'Macro:src/macros.h:ASSERT', 'Macro'); const result = ctx.resolve('ASSERT', 'src/main.c'); expect(result).not.toBeNull(); expect(result!.tier).toBe('global'); @@ -1050,7 +1103,7 @@ describe('SM-16: Tier 3 — TypeAlias, Const, Variable are NOT returned', () => }); it('Delegate (C#) is reachable at Tier 3 via callable index', () => { - ctx.symbols.add('src/Events.cs', 'OnClick', 'Delegate:src/Events.cs:OnClick', 'Delegate'); + ctx.model.symbols.add('src/Events.cs', 'OnClick', 'Delegate:src/Events.cs:OnClick', 'Delegate'); const result = ctx.resolve('OnClick', 'src/App.cs'); expect(result).not.toBeNull(); expect(result!.tier).toBe('global'); @@ -1064,7 +1117,7 @@ describe('SM-16: Tier 2b — packageDirIndex picks up symbols added after clear( it('resolves newly added symbol after clear() resets the index', () => { const ctx = createResolutionContext(); // Initial setup: one symbol in package dir - ctx.symbols.add('pkg/models/user.go', 'User', 'Struct:pkg/models/user.go:User', 'Struct'); + ctx.model.symbols.add('pkg/models/user.go', 'User', 'Struct:pkg/models/user.go:User', 'Struct'); ctx.packageMap.set('cmd/main.go', new Set(['/pkg/models/'])); // Prime the packageDirIndex via a Tier 2b resolution @@ -1075,8 +1128,13 @@ describe('SM-16: Tier 2b — packageDirIndex picks up symbols added after clear( ctx.clear(); // Re-add symbols with a NEW file in the package dir - ctx.symbols.add('pkg/models/user.go', 'User', 'Struct:pkg/models/user.go:User', 'Struct'); - ctx.symbols.add('pkg/models/order.go', 'Order', 'Struct:pkg/models/order.go:Order', 'Struct'); + ctx.model.symbols.add('pkg/models/user.go', 'User', 'Struct:pkg/models/user.go:User', 'Struct'); + ctx.model.symbols.add( + 'pkg/models/order.go', + 'Order', + 'Struct:pkg/models/order.go:Order', + 'Struct', + ); ctx.packageMap.set('cmd/main.go', new Set(['/pkg/models/'])); // The new symbol must be visible — packageDirIndex was invalidated by clear() @@ -1092,8 +1150,8 @@ describe('SM-16: Tier 2b — packageDirIndex picks up symbols added after clear( describe('SM-16: Tier 2b — Rust package-scoped resolution', () => { it('resolves struct in package dir via Tier 2b', () => { const ctx = createResolutionContext(); - ctx.symbols.add('src/models/user.rs', 'User', 'Struct:src/models/user.rs:User', 'Struct'); - ctx.symbols.add('src/other/user.rs', 'User', 'Struct:src/other/user.rs:User', 'Struct'); + ctx.model.symbols.add('src/models/user.rs', 'User', 'Struct:src/models/user.rs:User', 'Struct'); + ctx.model.symbols.add('src/other/user.rs', 'User', 'Struct:src/other/user.rs:User', 'Struct'); ctx.packageMap.set('src/main.rs', new Set(['/src/models/'])); const result = ctx.resolve('User', 'src/main.rs'); @@ -1106,8 +1164,18 @@ describe('SM-16: Tier 2b — Rust package-scoped resolution', () => { describe('SM-16: Tier 2b — Kotlin package-scoped resolution', () => { it('resolves class in package dir via Tier 2b', () => { const ctx = createResolutionContext(); - ctx.symbols.add('com/app/models/User.kt', 'User', 'Class:com/app/models/User.kt:User', 'Class'); - ctx.symbols.add('com/app/other/User.kt', 'User', 'Class:com/app/other/User.kt:User', 'Class'); + ctx.model.symbols.add( + 'com/app/models/User.kt', + 'User', + 'Class:com/app/models/User.kt:User', + 'Class', + ); + ctx.model.symbols.add( + 'com/app/other/User.kt', + 'User', + 'Class:com/app/other/User.kt:User', + 'Class', + ); ctx.packageMap.set('com/app/Main.kt', new Set(['/com/app/models/'])); const result = ctx.resolve('User', 'com/app/Main.kt'); @@ -1120,8 +1188,8 @@ describe('SM-16: Tier 2b — Kotlin package-scoped resolution', () => { describe('SM-16: Tier 2b — PHP namespace directory resolution', () => { it('resolves class in namespace dir via Tier 2b', () => { const ctx = createResolutionContext(); - ctx.symbols.add('app/Models/User.php', 'User', 'Class:app/Models/User.php:User', 'Class'); - ctx.symbols.add('app/Other/User.php', 'User', 'Class:app/Other/User.php:User', 'Class'); + ctx.model.symbols.add('app/Models/User.php', 'User', 'Class:app/Models/User.php:User', 'Class'); + ctx.model.symbols.add('app/Other/User.php', 'User', 'Class:app/Other/User.php:User', 'Class'); ctx.packageMap.set('app/Controllers/UserController.php', new Set(['/app/Models/'])); const result = ctx.resolve('User', 'app/Controllers/UserController.php'); diff --git a/gitnexus/test/unit/symbol-table.test.ts b/gitnexus/test/unit/symbol-table.test.ts index 0f12851d6..1b8490fce 100644 --- a/gitnexus/test/unit/symbol-table.test.ts +++ b/gitnexus/test/unit/symbol-table.test.ts @@ -1,11 +1,24 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { createSymbolTable, type SymbolTable } from '../../src/core/ingestion/symbol-table.js'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { SymbolTableWriter } from '../../src/core/ingestion/model/symbol-table.js'; +import { + createSemanticModel, + type MutableSemanticModel, +} from '../../src/core/ingestion/model/semantic-model.js'; describe('SymbolTable', () => { - let table: SymbolTable; + // SM-23 DAG: SymbolTable is now a pure leaf with no registry knowledge. + // Tests that exercise owner-scoped lookups (lookupClassByName, + // lookupMethodByOwner, lookupFieldByOwner, lookupClassByQualifiedName, + // lookupImplByName) must go through SemanticModel which composes + // SymbolTable with the registries. We build a model and alias + // `table = model.symbols` so the 200+ file/callable test cases keep + // their existing call sites unchanged. + let model: MutableSemanticModel; + let table: SymbolTableWriter; beforeEach(() => { - table = createSymbolTable(); + model = createSemanticModel(); + table = model.symbols; }); describe('add', () => { @@ -151,7 +164,7 @@ describe('SymbolTable', () => { // No declaredType → still indexed in fieldByOwner (for write-access tracking // in dynamically-typed languages like Ruby/JS), but excluded from callable index expect(table.lookupCallableByName('name')).toEqual([]); - expect(table.lookupFieldByOwner('class:User', 'name')).toEqual({ + expect(model.fields.lookupFieldByOwner('class:User', 'name')).toEqual({ nodeId: 'prop:name', filePath: 'src/models.ts', type: 'Property', @@ -159,9 +172,12 @@ describe('SymbolTable', () => { }); }); - it('non-Property callable types are in callable index', () => { + it('post-A4: Method with ownerId lands in methodsByName, not callableByName', () => { + // Plan 006 Unit 4 shrank FREE_CALLABLE_TYPES to free callables only. + // Method registrations now flow through the method registry. table.add('src/models.ts', 'save', 'method:save', 'Method', { ownerId: 'class:User' }); - expect(table.lookupCallableByName('save')).toHaveLength(1); + expect(table.lookupCallableByName('save')).toHaveLength(0); + expect(model.methods.lookupMethodByName('save')).toHaveLength(1); }); }); @@ -169,9 +185,9 @@ describe('SymbolTable', () => { it('adding a Function makes it available in callable index', () => { table.add('src/a.ts', 'foo', 'func:foo', 'Function', { returnType: 'void' }); expect(table.lookupCallableByName('foo')).toHaveLength(1); - // Add another callable - table.add('src/a.ts', 'bar', 'func:bar', 'Method'); - expect(table.lookupCallableByName('bar')).toHaveLength(1); + // Free Macro is a callable (C/C++ preprocessor macro). + table.add('src/macros.h', 'BAR', 'macro:BAR', 'Macro'); + expect(table.lookupCallableByName('BAR')).toHaveLength(1); }); it('adding a Property does NOT add it to callable index', () => { @@ -204,6 +220,43 @@ describe('SymbolTable', () => { expect(table.lookupCallableByName('OnClick')).toHaveLength(1); expect(table.lookupCallableByName('OnClick')[0].type).toBe('Delegate'); }); + + it('Method WITHOUT ownerId falls back to the callable index', () => { + // Orphaned Method (extractor contract violation / degraded AST). + // The dispatch hook silently skips it because it has no owner to + // key under; the callable-index fallback keeps it reachable at + // Tier 3 global resolution. + table.add('src/a.ts', 'orphan', 'method:orphan', 'Method'); + expect(table.lookupCallableByName('orphan')).toHaveLength(1); + expect(table.lookupCallableByName('orphan')[0].type).toBe('Method'); + }); + + it('Constructor WITHOUT ownerId falls back to the callable index', () => { + table.add('src/a.ts', 'Orphan', 'ctor:Orphan', 'Constructor'); + expect(table.lookupCallableByName('Orphan')).toHaveLength(1); + expect(table.lookupCallableByName('Orphan')[0].type).toBe('Constructor'); + }); + + it('Method WITH ownerId does NOT land in the callable index (goes to MethodRegistry instead)', () => { + table.add('src/user.ts', 'greet', 'method:User.greet', 'Method', { + ownerId: 'class:User', + }); + expect(table.lookupCallableByName('greet')).toHaveLength(0); + }); + + it('Constructor WITH ownerId does NOT land in the callable index', () => { + table.add('src/user.ts', 'User', 'ctor:User', 'Constructor', { + ownerId: 'class:User', + }); + expect(table.lookupCallableByName('User')).toHaveLength(0); + }); + + it('Property WITHOUT ownerId still does NOT fall back to the callable index', () => { + // Property fallback would pollute common names like `id` / `name` / + // `type` — kept disjoint from the Method/Constructor fallback. + table.add('src/a.ts', 'orphanField', 'prop:orphan', 'Property'); + expect(table.lookupCallableByName('orphanField')).toHaveLength(0); + }); }); describe('lookupFieldByOwner', () => { @@ -212,7 +265,7 @@ describe('SymbolTable', () => { declaredType: 'Address', ownerId: 'class:User', }); - const def = table.lookupFieldByOwner('class:User', 'address'); + const def = model.fields.lookupFieldByOwner('class:User', 'address'); expect(def).toBeDefined(); expect(def!.declaredType).toBe('Address'); expect(def!.nodeId).toBe('prop:address'); @@ -223,7 +276,7 @@ describe('SymbolTable', () => { declaredType: 'Address', ownerId: 'class:User', }); - expect(table.lookupFieldByOwner('class:Unknown', 'address')).toBeUndefined(); + expect(model.fields.lookupFieldByOwner('class:Unknown', 'address')).toBeUndefined(); }); it('returns undefined for unknown field name', () => { @@ -231,16 +284,16 @@ describe('SymbolTable', () => { declaredType: 'Address', ownerId: 'class:User', }); - expect(table.lookupFieldByOwner('class:User', 'email')).toBeUndefined(); + expect(model.fields.lookupFieldByOwner('class:User', 'email')).toBeUndefined(); }); it('returns undefined for empty table', () => { - expect(table.lookupFieldByOwner('class:User', 'name')).toBeUndefined(); + expect(model.fields.lookupFieldByOwner('class:User', 'name')).toBeUndefined(); }); it('indexes Property without declaredType (for dynamic language write-access)', () => { table.add('src/models.ts', 'name', 'prop:name', 'Property', { ownerId: 'class:User' }); - expect(table.lookupFieldByOwner('class:User', 'name')).toEqual({ + expect(model.fields.lookupFieldByOwner('class:User', 'name')).toEqual({ nodeId: 'prop:name', filePath: 'src/models.ts', type: 'Property', @@ -257,8 +310,8 @@ describe('SymbolTable', () => { declaredType: 'RepoName', ownerId: 'class:Repo', }); - expect(table.lookupFieldByOwner('class:User', 'name')!.declaredType).toBe('string'); - expect(table.lookupFieldByOwner('class:Repo', 'name')!.declaredType).toBe('RepoName'); + expect(model.fields.lookupFieldByOwner('class:User', 'name')!.declaredType).toBe('string'); + expect(model.fields.lookupFieldByOwner('class:Repo', 'name')!.declaredType).toBe('RepoName'); }); }); @@ -268,7 +321,7 @@ describe('SymbolTable', () => { returnType: 'Address', ownerId: 'class:User', }); - const def = table.lookupMethodByOwner('class:User', 'getAddress'); + const def = model.methods.lookupMethodByOwner('class:User', 'getAddress'); expect(def).toBeDefined(); expect(def!.returnType).toBe('Address'); expect(def!.nodeId).toBe('method:getAddress'); @@ -283,8 +336,10 @@ describe('SymbolTable', () => { returnType: 'String', ownerId: 'class:User', }); - expect(table.lookupMethodByOwner('class:User', 'getAddress')!.returnType).toBe('Address'); - expect(table.lookupMethodByOwner('class:User', 'getName')!.returnType).toBe('String'); + expect(model.methods.lookupMethodByOwner('class:User', 'getAddress')!.returnType).toBe( + 'Address', + ); + expect(model.methods.lookupMethodByOwner('class:User', 'getName')!.returnType).toBe('String'); }); it('distinguishes methods by owner', () => { @@ -296,8 +351,10 @@ describe('SymbolTable', () => { returnType: 'void', ownerId: 'class:Address', }); - expect(table.lookupMethodByOwner('class:User', 'save')!.nodeId).toBe('method:user:save'); - expect(table.lookupMethodByOwner('class:Address', 'save')!.nodeId).toBe( + expect(model.methods.lookupMethodByOwner('class:User', 'save')!.nodeId).toBe( + 'method:user:save', + ); + expect(model.methods.lookupMethodByOwner('class:Address', 'save')!.nodeId).toBe( 'method:address:save', ); }); @@ -307,7 +364,7 @@ describe('SymbolTable', () => { returnType: 'void', ownerId: 'class:User', }); - expect(table.lookupMethodByOwner('class:Unknown', 'save')).toBeUndefined(); + expect(model.methods.lookupMethodByOwner('class:Unknown', 'save')).toBeUndefined(); }); it('returns undefined for unknown method name', () => { @@ -315,18 +372,24 @@ describe('SymbolTable', () => { returnType: 'void', ownerId: 'class:User', }); - expect(table.lookupMethodByOwner('class:User', 'delete')).toBeUndefined(); + expect(model.methods.lookupMethodByOwner('class:User', 'delete')).toBeUndefined(); }); it('returns undefined for empty table', () => { - expect(table.lookupMethodByOwner('class:User', 'save')).toBeUndefined(); + expect(model.methods.lookupMethodByOwner('class:User', 'save')).toBeUndefined(); }); - it('does NOT index Method without ownerId', () => { + it('Method without ownerId is not in MethodRegistry but falls back to callable index', () => { + // methodHook silently skips Method-without-ownerId (methods.register + // requires an owner). The orphan-owner-scoped fallback in + // `SymbolTable.add()` routes such defs through `callableByName` so + // Tier 3 global resolution can still find them. table.add('src/utils.ts', 'helper', 'method:helper', 'Method'); - expect(table.lookupMethodByOwner('', 'helper')).toBeUndefined(); - // But it should still be in lookupCallableByName + expect(model.methods.lookupMethodByOwner('', 'helper')).toBeUndefined(); + expect(model.methods.lookupMethodByName('helper')).toHaveLength(0); expect(table.lookupCallableByName('helper')).toHaveLength(1); + expect(table.lookupCallableByName('helper')[0].type).toBe('Method'); + expect(table.lookupExact('src/utils.ts', 'helper')).toBe('method:helper'); }); it('returns first match for overloads with same returnType (unambiguous)', () => { @@ -340,7 +403,7 @@ describe('SymbolTable', () => { returnType: 'User', ownerId: 'class:UserRepo', }); - const def = table.lookupMethodByOwner('class:UserRepo', 'find'); + const def = model.methods.lookupMethodByOwner('class:UserRepo', 'find'); expect(def).toBeDefined(); expect(def!.nodeId).toBe('method:find:1'); expect(def!.returnType).toBe('User'); @@ -355,7 +418,7 @@ describe('SymbolTable', () => { parameterCount: 2, ownerId: 'class:Handler', }); - expect(table.lookupMethodByOwner('class:Handler', 'process')).toBeUndefined(); + expect(model.methods.lookupMethodByOwner('class:Handler', 'process')).toBeUndefined(); }); it('indexes Constructor in methodByOwner', () => { @@ -363,15 +426,17 @@ describe('SymbolTable', () => { parameterCount: 0, ownerId: 'class:User', }); - expect(table.lookupMethodByOwner('class:User', 'User')).toEqual({ + expect(model.methods.lookupMethodByOwner('class:User', 'User')).toEqual({ nodeId: 'ctor:User', filePath: 'src/models.ts', type: 'Constructor', parameterCount: 0, ownerId: 'class:User', }); - // But it should be in lookupCallableByName - expect(table.lookupCallableByName('User')).toHaveLength(1); + // Post-A4 Unit 4: Constructor no longer lands in callableByName. + // It is reachable via methodsByName instead. + expect(table.lookupCallableByName('User')).toHaveLength(0); + expect(model.methods.lookupMethodByName('User')).toHaveLength(1); }); it('returns undefined for overloads with different returnTypes (ambiguous)', () => { @@ -385,15 +450,17 @@ describe('SymbolTable', () => { returnType: 'Number', ownerId: 'class:Converter', }); - expect(table.lookupMethodByOwner('class:Converter', 'convert')).toBeUndefined(); + expect(model.methods.lookupMethodByOwner('class:Converter', 'convert')).toBeUndefined(); }); - it('Method with ownerId is still available via lookupCallableByName', () => { + it('post-A4: Method with ownerId is reachable via methodsByName, not callableByName', () => { table.add('src/models.ts', 'save', 'method:save', 'Method', { returnType: 'void', ownerId: 'class:User', }); - expect(table.lookupCallableByName('save')).toHaveLength(1); + expect(table.lookupCallableByName('save')).toHaveLength(0); + expect(model.methods.lookupMethodByName('save')).toHaveLength(1); + expect(model.methods.lookupMethodByOwner('class:User', 'save')).toBeDefined(); }); it('after clear(), lookupMethodByOwner returns undefined', () => { @@ -401,22 +468,26 @@ describe('SymbolTable', () => { returnType: 'void', ownerId: 'class:User', }); - expect(table.lookupMethodByOwner('class:User', 'save')).toBeDefined(); - table.clear(); - expect(table.lookupMethodByOwner('class:User', 'save')).toBeUndefined(); + expect(model.methods.lookupMethodByOwner('class:User', 'save')).toBeDefined(); + model.clear(); + expect(model.methods.lookupMethodByOwner('class:User', 'save')).toBeUndefined(); }); }); describe('lookupCallableByName', () => { - it('returns only callable types (Function, Method, Constructor)', () => { + it('post-A4: returns only free callables (Function/Macro/Delegate)', () => { + // Post-Unit 4, FREE_CALLABLE_TYPES = {Function, Macro, Delegate}. + // Method and Constructor flow through the method registry instead. table.add('src/a.ts', 'foo', 'func:foo', 'Function'); - table.add('src/a.ts', 'bar', 'method:bar', 'Method'); - table.add('src/a.ts', 'Baz', 'ctor:Baz', 'Constructor'); + table.add('src/a.ts', 'bar', 'method:bar', 'Method', { ownerId: 'class:X' }); + table.add('src/a.ts', 'Baz', 'ctor:Baz', 'Constructor', { ownerId: 'class:Baz' }); table.add('src/a.ts', 'User', 'class:User', 'Class'); table.add('src/a.ts', 'IUser', 'iface:IUser', 'Interface'); expect(table.lookupCallableByName('foo')).toHaveLength(1); - expect(table.lookupCallableByName('bar')).toHaveLength(1); - expect(table.lookupCallableByName('Baz')).toHaveLength(1); + expect(table.lookupCallableByName('bar')).toEqual([]); + expect(table.lookupCallableByName('Baz')).toEqual([]); + expect(model.methods.lookupMethodByName('bar')).toHaveLength(1); + expect(model.methods.lookupMethodByName('Baz')).toHaveLength(1); expect(table.lookupCallableByName('User')).toEqual([]); expect(table.lookupCallableByName('IUser')).toEqual([]); }); @@ -456,20 +527,20 @@ describe('SymbolTable', () => { ownerId: 'class:User', }); table.add('src/models.ts', 'User', 'class:User', 'Class'); - table.clear(); + model.clear(); expect(table.getStats()).toEqual({ fileCount: 0, }); expect(table.lookupExact('src/a.ts', 'foo')).toBeUndefined(); - expect(table.lookupFieldByOwner('class:User', 'address')).toBeUndefined(); - expect(table.lookupMethodByOwner('class:User', 'save')).toBeUndefined(); + expect(model.fields.lookupFieldByOwner('class:User', 'address')).toBeUndefined(); + expect(model.methods.lookupMethodByOwner('class:User', 'save')).toBeUndefined(); expect(table.lookupCallableByName('foo')).toEqual([]); - expect(table.lookupClassByName('User')).toEqual([]); + expect(model.types.lookupClassByName('User')).toEqual([]); }); it('allows re-adding after clear', () => { table.add('src/a.ts', 'foo', 'func:foo', 'Function'); - table.clear(); + model.clear(); table.add('src/b.ts', 'bar', 'func:bar', 'Function'); expect(table.getStats()).toEqual({ fileCount: 1, @@ -480,7 +551,7 @@ describe('SymbolTable', () => { table.add('src/a.ts', 'foo', 'func:foo', 'Function'); // Verify callable is found expect(table.lookupCallableByName('foo')).toHaveLength(1); - table.clear(); + model.clear(); // After clear the callable index must be gone — empty table returns nothing expect(table.lookupCallableByName('foo')).toEqual([]); // Re-adding and looking up works correctly @@ -501,7 +572,7 @@ describe('SymbolTable', () => { expect(def!.ownerId).toBeUndefined(); }); - it('stores only ownerId on a Method (non-Property) — still in callable index', () => { + it('stores only ownerId on a Method — reachable via methodsByName (post-A4)', () => { table.add('src/models.ts', 'save', 'method:save', 'Method', { ownerId: 'class:Repo' }); const def = table.lookupExactFull('src/models.ts', 'save'); expect(def).toBeDefined(); @@ -509,8 +580,10 @@ describe('SymbolTable', () => { expect(def!.parameterCount).toBeUndefined(); expect(def!.returnType).toBeUndefined(); expect(def!.declaredType).toBeUndefined(); - // Non-Property with ownerId must still appear in callable index - expect(table.lookupCallableByName('save')).toHaveLength(1); + // Post-A4 Unit 4: owner-scoped Method lives in methodsByName, + // not callableByName. + expect(table.lookupCallableByName('save')).toHaveLength(0); + expect(model.methods.lookupMethodByName('save')).toHaveLength(1); }); it('stores declaredType alone (no ownerId) — symbol in file index', () => { @@ -575,23 +648,27 @@ describe('SymbolTable', () => { expect(second[0].nodeId).toBe('func:fetch'); }); - it('includes newly added Method', () => { + it('post-A4: newly added Method is reachable via methodsByName, not callableByName', () => { table.add('src/a.ts', 'alpha', 'func:alpha', 'Function'); expect(table.lookupCallableByName('alpha')).toHaveLength(1); expect(table.lookupCallableByName('beta')).toEqual([]); - // Add a Method - table.add('src/a.ts', 'beta', 'method:beta', 'Method'); - const result = table.lookupCallableByName('beta'); - expect(result).toHaveLength(1); - expect(result[0].type).toBe('Method'); + table.add('src/a.ts', 'beta', 'method:beta', 'Method', { ownerId: 'class:X' }); + expect(table.lookupCallableByName('beta')).toHaveLength(0); + const byName = model.methods.lookupMethodByName('beta'); + expect(byName).toHaveLength(1); + expect(byName[0].type).toBe('Method'); }); - it('includes newly added Constructor', () => { + it('post-A4: newly added Constructor is reachable via methodsByName, not callableByName', () => { table.add('src/a.ts', 'existing', 'func:existing', 'Function'); expect(table.lookupCallableByName('existing')).toHaveLength(1); - table.add('src/models.ts', 'MyClass', 'ctor:MyClass', 'Constructor'); - expect(table.lookupCallableByName('MyClass')).toHaveLength(1); - expect(table.lookupCallableByName('MyClass')[0].type).toBe('Constructor'); + table.add('src/models.ts', 'MyClass', 'ctor:MyClass', 'Constructor', { + ownerId: 'class:MyClass', + }); + expect(table.lookupCallableByName('MyClass')).toHaveLength(0); + const byName = model.methods.lookupMethodByName('MyClass'); + expect(byName).toHaveLength(1); + expect(byName[0].type).toBe('Constructor'); }); }); @@ -655,9 +732,9 @@ describe('SymbolTable', () => { declaredType: 'Date', ownerId: 'class:User', }); - expect(table.lookupFieldByOwner('class:User', 'id')!.declaredType).toBe('number'); - expect(table.lookupFieldByOwner('class:User', 'email')!.declaredType).toBe('string'); - expect(table.lookupFieldByOwner('class:User', 'createdAt')!.declaredType).toBe('Date'); + expect(model.fields.lookupFieldByOwner('class:User', 'id')!.declaredType).toBe('number'); + expect(model.fields.lookupFieldByOwner('class:User', 'email')!.declaredType).toBe('string'); + expect(model.fields.lookupFieldByOwner('class:User', 'createdAt')!.declaredType).toBe('Date'); }); it('returns the full SymbolDefinition (nodeId + filePath + type) not just declaredType', () => { @@ -665,7 +742,7 @@ describe('SymbolTable', () => { declaredType: 'number', ownerId: 'class:Player', }); - const def = table.lookupFieldByOwner('class:Player', 'score'); + const def = model.fields.lookupFieldByOwner('class:Player', 'score'); expect(def).toBeDefined(); expect(def!.nodeId).toBe('prop:score'); expect(def!.filePath).toBe('src/models.ts'); @@ -682,17 +759,17 @@ describe('SymbolTable', () => { declaredType: 'UUID', ownerId: 'class:B', }); - expect(table.lookupFieldByOwner('class:A', 'id')!.nodeId).toBe('prop:a:id'); - expect(table.lookupFieldByOwner('class:B', 'id')!.nodeId).toBe('prop:b:id'); + expect(model.fields.lookupFieldByOwner('class:A', 'id')!.nodeId).toBe('prop:a:id'); + expect(model.fields.lookupFieldByOwner('class:B', 'id')!.nodeId).toBe('prop:b:id'); // An owner whose id is the concatenation of A's ownerId + fieldName must not match - expect(table.lookupFieldByOwner('class:A\0id', '')).toBeUndefined(); + expect(model.fields.lookupFieldByOwner('class:A\0id', '')).toBeUndefined(); }); }); describe('lookupClassByName', () => { it('returns Class definitions by name', () => { table.add('src/models.ts', 'User', 'class:User', 'Class'); - const results = table.lookupClassByName('User'); + const results = model.types.lookupClassByName('User'); expect(results).toHaveLength(1); expect(results[0]).toEqual({ nodeId: 'class:User', @@ -704,28 +781,28 @@ describe('SymbolTable', () => { it('returns Struct definitions by name', () => { table.add('src/models.rs', 'Point', 'struct:Point', 'Struct'); - const results = table.lookupClassByName('Point'); + const results = model.types.lookupClassByName('Point'); expect(results).toHaveLength(1); expect(results[0].type).toBe('Struct'); }); it('returns Interface definitions by name', () => { table.add('src/types.ts', 'Serializable', 'iface:Serializable', 'Interface'); - const results = table.lookupClassByName('Serializable'); + const results = model.types.lookupClassByName('Serializable'); expect(results).toHaveLength(1); expect(results[0].type).toBe('Interface'); }); it('returns Enum definitions by name', () => { table.add('src/types.ts', 'Color', 'enum:Color', 'Enum'); - const results = table.lookupClassByName('Color'); + const results = model.types.lookupClassByName('Color'); expect(results).toHaveLength(1); expect(results[0].type).toBe('Enum'); }); it('returns Record definitions by name', () => { table.add('src/models.java', 'Config', 'record:Config', 'Record'); - const results = table.lookupClassByName('Config'); + const results = model.types.lookupClassByName('Config'); expect(results).toHaveLength(1); expect(results[0].type).toBe('Record'); }); @@ -733,7 +810,7 @@ describe('SymbolTable', () => { it('does NOT include Function with the same name', () => { table.add('src/models.ts', 'User', 'class:User', 'Class'); table.add('src/utils.ts', 'User', 'func:User', 'Function'); - const results = table.lookupClassByName('User'); + const results = model.types.lookupClassByName('User'); expect(results).toHaveLength(1); expect(results[0].type).toBe('Class'); expect(results[0].nodeId).toBe('class:User'); @@ -744,10 +821,10 @@ describe('SymbolTable', () => { table.add('src/a.ts', 'Bar', 'var:Bar', 'Variable'); table.add('src/a.ts', 'Baz', 'prop:Baz', 'Property'); table.add('src/a.ts', 'Qux', 'ctor:Qux', 'Constructor'); - expect(table.lookupClassByName('Foo')).toEqual([]); - expect(table.lookupClassByName('Bar')).toEqual([]); - expect(table.lookupClassByName('Baz')).toEqual([]); - expect(table.lookupClassByName('Qux')).toEqual([]); + expect(model.types.lookupClassByName('Foo')).toEqual([]); + expect(model.types.lookupClassByName('Bar')).toEqual([]); + expect(model.types.lookupClassByName('Baz')).toEqual([]); + expect(model.types.lookupClassByName('Qux')).toEqual([]); }); it('includes Trait in the class set (PHP use, Rust impl, Scala traits)', () => { @@ -757,20 +834,20 @@ describe('SymbolTable', () => { // Struct` in Rust, etc. Added as part of PR #744 (SM-11 Codex review // fixes) after the PHP HasTimestamps trait walk gap was discovered. table.add('src/a.rs', 'Writer', 'trait:Writer', 'Trait'); - const results = table.lookupClassByName('Writer'); + const results = model.types.lookupClassByName('Writer'); expect(results).toHaveLength(1); expect(results[0].nodeId).toBe('trait:Writer'); }); it('does NOT include other type-like labels outside the allowed class set', () => { table.add('src/a.ts', 'User', 'type:User', 'Type'); - expect(table.lookupClassByName('User')).toEqual([]); + expect(model.types.lookupClassByName('User')).toEqual([]); }); it('returns multiple classes with the same name from different files', () => { table.add('src/models/user.ts', 'User', 'class:user:User', 'Class'); table.add('src/dto/user.ts', 'User', 'class:dto:User', 'Class'); - const results = table.lookupClassByName('User'); + const results = model.types.lookupClassByName('User'); expect(results).toHaveLength(2); expect(results[0].filePath).toBe('src/models/user.ts'); expect(results[1].filePath).toBe('src/dto/user.ts'); @@ -778,25 +855,25 @@ describe('SymbolTable', () => { it('returns empty array for unknown name', () => { table.add('src/models.ts', 'User', 'class:User', 'Class'); - expect(table.lookupClassByName('NonExistent')).toEqual([]); + expect(model.types.lookupClassByName('NonExistent')).toEqual([]); }); it('returns empty array for empty table', () => { - expect(table.lookupClassByName('User')).toEqual([]); + expect(model.types.lookupClassByName('User')).toEqual([]); }); it('after clear(), returns empty array', () => { table.add('src/models.ts', 'User', 'class:User', 'Class'); - expect(table.lookupClassByName('User')).toHaveLength(1); - table.clear(); - expect(table.lookupClassByName('User')).toEqual([]); + expect(model.types.lookupClassByName('User')).toHaveLength(1); + model.clear(); + expect(model.types.lookupClassByName('User')).toEqual([]); }); it('returns mixed class-like types with the same name', () => { // e.g. a Class and an Interface both named 'Comparable' in different files table.add('src/base.ts', 'Comparable', 'class:Comparable', 'Class'); table.add('src/types.ts', 'Comparable', 'iface:Comparable', 'Interface'); - const results = table.lookupClassByName('Comparable'); + const results = model.types.lookupClassByName('Comparable'); expect(results).toHaveLength(2); expect(results.map((r) => r.type)).toEqual(['Class', 'Interface']); }); @@ -806,7 +883,7 @@ describe('SymbolTable', () => { returnType: 'User', ownerId: 'module:models', }); - const results = table.lookupClassByName('User'); + const results = model.types.lookupClassByName('User'); expect(results).toHaveLength(1); expect(results[0].ownerId).toBe('module:models'); }); @@ -814,14 +891,14 @@ describe('SymbolTable', () => { it('class-like symbols are available via lookupClassByName', () => { table.add('src/models.ts', 'User', 'class:User', 'Class'); // classByName is the dedicated index for class-like lookups - expect(table.lookupClassByName('User')).toHaveLength(1); + expect(model.types.lookupClassByName('User')).toHaveLength(1); }); it('allows re-adding after clear and returns correct results', () => { table.add('src/models.ts', 'User', 'class:User:v1', 'Class'); - table.clear(); + model.clear(); table.add('src/models.ts', 'User', 'class:User:v2', 'Class'); - const results = table.lookupClassByName('User'); + const results = model.types.lookupClassByName('User'); expect(results).toHaveLength(1); expect(results[0].nodeId).toBe('class:User:v2'); }); @@ -836,8 +913,8 @@ describe('SymbolTable', () => { qualifiedName: 'Data.User', }); - expect(table.lookupClassByName('User')).toHaveLength(2); - expect(table.lookupClassByQualifiedName('Services.User')).toEqual([ + expect(model.types.lookupClassByName('User')).toHaveLength(2); + expect(model.types.lookupClassByQualifiedName('Services.User')).toEqual([ { nodeId: 'class:services:User', filePath: 'src/services/user.cs', @@ -845,14 +922,14 @@ describe('SymbolTable', () => { qualifiedName: 'Services.User', }, ]); - const dataUserMatches = table.lookupClassByQualifiedName('Data.User'); + const dataUserMatches = model.types.lookupClassByQualifiedName('Data.User'); expect(dataUserMatches).toHaveLength(1); expect(dataUserMatches[0].qualifiedName).toBe('Data.User'); }); it('falls back to the simple name when no qualified metadata is provided', () => { table.add('src/models.ts', 'User', 'class:User', 'Class'); - expect(table.lookupClassByQualifiedName('User')).toEqual([ + expect(model.types.lookupClassByQualifiedName('User')).toEqual([ { nodeId: 'class:User', filePath: 'src/models.ts', @@ -866,16 +943,258 @@ describe('SymbolTable', () => { table.add('src/utils.ts', 'User', 'func:User', 'Function', { qualifiedName: 'Services.User', }); - expect(table.lookupClassByQualifiedName('Services.User')).toEqual([]); + expect(model.types.lookupClassByQualifiedName('Services.User')).toEqual([]); }); it('after clear(), returns empty array', () => { table.add('src/services/user.cs', 'User', 'class:User', 'Class', { qualifiedName: 'Services.User', }); - expect(table.lookupClassByQualifiedName('Services.User')).toHaveLength(1); - table.clear(); - expect(table.lookupClassByQualifiedName('Services.User')).toEqual([]); + expect(model.types.lookupClassByQualifiedName('Services.User')).toHaveLength(1); + model.clear(); + expect(model.types.lookupClassByQualifiedName('Services.User')).toEqual([]); + }); + }); + + describe('SemanticModel container (SM-21 inversion)', () => { + // Post-inversion, the SemanticModel is the top-level container and + // SymbolTable is a nested `symbols` subfield. These tests exercise the + // inverted access pattern directly via createSemanticModel() so the + // factory wiring is covered end-to-end: feeding the symbol table via + // its `add()` populates the parent registries (types/methods/fields). + const buildModel = (): MutableSemanticModel => createSemanticModel(); + + it('exposes types, methods, fields, and symbols subfields', () => { + const model = buildModel(); + expect(model.types).toBeDefined(); + expect(model.methods).toBeDefined(); + expect(model.fields).toBeDefined(); + expect(model.symbols).toBeDefined(); + }); + + it('feeding a Class via model.symbols.add populates model.types', () => { + const model = buildModel(); + model.symbols.add('src/user.ts', 'User', 'class:User', 'Class', { + qualifiedName: 'app.User', + }); + expect(model.types.lookupClassByName('User')).toHaveLength(1); + expect(model.types.lookupClassByName('User')[0]!.nodeId).toBe('class:User'); + expect(model.types.lookupClassByQualifiedName('app.User')).toHaveLength(1); + }); + + it('feeding a Method with ownerId populates model.methods', () => { + const model = buildModel(); + model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + model.symbols.add('src/user.ts', 'save', 'mtd:User.save', 'Method', { + ownerId: 'class:User', + parameterCount: 0, + }); + expect(model.methods.lookupMethodByOwner('class:User', 'save')?.nodeId).toBe('mtd:User.save'); + }); + + it('feeding a Property with ownerId populates model.fields', () => { + const model = buildModel(); + model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + model.symbols.add('src/user.ts', 'name', 'prop:User.name', 'Property', { + ownerId: 'class:User', + declaredType: 'string', + }); + expect(model.fields.lookupFieldByOwner('class:User', 'name')?.nodeId).toBe('prop:User.name'); + }); + + it('feeding an Impl populates model.types.lookupImplByName', () => { + const model = buildModel(); + model.symbols.add('src/user.rs', 'User', 'impl:User', 'Impl'); + expect(model.types.lookupImplByName('User')).toHaveLength(1); + }); + + it('arity filtering disambiguates overloads via model.methods', () => { + const model = buildModel(); + model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + model.symbols.add('src/user.ts', 'greet', 'mtd:greet:0', 'Method', { + ownerId: 'class:User', + parameterCount: 0, + }); + model.symbols.add('src/user.ts', 'greet', 'mtd:greet:1', 'Method', { + ownerId: 'class:User', + parameterCount: 1, + }); + expect(model.methods.lookupMethodByOwner('class:User', 'greet', 0)?.nodeId).toBe( + 'mtd:greet:0', + ); + expect(model.methods.lookupMethodByOwner('class:User', 'greet', 1)?.nodeId).toBe( + 'mtd:greet:1', + ); + }); + + it('clear() cascades through all three registries and the nested symbol table', () => { + const model = buildModel(); + model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + model.symbols.add('src/user.ts', 'save', 'mtd:User.save', 'Method', { + ownerId: 'class:User', + }); + model.symbols.add('src/user.ts', 'name', 'prop:User.name', 'Property', { + ownerId: 'class:User', + declaredType: 'string', + }); + + // Pre-clear: every store is populated. + expect(model.types.lookupClassByName('User')).toHaveLength(1); + expect(model.methods.lookupMethodByOwner('class:User', 'save')?.nodeId).toBe('mtd:User.save'); + expect(model.fields.lookupFieldByOwner('class:User', 'name')?.nodeId).toBe('prop:User.name'); + expect(model.symbols.lookupExact('src/user.ts', 'User')).toBe('class:User'); + + model.clear(); + + // Post-clear: every store is empty — types, methods, fields, symbols. + expect(model.types.lookupClassByName('User')).toEqual([]); + expect(model.methods.lookupMethodByOwner('class:User', 'save')).toBeUndefined(); + expect(model.fields.lookupFieldByOwner('class:User', 'name')).toBeUndefined(); + expect(model.symbols.lookupExact('src/user.ts', 'User')).toBeUndefined(); + }); + + it('feeds Function-with-ownerId into model.methods (Python-style class method)', () => { + // Python/Rust/Kotlin extractors emit class methods as `Function` with + // ownerId. The add() branch must route these into the method registry + // so owner-scoped resolution works uniformly across languages. + const model = buildModel(); + model.symbols.add('src/user.py', 'User', 'class:User', 'Class'); + model.symbols.add('src/user.py', 'save', 'fn:User.save', 'Function', { + ownerId: 'class:User', + }); + expect(model.methods.lookupMethodByOwner('class:User', 'save')?.nodeId).toBe('fn:User.save'); + }); + + it('silently skips Property without ownerId (no model.fields registration)', () => { + // Properties without ownerId are kept in the file index but never + // reach the fields registry — documenting the intentional behavior. + const model = buildModel(); + model.symbols.add('src/user.ts', 'name', 'prop:orphan.name', 'Property', { + declaredType: 'string', + }); + expect(model.symbols.lookupExact('src/user.ts', 'name')).toBe('prop:orphan.name'); + expect(model.fields.lookupFieldByOwner('class:User', 'name')).toBeUndefined(); + }); + }); + + // ------------------------------------------------------------------------- + // SM-22 — dispatch table routing invariants + // ------------------------------------------------------------------------- + + describe('registration dispatch table (SM-22)', () => { + it('registering a Class hits types.registerClass exactly once and touches no other registry', () => { + const model = createSemanticModel(); + const classSpy = vi.spyOn(model.types, 'registerClass'); + const implSpy = vi.spyOn(model.types, 'registerImpl'); + const methodsSpy = vi.spyOn(model.methods, 'register'); + const fieldsSpy = vi.spyOn(model.fields, 'register'); + + model.symbols.add('src/user.ts', 'User', 'class:User', 'Class', { + qualifiedName: 'app.User', + }); + + expect(classSpy).toHaveBeenCalledTimes(1); + expect(implSpy).not.toHaveBeenCalled(); + expect(methodsSpy).not.toHaveBeenCalled(); + expect(fieldsSpy).not.toHaveBeenCalled(); + }); + + it('registering a Property populates fields.register and DOES NOT append to callableByName', () => { + const model = createSemanticModel(); + model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + model.symbols.add('src/user.ts', 'name', 'prop:User.name', 'Property', { + ownerId: 'class:User', + declaredType: 'string', + }); + + expect(model.fields.lookupFieldByOwner('class:User', 'name')?.nodeId).toBe('prop:User.name'); + // Property must NOT leak into callableByName — Property is not in + // FREE_CALLABLE_TYPES, so SymbolTable.add() never appends it. + expect(model.symbols.lookupCallableByName('name')).toHaveLength(0); + }); + + it('registering a free Function populates callableByName but not methods.register', () => { + const model = createSemanticModel(); + const methodsSpy = vi.spyOn(model.methods, 'register'); + + model.symbols.add('src/utils.ts', 'format', 'fn:format', 'Function'); + + expect(model.symbols.lookupCallableByName('format')).toHaveLength(1); + expect(methodsSpy).not.toHaveBeenCalled(); + }); + + it('registering a Function-with-ownerId routes to methods.register via pre-dispatch normalization AND appears in callableByName', () => { + const model = createSemanticModel(); + model.symbols.add('src/user.py', 'User', 'class:User', 'Class'); + model.symbols.add('src/user.py', 'save', 'fn:User.save', 'Function', { + ownerId: 'class:User', + }); + + // Owner-scoped method lookup resolves it (Python-style class method). + expect(model.methods.lookupMethodByOwner('class:User', 'save')?.nodeId).toBe('fn:User.save'); + // Function is in FREE_CALLABLE_TYPES, so it also appears in callableByName. + expect(model.symbols.lookupCallableByName('save')).toHaveLength(1); + }); + + it('registering an Impl populates lookupImplByName but NOT lookupClassByName', () => { + const model = createSemanticModel(); + model.symbols.add('src/user.rs', 'User', 'impl:User', 'Impl'); + // Impl is kept separate from class-like so heritage resolution + // does not treat it as a parent type candidate. + expect(model.types.lookupImplByName('User')).toHaveLength(1); + expect(model.types.lookupClassByName('User')).toHaveLength(0); + }); + + it('registering an inert NodeLabel only populates the file index', () => { + const model = createSemanticModel(); + const classSpy = vi.spyOn(model.types, 'registerClass'); + const implSpy = vi.spyOn(model.types, 'registerImpl'); + const methodsSpy = vi.spyOn(model.methods, 'register'); + const fieldsSpy = vi.spyOn(model.fields, 'register'); + + // `Variable` is in INERT_LABELS — no specialized registry, no + // callable index (it's not in FREE_CALLABLE_TYPES). + model.symbols.add('src/main.ts', 'CONFIG', 'var:CONFIG', 'Variable'); + + expect(model.symbols.lookupExact('src/main.ts', 'CONFIG')).toBe('var:CONFIG'); + expect(classSpy).not.toHaveBeenCalled(); + expect(implSpy).not.toHaveBeenCalled(); + expect(methodsSpy).not.toHaveBeenCalled(); + expect(fieldsSpy).not.toHaveBeenCalled(); + expect(model.symbols.lookupCallableByName('CONFIG')).toHaveLength(0); + }); + + it('Method-without-ownerId skips methods.register and falls back to the callable index', () => { + const model = createSemanticModel(); + const methodsSpy = vi.spyOn(model.methods, 'register'); + + model.symbols.add('src/orphan.ts', 'orphan', 'mtd:orphan', 'Method'); + + // File index still populated. + expect(model.symbols.lookupExact('src/orphan.ts', 'orphan')).toBe('mtd:orphan'); + // Method registry NOT populated (no ownerId to key under) — the + // dispatch hook silently skips. + expect(methodsSpy).not.toHaveBeenCalled(); + expect(model.methods.lookupMethodByName('orphan')).toHaveLength(0); + // Callable-index fallback: an orphaned Method/Constructor is an + // extractor contract violation (AST-degraded parse), but we keep + // it reachable at Tier 3 global resolution by routing it through + // `callableByName`. Matches pre-dispatch-table behavior. + expect(model.symbols.lookupCallableByName('orphan')).toHaveLength(1); + expect(model.symbols.lookupCallableByName('orphan')[0].type).toBe('Method'); + }); + + it('exhaustiveness guard does not fire for the current NodeLabel taxonomy', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + // Fresh SymbolTable — triggers the guard at construction. + createSemanticModel(); + // No warnings about missing NodeLabels — every label is accounted + // for in one of the three allowlists. + const mismatchWarnings = warnSpy.mock.calls.filter((args) => + String(args[0]).startsWith('[SymbolTable] NodeLabel '), + ); + expect(mismatchWarnings).toHaveLength(0); + warnSpy.mockRestore(); }); }); }); @@ -884,14 +1203,13 @@ describe('SymbolTable', () => { // lookupMethodByOwnerWithMRO — MRO-aware method resolution via HeritageMap // --------------------------------------------------------------------------- -import { buildHeritageMap } from '../../src/core/ingestion/heritage-map.js'; -import { lookupMethodByOwnerWithMRO } from '../../src/core/ingestion/call-processor.js'; +import { buildHeritageMap } from '../../src/core/ingestion/model/heritage-map.js'; +import { lookupMethodByOwnerWithMRO } from '../../src/core/ingestion/model/index.js'; import { createResolutionContext, type ResolutionContext, -} from '../../src/core/ingestion/resolution-context.js'; -import { SupportedLanguages } from 'gitnexus-shared'; -import type { ExtractedHeritage } from '../../src/core/ingestion/workers/parse-worker.js'; +} from '../../src/core/ingestion/model/resolution-context.js'; +import type { ExtractedHeritage } from '../../src/core/ingestion/model/heritage-map.js'; describe('lookupMethodByOwnerWithMRO', () => { let ctx: ResolutionContext; @@ -901,12 +1219,18 @@ describe('lookupMethodByOwnerWithMRO', () => { }); it('child.parentMethod() resolves to Parent#parentMethod via MRO walk', () => { - ctx.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class'); - ctx.symbols.add('src/child.java', 'Child', 'class:Child', 'Class'); - ctx.symbols.add('src/parent.java', 'parentMethod', 'method:Parent:parentMethod', 'Method', { - returnType: 'String', - ownerId: 'class:Parent', - }); + ctx.model.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class'); + ctx.model.symbols.add('src/child.java', 'Child', 'class:Child', 'Class'); + ctx.model.symbols.add( + 'src/parent.java', + 'parentMethod', + 'method:Parent:parentMethod', + 'Method', + { + returnType: 'String', + ownerId: 'class:Parent', + }, + ); const heritage: ExtractedHeritage[] = [ { filePath: 'src/child.java', className: 'Child', parentName: 'Parent', kind: 'extends' }, @@ -917,8 +1241,8 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:Child', 'parentMethod', map, - ctx.symbols, - SupportedLanguages.Java, + ctx.model, + 'implements-split', ); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:Parent:parentMethod'); @@ -926,13 +1250,13 @@ describe('lookupMethodByOwnerWithMRO', () => { }); it('child override returns child version (direct hit, no walk)', () => { - ctx.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class'); - ctx.symbols.add('src/child.java', 'Child', 'class:Child', 'Class'); - ctx.symbols.add('src/parent.java', 'save', 'method:Parent:save', 'Method', { + ctx.model.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class'); + ctx.model.symbols.add('src/child.java', 'Child', 'class:Child', 'Class'); + ctx.model.symbols.add('src/parent.java', 'save', 'method:Parent:save', 'Method', { returnType: 'void', ownerId: 'class:Parent', }); - ctx.symbols.add('src/child.java', 'save', 'method:Child:save', 'Method', { + ctx.model.symbols.add('src/child.java', 'save', 'method:Child:save', 'Method', { returnType: 'void', ownerId: 'class:Child', }); @@ -946,18 +1270,18 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:Child', 'save', map, - ctx.symbols, - SupportedLanguages.Java, + ctx.model, + 'implements-split', ); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:Child:save'); }); it('3-level inheritance: grandchild → child → parent, method on parent found', () => { - ctx.symbols.add('src/a.java', 'A', 'class:A', 'Class'); - ctx.symbols.add('src/b.java', 'B', 'class:B', 'Class'); - ctx.symbols.add('src/c.java', 'C', 'class:C', 'Class'); - ctx.symbols.add('src/a.java', 'greet', 'method:A:greet', 'Method', { + ctx.model.symbols.add('src/a.java', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/b.java', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/c.java', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('src/a.java', 'greet', 'method:A:greet', 'Method', { returnType: 'Greeting', ownerId: 'class:A', }); @@ -972,8 +1296,8 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:C', 'greet', map, - ctx.symbols, - SupportedLanguages.Java, + ctx.model, + 'implements-split', ); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:A:greet'); @@ -981,15 +1305,15 @@ describe('lookupMethodByOwnerWithMRO', () => { }); it('diamond pattern: first-wins strategy returns first ancestor match in BFS order', () => { - ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); - ctx.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); - ctx.symbols.add('src/c.ts', 'C', 'class:C', 'Class'); - ctx.symbols.add('src/d.ts', 'D', 'class:D', 'Class'); - ctx.symbols.add('src/b.ts', 'foo', 'method:B:foo', 'Method', { + ctx.model.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/c.ts', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('src/d.ts', 'D', 'class:D', 'Class'); + ctx.model.symbols.add('src/b.ts', 'foo', 'method:B:foo', 'Method', { returnType: 'String', ownerId: 'class:B', }); - ctx.symbols.add('src/c.ts', 'foo', 'method:C:foo', 'Method', { + ctx.model.symbols.add('src/c.ts', 'foo', 'method:C:foo', 'Method', { returnType: 'String', ownerId: 'class:C', }); @@ -1003,27 +1327,21 @@ describe('lookupMethodByOwnerWithMRO', () => { const map = buildHeritageMap(heritage, ctx); // TypeScript uses 'first-wins' — B is first parent, so B.foo wins - const result = lookupMethodByOwnerWithMRO( - 'class:D', - 'foo', - map, - ctx.symbols, - SupportedLanguages.TypeScript, - ); + const result = lookupMethodByOwnerWithMRO('class:D', 'foo', map, ctx.model, 'first-wins'); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:B:foo'); }); it('diamond pattern: c3 strategy uses C3 linearization order', () => { - ctx.symbols.add('src/a.py', 'A', 'class:A', 'Class'); - ctx.symbols.add('src/b.py', 'B', 'class:B', 'Class'); - ctx.symbols.add('src/c.py', 'C', 'class:C', 'Class'); - ctx.symbols.add('src/d.py', 'D', 'class:D', 'Class'); - ctx.symbols.add('src/b.py', 'foo', 'method:B:foo', 'Method', { + ctx.model.symbols.add('src/a.py', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/b.py', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/c.py', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('src/d.py', 'D', 'class:D', 'Class'); + ctx.model.symbols.add('src/b.py', 'foo', 'method:B:foo', 'Method', { returnType: 'str', ownerId: 'class:B', }); - ctx.symbols.add('src/c.py', 'foo', 'method:C:foo', 'Method', { + ctx.model.symbols.add('src/c.py', 'foo', 'method:C:foo', 'Method', { returnType: 'str', ownerId: 'class:C', }); @@ -1037,22 +1355,41 @@ describe('lookupMethodByOwnerWithMRO', () => { const map = buildHeritageMap(heritage, ctx); // Python uses 'c3' — C3 linearization for D(B,C): [B, C, A] - const result = lookupMethodByOwnerWithMRO( - 'class:D', - 'foo', - map, - ctx.symbols, - SupportedLanguages.Python, - ); + const result = lookupMethodByOwnerWithMRO('class:D', 'foo', map, ctx.model, 'c3'); expect(result).toBeDefined(); // C3 linearization resolves to B before C in this hierarchy expect(result!.nodeId).toBe('method:B:foo'); }); + it('c3 (Python): cyclic hierarchy falls back to BFS ancestor order', () => { + // Build a legitimately cyclic heritage: A extends B, B extends A. + // c3Linearize returns null for this case (inconsistent linearization). + // The MRO walker must then fall back to heritageMap.getAncestors() + // (BFS order) instead of silently returning undefined. + ctx.model.symbols.add('src/a.py', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/b.py', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/b.py', 'foo', 'method:B:foo', 'Method', { + returnType: 'void', + ownerId: 'class:B', + }); + + const heritage: ExtractedHeritage[] = [ + { filePath: 'src/a.py', className: 'A', parentName: 'B', kind: 'extends' }, + { filePath: 'src/b.py', className: 'B', parentName: 'A', kind: 'extends' }, + ]; + const map = buildHeritageMap(heritage, ctx); + + // Even with a cyclic hierarchy, BFS via heritageMap.getAncestors() + // walks A → B and finds `foo` on B. The method lookup must succeed. + const result = lookupMethodByOwnerWithMRO('class:A', 'foo', map, ctx.model, 'c3'); + expect(result).toBeDefined(); + expect(result!.nodeId).toBe('method:B:foo'); + }); + it('qualified-syntax (Rust): returns undefined for inherited methods', () => { - ctx.symbols.add('src/parent.rs', 'Parent', 'class:Parent', 'Class'); - ctx.symbols.add('src/child.rs', 'Child', 'class:Child', 'Class'); - ctx.symbols.add('src/parent.rs', 'process', 'method:Parent:process', 'Method', { + ctx.model.symbols.add('src/parent.rs', 'Parent', 'class:Parent', 'Class'); + ctx.model.symbols.add('src/child.rs', 'Child', 'class:Child', 'Class'); + ctx.model.symbols.add('src/parent.rs', 'process', 'method:Parent:process', 'Method', { returnType: 'void', ownerId: 'class:Parent', }); @@ -1066,16 +1403,16 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:Child', 'process', map, - ctx.symbols, - SupportedLanguages.Rust, + ctx.model, + 'qualified-syntax', ); // Rust requires qualified syntax — no auto-resolution expect(result).toBeUndefined(); }); it('method not on any ancestor returns undefined', () => { - ctx.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class'); - ctx.symbols.add('src/child.java', 'Child', 'class:Child', 'Class'); + ctx.model.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class'); + ctx.model.symbols.add('src/child.java', 'Child', 'class:Child', 'Class'); const heritage: ExtractedHeritage[] = [ { filePath: 'src/child.java', className: 'Child', parentName: 'Parent', kind: 'extends' }, @@ -1086,17 +1423,17 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:Child', 'nonExistent', map, - ctx.symbols, - SupportedLanguages.Java, + ctx.model, + 'implements-split', ); expect(result).toBeUndefined(); }); it('leftmost-base (C++): walks ancestors in BFS order', () => { - ctx.symbols.add('src/a.cpp', 'A', 'class:A', 'Class'); - ctx.symbols.add('src/b.cpp', 'B', 'class:B', 'Class'); - ctx.symbols.add('src/c.cpp', 'C', 'class:C', 'Class'); - ctx.symbols.add('src/a.cpp', 'render', 'method:A:render', 'Method', { + ctx.model.symbols.add('src/a.cpp', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/b.cpp', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/c.cpp', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('src/a.cpp', 'render', 'method:A:render', 'Method', { returnType: 'void', ownerId: 'class:A', }); @@ -1107,22 +1444,16 @@ describe('lookupMethodByOwnerWithMRO', () => { ]; const map = buildHeritageMap(heritage, ctx); - const result = lookupMethodByOwnerWithMRO( - 'class:C', - 'render', - map, - ctx.symbols, - SupportedLanguages.CPlusPlus, - ); + const result = lookupMethodByOwnerWithMRO('class:C', 'render', map, ctx.model, 'leftmost-base'); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:A:render'); }); it('implements-split (Java): walks ancestors to find inherited method', () => { - ctx.symbols.add('src/base.java', 'Base', 'class:Base', 'Class'); - ctx.symbols.add('src/iface.java', 'IRepo', 'iface:IRepo', 'Interface'); - ctx.symbols.add('src/child.java', 'Child', 'class:Child', 'Class'); - ctx.symbols.add('src/base.java', 'save', 'method:Base:save', 'Method', { + ctx.model.symbols.add('src/base.java', 'Base', 'class:Base', 'Class'); + ctx.model.symbols.add('src/iface.java', 'IRepo', 'iface:IRepo', 'Interface'); + ctx.model.symbols.add('src/child.java', 'Child', 'class:Child', 'Class'); + ctx.model.symbols.add('src/base.java', 'save', 'method:Base:save', 'Method', { returnType: 'void', ownerId: 'class:Base', }); @@ -1142,8 +1473,8 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:Child', 'save', map, - ctx.symbols, - SupportedLanguages.Java, + ctx.model, + 'implements-split', ); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:Base:save'); @@ -1156,14 +1487,14 @@ describe('lookupMethodByOwnerWithMRO', () => { // level. lookupMethodByOwnerWithMRO itself uses BFS order and returns // the first match — this test pins that contract so a future regression // that starts returning undefined (or flips the order) fails loudly. - ctx.symbols.add('src/I1.java', 'I1', 'iface:I1', 'Interface'); - ctx.symbols.add('src/I2.java', 'I2', 'iface:I2', 'Interface'); - ctx.symbols.add('src/C.java', 'C', 'class:C', 'Class'); - ctx.symbols.add('src/I1.java', 'handle', 'method:I1:handle', 'Method', { + ctx.model.symbols.add('src/I1.java', 'I1', 'iface:I1', 'Interface'); + ctx.model.symbols.add('src/I2.java', 'I2', 'iface:I2', 'Interface'); + ctx.model.symbols.add('src/C.java', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('src/I1.java', 'handle', 'method:I1:handle', 'Method', { returnType: 'void', ownerId: 'iface:I1', }); - ctx.symbols.add('src/I2.java', 'handle', 'method:I2:handle', 'Method', { + ctx.model.symbols.add('src/I2.java', 'handle', 'method:I2:handle', 'Method', { returnType: 'void', ownerId: 'iface:I2', }); @@ -1179,8 +1510,8 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:C', 'handle', map, - ctx.symbols, - SupportedLanguages.Java, + ctx.model, + 'implements-split', ); expect(result).toBeDefined(); // BFS first-wins — I1 was declared first, so it wins. @@ -1194,14 +1525,14 @@ describe('lookupMethodByOwnerWithMRO', () => { // Base before IFoo — class wins. Documents the current BFS-level // behavior; the strict Java "class always wins" rule is enforced at // the mro-processor graph pass. - ctx.symbols.add('src/Base.java', 'Base', 'class:Base', 'Class'); - ctx.symbols.add('src/IFoo.java', 'IFoo', 'iface:IFoo', 'Interface'); - ctx.symbols.add('src/Child.java', 'Child', 'class:Child', 'Class'); - ctx.symbols.add('src/Base.java', 'handle', 'method:Base:handle', 'Method', { + ctx.model.symbols.add('src/Base.java', 'Base', 'class:Base', 'Class'); + ctx.model.symbols.add('src/IFoo.java', 'IFoo', 'iface:IFoo', 'Interface'); + ctx.model.symbols.add('src/Child.java', 'Child', 'class:Child', 'Class'); + ctx.model.symbols.add('src/Base.java', 'handle', 'method:Base:handle', 'Method', { returnType: 'void', ownerId: 'class:Base', }); - ctx.symbols.add('src/IFoo.java', 'handle', 'method:IFoo:handle', 'Method', { + ctx.model.symbols.add('src/IFoo.java', 'handle', 'method:IFoo:handle', 'Method', { returnType: 'void', ownerId: 'iface:IFoo', }); @@ -1216,17 +1547,17 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:Child', 'handle', map, - ctx.symbols, - SupportedLanguages.Java, + ctx.model, + 'implements-split', ); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:Base:handle'); }); it('implements-split (Kotlin): walks ancestors to find inherited method', () => { - ctx.symbols.add('src/base.kt', 'Base', 'class:Base', 'Class'); - ctx.symbols.add('src/child.kt', 'Child', 'class:Child', 'Class'); - ctx.symbols.add('src/base.kt', 'handle', 'method:Base:handle', 'Method', { + ctx.model.symbols.add('src/base.kt', 'Base', 'class:Base', 'Class'); + ctx.model.symbols.add('src/child.kt', 'Child', 'class:Child', 'Class'); + ctx.model.symbols.add('src/base.kt', 'handle', 'method:Base:handle', 'Method', { returnType: 'Unit', ownerId: 'class:Base', }); @@ -1240,17 +1571,17 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:Child', 'handle', map, - ctx.symbols, - SupportedLanguages.Kotlin, + ctx.model, + 'implements-split', ); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:Base:handle'); }); it('implements-split (C#): walks ancestors to find inherited method', () => { - ctx.symbols.add('src/Base.cs', 'Base', 'class:Base', 'Class'); - ctx.symbols.add('src/Child.cs', 'Child', 'class:Child', 'Class'); - ctx.symbols.add('src/Base.cs', 'Execute', 'method:Base:Execute', 'Method', { + ctx.model.symbols.add('src/Base.cs', 'Base', 'class:Base', 'Class'); + ctx.model.symbols.add('src/Child.cs', 'Child', 'class:Child', 'Class'); + ctx.model.symbols.add('src/Base.cs', 'Execute', 'method:Base:Execute', 'Method', { returnType: 'void', ownerId: 'class:Base', }); @@ -1264,8 +1595,8 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:Child', 'Execute', map, - ctx.symbols, - SupportedLanguages.CSharp, + ctx.model, + 'implements-split', ); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:Base:Execute'); @@ -1274,9 +1605,9 @@ describe('lookupMethodByOwnerWithMRO', () => { it('first-wins (JavaScript): walks ancestors to find inherited method', () => { // JavaScript provider is wired separately from TypeScript — this guards // the provider wiring independent of the TS path. - ctx.symbols.add('src/animal.js', 'Animal', 'class:Animal', 'Class'); - ctx.symbols.add('src/dog.js', 'Dog', 'class:Dog', 'Class'); - ctx.symbols.add('src/animal.js', 'speak', 'method:Animal:speak', 'Method', { + ctx.model.symbols.add('src/animal.js', 'Animal', 'class:Animal', 'Class'); + ctx.model.symbols.add('src/dog.js', 'Dog', 'class:Dog', 'Class'); + ctx.model.symbols.add('src/animal.js', 'speak', 'method:Animal:speak', 'Method', { returnType: 'string', ownerId: 'class:Animal', }); @@ -1286,13 +1617,7 @@ describe('lookupMethodByOwnerWithMRO', () => { ]; const map = buildHeritageMap(heritage, ctx); - const result = lookupMethodByOwnerWithMRO( - 'class:Dog', - 'speak', - map, - ctx.symbols, - SupportedLanguages.JavaScript, - ); + const result = lookupMethodByOwnerWithMRO('class:Dog', 'speak', map, ctx.model, 'first-wins'); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:Animal:speak'); }); @@ -1301,19 +1626,19 @@ describe('lookupMethodByOwnerWithMRO', () => { // Diamond: D extends B, C; B extends A; C extends A. // Both B and C define render(). leftmost-base must return B#render (first // branch in declaration order), not A#render or C#render. - ctx.symbols.add('src/a.cpp', 'A', 'class:A', 'Class'); - ctx.symbols.add('src/b.cpp', 'B', 'class:B', 'Class'); - ctx.symbols.add('src/c.cpp', 'C', 'class:C', 'Class'); - ctx.symbols.add('src/d.cpp', 'D', 'class:D', 'Class'); - ctx.symbols.add('src/a.cpp', 'render', 'method:A:render', 'Method', { + ctx.model.symbols.add('src/a.cpp', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/b.cpp', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/c.cpp', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('src/d.cpp', 'D', 'class:D', 'Class'); + ctx.model.symbols.add('src/a.cpp', 'render', 'method:A:render', 'Method', { returnType: 'void', ownerId: 'class:A', }); - ctx.symbols.add('src/b.cpp', 'render', 'method:B:render', 'Method', { + ctx.model.symbols.add('src/b.cpp', 'render', 'method:B:render', 'Method', { returnType: 'void', ownerId: 'class:B', }); - ctx.symbols.add('src/c.cpp', 'render', 'method:C:render', 'Method', { + ctx.model.symbols.add('src/c.cpp', 'render', 'method:C:render', 'Method', { returnType: 'void', ownerId: 'class:C', }); @@ -1327,13 +1652,7 @@ describe('lookupMethodByOwnerWithMRO', () => { ]; const map = buildHeritageMap(heritage, ctx); - const result = lookupMethodByOwnerWithMRO( - 'class:D', - 'render', - map, - ctx.symbols, - SupportedLanguages.CPlusPlus, - ); + const result = lookupMethodByOwnerWithMRO('class:D', 'render', map, ctx.model, 'leftmost-base'); expect(result).toBeDefined(); // BFS via HeritageMap visits B before C (insertion order), so leftmost // branch wins — matches C++ leftmost-base semantics for non-virtual base. @@ -1341,8 +1660,8 @@ describe('lookupMethodByOwnerWithMRO', () => { }); it('returns direct method on owner without walking (no heritage needed)', () => { - ctx.symbols.add('src/user.java', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/user.java', 'getName', 'method:User:getName', 'Method', { + ctx.model.symbols.add('src/user.java', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.java', 'getName', 'method:User:getName', 'Method', { returnType: 'String', ownerId: 'class:User', }); @@ -1353,8 +1672,8 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:User', 'getName', map, - ctx.symbols, - SupportedLanguages.Java, + ctx.model, + 'implements-split', ); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:User:getName'); @@ -1380,8 +1699,8 @@ describe('resolveMemberCall', () => { }); it('resolves direct method on owner type', () => { - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { returnType: 'void', ownerId: 'class:User', }); @@ -1396,9 +1715,9 @@ describe('resolveMemberCall', () => { }); it('resolves inherited method via MRO walk', () => { - ctx.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class'); - ctx.symbols.add('src/child.java', 'Child', 'class:Child', 'Class'); - ctx.symbols.add('src/parent.java', 'validate', 'method:Parent:validate', 'Method', { + ctx.model.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class'); + ctx.model.symbols.add('src/child.java', 'Child', 'class:Child', 'Class'); + ctx.model.symbols.add('src/parent.java', 'validate', 'method:Parent:validate', 'Method', { returnType: 'boolean', ownerId: 'class:Parent', }); @@ -1422,7 +1741,7 @@ describe('resolveMemberCall', () => { }); it('returns null for unknown method on known owner', () => { - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); ctx.importMap.set('src/app.ts', new Set(['src/user.ts'])); const result = resolveMemberCall('User', 'nonExistentMethod', 'src/app.ts', ctx); @@ -1430,8 +1749,8 @@ describe('resolveMemberCall', () => { }); it('returns result with correct confidence tier for same-file resolution', () => { - ctx.symbols.add('src/app.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/app.ts', 'save', 'method:User:save', 'Method', { + ctx.model.symbols.add('src/app.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/app.ts', 'save', 'method:User:save', 'Method', { returnType: 'void', ownerId: 'class:User', }); @@ -1444,8 +1763,8 @@ describe('resolveMemberCall', () => { }); it('returns result with import-scoped tier for cross-file resolution', () => { - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { returnType: 'void', ownerId: 'class:User', }); @@ -1459,10 +1778,10 @@ describe('resolveMemberCall', () => { }); it('resolves with heritage map across C3 MRO chain (Python)', () => { - ctx.symbols.add('src/a.py', 'A', 'class:A', 'Class'); - ctx.symbols.add('src/b.py', 'B', 'class:B', 'Class'); - ctx.symbols.add('src/c.py', 'C', 'class:C', 'Class'); - ctx.symbols.add('src/a.py', 'foo', 'method:A:foo', 'Method', { + ctx.model.symbols.add('src/a.py', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/b.py', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/c.py', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('src/a.py', 'foo', 'method:A:foo', 'Method', { returnType: 'str', ownerId: 'class:A', }); @@ -1491,8 +1810,8 @@ describe('resolveMemberCall', () => { // have used the tier of resolving "save" globally; new behaviour uses the // tier of resolving "User". Both happen to yield import-scoped here — // the test locks that the reported tier tracks the class lookup. - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { returnType: 'void', ownerId: 'class:User', }); @@ -1513,9 +1832,9 @@ describe('resolveMemberCall', () => { it('Rust: returns null for trait-inherited method (qualified-syntax MRO)', () => { // Trait Writer defines `save`. Struct User has an impl_item but NO save // method of its own — save is only available via trait. - ctx.symbols.add('src/writer.rs', 'Writer', 'trait:Writer', 'Trait'); - ctx.symbols.add('src/user.rs', 'User', 'struct:User', 'Struct'); - ctx.symbols.add('src/writer.rs', 'save', 'method:Writer:save', 'Method', { + ctx.model.symbols.add('src/writer.rs', 'Writer', 'trait:Writer', 'Trait'); + ctx.model.symbols.add('src/user.rs', 'User', 'struct:User', 'Struct'); + ctx.model.symbols.add('src/writer.rs', 'save', 'method:Writer:save', 'Method', { returnType: 'bool', ownerId: 'trait:Writer', }); @@ -1537,8 +1856,8 @@ describe('resolveMemberCall', () => { // Positive control: a method defined directly on User (not via trait) // resolves normally — demonstrates the null in the previous test is // specifically due to the trait-inheritance path, not a broken fixture. - ctx.symbols.add('src/user.rs', 'User', 'struct:User', 'Struct'); - ctx.symbols.add('src/user.rs', 'name', 'method:User:name', 'Method', { + ctx.model.symbols.add('src/user.rs', 'User', 'struct:User', 'Struct'); + ctx.model.symbols.add('src/user.rs', 'name', 'method:User:name', 'Method', { returnType: 'String', ownerId: 'struct:User', }); @@ -1562,13 +1881,13 @@ describe('resolveMemberCall', () => { it('disambiguates homonym classes: only one owns the method', () => { // Two classes both named `User` — one in auth.py (has `save`), one in // legacy.py (has `archive` but no `save`). Both are imported from app.py. - ctx.symbols.add('src/auth.py', 'User', 'class:auth:User', 'Class'); - ctx.symbols.add('src/auth.py', 'save', 'method:auth:User:save', 'Method', { + ctx.model.symbols.add('src/auth.py', 'User', 'class:auth:User', 'Class'); + ctx.model.symbols.add('src/auth.py', 'save', 'method:auth:User:save', 'Method', { returnType: 'None', ownerId: 'class:auth:User', }); - ctx.symbols.add('src/legacy.py', 'User', 'class:legacy:User', 'Class'); - ctx.symbols.add('src/legacy.py', 'archive', 'method:legacy:User:archive', 'Method', { + ctx.model.symbols.add('src/legacy.py', 'User', 'class:legacy:User', 'Class'); + ctx.model.symbols.add('src/legacy.py', 'archive', 'method:legacy:User:archive', 'Method', { returnType: 'None', ownerId: 'class:legacy:User', }); @@ -1589,13 +1908,13 @@ describe('resolveMemberCall', () => { // Both homonym Users define a `save` method — resolveMemberCall refuses // to pick one. The caller (resolveCallTarget) falls through to D1-D4 which // may or may not be able to narrow further. - ctx.symbols.add('src/auth.py', 'User', 'class:auth:User', 'Class'); - ctx.symbols.add('src/auth.py', 'save', 'method:auth:User:save', 'Method', { + ctx.model.symbols.add('src/auth.py', 'User', 'class:auth:User', 'Class'); + ctx.model.symbols.add('src/auth.py', 'save', 'method:auth:User:save', 'Method', { returnType: 'None', ownerId: 'class:auth:User', }); - ctx.symbols.add('src/legacy.py', 'User', 'class:legacy:User', 'Class'); - ctx.symbols.add('src/legacy.py', 'save', 'method:legacy:User:save', 'Method', { + ctx.model.symbols.add('src/legacy.py', 'User', 'class:legacy:User', 'Class'); + ctx.model.symbols.add('src/legacy.py', 'save', 'method:legacy:User:save', 'Method', { returnType: 'None', ownerId: 'class:legacy:User', }); @@ -1609,13 +1928,13 @@ describe('resolveMemberCall', () => { // Two homonym `User` classes in different files, both extending a common // `BaseUser` that owns `save`. Direct lookup on either User misses; MRO // walks both find BaseUser.save. Dedup by nodeId yields a single result. - ctx.symbols.add('src/base.ts', 'BaseUser', 'class:BaseUser', 'Class'); - ctx.symbols.add('src/base.ts', 'save', 'method:BaseUser:save', 'Method', { + ctx.model.symbols.add('src/base.ts', 'BaseUser', 'class:BaseUser', 'Class'); + ctx.model.symbols.add('src/base.ts', 'save', 'method:BaseUser:save', 'Method', { returnType: 'void', ownerId: 'class:BaseUser', }); - ctx.symbols.add('src/a.ts', 'User', 'class:a:User', 'Class'); - ctx.symbols.add('src/b.ts', 'User', 'class:b:User', 'Class'); + ctx.model.symbols.add('src/a.ts', 'User', 'class:a:User', 'Class'); + ctx.model.symbols.add('src/b.ts', 'User', 'class:b:User', 'Class'); ctx.importMap.set('src/app.ts', new Set(['src/base.ts', 'src/a.ts', 'src/b.ts'])); const heritage: ExtractedHeritage[] = [ @@ -1639,11 +1958,11 @@ describe('resolveMemberCall', () => { // // Both A and B inherit `method` from Base. Derived extends (A, B). // Leftmost-base strategy walks A's chain first → finds Base::method. - ctx.symbols.add('src/base.h', 'Base', 'class:Base', 'Class'); - ctx.symbols.add('src/a.h', 'A', 'class:A', 'Class'); - ctx.symbols.add('src/b.h', 'B', 'class:B', 'Class'); - ctx.symbols.add('src/derived.h', 'Derived', 'class:Derived', 'Class'); - ctx.symbols.add('src/base.h', 'method', 'method:Base:method', 'Method', { + ctx.model.symbols.add('src/base.h', 'Base', 'class:Base', 'Class'); + ctx.model.symbols.add('src/a.h', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/b.h', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/derived.h', 'Derived', 'class:Derived', 'Class'); + ctx.model.symbols.add('src/base.h', 'method', 'method:Base:method', 'Method', { returnType: 'int', ownerId: 'class:Base', }); @@ -1677,10 +1996,10 @@ describe('resolveMemberCall', () => { // C# uses implements-split MRO: class base chain walked first, then // interfaces. Here IService declares Save which is implemented by the // base class BaseService — MyService inherits Save through the class. - ctx.symbols.add('src/iservice.cs', 'IService', 'interface:IService', 'Interface'); - ctx.symbols.add('src/base.cs', 'BaseService', 'class:BaseService', 'Class'); - ctx.symbols.add('src/my.cs', 'MyService', 'class:MyService', 'Class'); - ctx.symbols.add('src/base.cs', 'Save', 'method:BaseService:Save', 'Method', { + ctx.model.symbols.add('src/iservice.cs', 'IService', 'interface:IService', 'Interface'); + ctx.model.symbols.add('src/base.cs', 'BaseService', 'class:BaseService', 'Class'); + ctx.model.symbols.add('src/my.cs', 'MyService', 'class:MyService', 'Class'); + ctx.model.symbols.add('src/base.cs', 'Save', 'method:BaseService:Save', 'Method', { returnType: 'void', ownerId: 'class:BaseService', }); @@ -1708,9 +2027,9 @@ describe('resolveMemberCall', () => { // Kotlin shares the implements-split MRO strategy with Java/C#. A class // inheriting from an interface that provides a default method should // resolve `obj.method()` to the interface's implementation. - ctx.symbols.add('src/validator.kt', 'Validator', 'interface:Validator', 'Interface'); - ctx.symbols.add('src/user.kt', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/validator.kt', 'validate', 'method:Validator:validate', 'Method', { + ctx.model.symbols.add('src/validator.kt', 'Validator', 'interface:Validator', 'Interface'); + ctx.model.symbols.add('src/user.kt', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/validator.kt', 'validate', 'method:Validator:validate', 'Method', { returnType: 'Boolean', ownerId: 'interface:Validator', }); @@ -1765,13 +2084,13 @@ describe('resolveCallTarget thin dispatcher (SM-19)', () => { // type-file verification guard requires the alias target file to be // among the receiver type's defining files before alias narrowing is // considered a valid signal. - ctx.symbols.add('src/auth.py', 'User', 'class:auth:User', 'Class'); - ctx.symbols.add('src/auth.py', 'save', 'method:auth:User:save', 'Method', { + ctx.model.symbols.add('src/auth.py', 'User', 'class:auth:User', 'Class'); + ctx.model.symbols.add('src/auth.py', 'save', 'method:auth:User:save', 'Method', { returnType: 'None', ownerId: 'class:auth:User', }); - ctx.symbols.add('src/other.py', 'User', 'class:other:User', 'Class'); - ctx.symbols.add('src/other.py', 'save', 'method:other:User:save', 'Method', { + ctx.model.symbols.add('src/other.py', 'User', 'class:other:User', 'Class'); + ctx.model.symbols.add('src/other.py', 'save', 'method:other:User:save', 'Method', { returnType: 'None', ownerId: 'class:other:User', }); @@ -1797,8 +2116,8 @@ describe('resolveCallTarget thin dispatcher (SM-19)', () => { it('overloadHints ignored for member calls — resolveMemberCall resolves directly', () => { // With the thin dispatcher, overloadHints are not passed to resolveMemberCall // (it does not accept them). Single-candidate member calls still resolve. - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { returnType: 'void', ownerId: 'class:User', }); @@ -1825,8 +2144,8 @@ describe('resolveCallTarget thin dispatcher (SM-19)', () => { // Analogous to the overloadHints case: thin dispatcher delegates to // resolveMemberCall which resolves the single candidate without needing // argument-type disambiguation. - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { returnType: 'void', ownerId: 'class:User', }); @@ -1863,8 +2182,8 @@ describe('resolveStaticCall', () => { }); it('resolves constructor with ownerId via lookupMethodByOwner', () => { - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/user.ts', 'User', 'ctor:User', 'Constructor', { + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'ctor:User', 'Constructor', { returnType: 'User', ownerId: 'class:User', }); @@ -1877,7 +2196,7 @@ describe('resolveStaticCall', () => { }); it('returns class node when no constructor exists', () => { - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); ctx.importMap.set('src/app.ts', new Set(['src/user.ts'])); const result = resolveStaticCall('User', 'src/app.ts', ctx); @@ -1887,7 +2206,7 @@ describe('resolveStaticCall', () => { }); it('returns null for non-class symbol', () => { - ctx.symbols.add('src/utils.ts', 'helper', 'func:helper', 'Function'); + ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper', 'Function'); ctx.importMap.set('src/app.ts', new Set(['src/utils.ts'])); const result = resolveStaticCall('helper', 'src/app.ts', ctx); @@ -1902,8 +2221,8 @@ describe('resolveStaticCall', () => { }); it('returns null when Constructor nodes lack ownerId', () => { - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/user.ts', 'User', 'ctor:User', 'Constructor', { + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'ctor:User', 'Constructor', { parameterCount: 1, }); ctx.importMap.set('src/app.ts', new Set(['src/user.ts'])); @@ -1917,13 +2236,13 @@ describe('resolveStaticCall', () => { }); it('disambiguates constructor by arity', () => { - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/user.ts', 'User', 'ctor:User:0', 'Constructor', { + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'ctor:User:0', 'Constructor', { parameterCount: 0, returnType: 'User', ownerId: 'class:User', }); - ctx.symbols.add('src/user.ts', 'User', 'ctor:User:2', 'Constructor', { + ctx.model.symbols.add('src/user.ts', 'User', 'ctor:User:2', 'Constructor', { parameterCount: 2, returnType: 'User', ownerId: 'class:User', @@ -1937,7 +2256,7 @@ describe('resolveStaticCall', () => { }); it('returns correct confidence tier for import-scoped class', () => { - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); ctx.importMap.set('src/app.ts', new Set(['src/user.ts'])); const result = resolveStaticCall('User', 'src/app.ts', ctx); @@ -1948,7 +2267,7 @@ describe('resolveStaticCall', () => { }); it('returns correct confidence tier for same-file class', () => { - ctx.symbols.add('src/app.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/app.ts', 'User', 'class:User', 'Class'); const result = resolveStaticCall('User', 'src/app.ts', ctx); @@ -1958,8 +2277,8 @@ describe('resolveStaticCall', () => { }); it('returns null for ambiguous homonym classes without constructor', () => { - ctx.symbols.add('src/a.ts', 'User', 'class:a:User', 'Class'); - ctx.symbols.add('src/b.ts', 'User', 'class:b:User', 'Class'); + ctx.model.symbols.add('src/a.ts', 'User', 'class:a:User', 'Class'); + ctx.model.symbols.add('src/b.ts', 'User', 'class:b:User', 'Class'); ctx.importMap.set('src/app.ts', new Set(['src/a.ts', 'src/b.ts'])); const result = resolveStaticCall('User', 'src/app.ts', ctx); @@ -1969,7 +2288,7 @@ describe('resolveStaticCall', () => { }); it('routes through resolveCallTarget for constructor callForm', () => { - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); ctx.importMap.set('src/app.ts', new Set(['src/user.ts'])); const result = _resolveCallTargetForTesting( @@ -1986,7 +2305,7 @@ describe('resolveStaticCall', () => { }); it('routes through resolveCallTarget for free-form call targeting a class (Swift/Kotlin)', () => { - ctx.symbols.add('src/user.swift', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.swift', 'User', 'class:User', 'Class'); ctx.importMap.set('src/app.swift', new Set(['src/user.swift'])); const result = _resolveCallTargetForTesting( @@ -2003,8 +2322,8 @@ describe('resolveStaticCall', () => { }); it('reuses the pre-computed tiered result instead of calling ctx.resolve twice', () => { - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/user.ts', 'User', 'ctor:User', 'Constructor', { + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'ctor:User', 'Constructor', { returnType: 'User', ownerId: 'class:User', }); @@ -2030,8 +2349,8 @@ describe('resolveStaticCall', () => { }); it('routes through resolveCallTarget for Java constructor call (new User())', () => { - ctx.symbols.add('src/User.java', 'User', 'class:java:User', 'Class'); - ctx.symbols.add('src/User.java', 'User', 'ctor:java:User', 'Constructor', { + ctx.model.symbols.add('src/User.java', 'User', 'class:java:User', 'Class'); + ctx.model.symbols.add('src/User.java', 'User', 'ctor:java:User', 'Constructor', { returnType: 'User', ownerId: 'class:java:User', }); @@ -2052,7 +2371,7 @@ describe('resolveStaticCall', () => { }); it('routes through resolveCallTarget for Python free-form constructor (User())', () => { - ctx.symbols.add('models/user.py', 'User', 'class:py:User', 'Class'); + ctx.model.symbols.add('models/user.py', 'User', 'class:py:User', 'Class'); ctx.importMap.set('app.py', new Set(['models/user.py'])); const result = _resolveCallTargetForTesting( @@ -2069,7 +2388,7 @@ describe('resolveStaticCall', () => { }); it('routes through resolveCallTarget for Kotlin free-form constructor (User())', () => { - ctx.symbols.add('src/User.kt', 'User', 'class:kt:User', 'Class'); + ctx.model.symbols.add('src/User.kt', 'User', 'class:kt:User', 'Class'); ctx.importMap.set('src/App.kt', new Set(['src/User.kt'])); const result = _resolveCallTargetForTesting( @@ -2093,7 +2412,7 @@ describe('resolveStaticCall', () => { // ------------------------------------------------------------------------- it('returns a Struct node when no constructor exists (positive regression guard)', () => { - ctx.symbols.add('src/user.rs', 'User', 'struct:User', 'Struct'); + ctx.model.symbols.add('src/user.rs', 'User', 'struct:User', 'Struct'); ctx.importMap.set('src/app.rs', new Set(['src/user.rs'])); const result = resolveStaticCall('User', 'src/app.rs', ctx); @@ -2103,7 +2422,7 @@ describe('resolveStaticCall', () => { }); it('returns a Record node when no constructor exists (positive regression guard)', () => { - ctx.symbols.add('src/User.cs', 'User', 'record:User', 'Record'); + ctx.model.symbols.add('src/User.cs', 'User', 'record:User', 'Record'); ctx.importMap.set('src/App.cs', new Set(['src/User.cs'])); const result = resolveStaticCall('User', 'src/App.cs', ctx); @@ -2115,7 +2434,7 @@ describe('resolveStaticCall', () => { it('null-routes when the sole candidate is an Interface (Java/C#/TS)', () => { // Constructor-shaped call on an interface name — not legal source, but // the resolver must refuse to emit a CALLS edge to a non-instantiable node. - ctx.symbols.add('src/validator.java', 'IValidator', 'iface:IValidator', 'Interface'); + ctx.model.symbols.add('src/validator.java', 'IValidator', 'iface:IValidator', 'Interface'); ctx.importMap.set('src/app.java', new Set(['src/validator.java'])); const result = resolveStaticCall('IValidator', 'src/app.java', ctx); @@ -2125,7 +2444,7 @@ describe('resolveStaticCall', () => { it('null-routes when the sole candidate is a Trait (PHP/Rust/Scala)', () => { // PHP `HasTimestamps` trait — not instantiable via constructor syntax. - ctx.symbols.add('src/timestamps.php', 'HasTimestamps', 'trait:HasTimestamps', 'Trait'); + ctx.model.symbols.add('src/timestamps.php', 'HasTimestamps', 'trait:HasTimestamps', 'Trait'); ctx.importMap.set('src/model.php', new Set(['src/timestamps.php'])); const result = resolveStaticCall('HasTimestamps', 'src/model.php', ctx); @@ -2134,7 +2453,7 @@ describe('resolveStaticCall', () => { }); it('null-routes when the sole candidate is a Rust Trait (Display)', () => { - ctx.symbols.add('src/fmt.rs', 'Display', 'trait:rs:Display', 'Trait'); + ctx.model.symbols.add('src/fmt.rs', 'Display', 'trait:rs:Display', 'Trait'); ctx.importMap.set('src/app.rs', new Set(['src/fmt.rs'])); const result = resolveStaticCall('Display', 'src/app.rs', ctx); @@ -2146,8 +2465,8 @@ describe('resolveStaticCall', () => { // Rust `impl User { ... }` alongside `struct User { ... }` in the same file. // Same-file tier returns both via lookupExactAll, both pass CLASS_LIKE_TYPES, // but the instantiability filter must strip the Impl so the Struct wins. - ctx.symbols.add('src/user.rs', 'User', 'struct:rs:User', 'Struct'); - ctx.symbols.add('src/user.rs', 'User', 'impl:rs:User', 'Impl'); + ctx.model.symbols.add('src/user.rs', 'User', 'struct:rs:User', 'Struct'); + ctx.model.symbols.add('src/user.rs', 'User', 'impl:rs:User', 'Impl'); const result = resolveStaticCall('User', 'src/user.rs', ctx); @@ -2158,7 +2477,7 @@ describe('resolveStaticCall', () => { it('null-routes when the sole candidate is a Rust Impl block (no Struct present)', () => { // Pathological extractor output: only the Impl survives tier resolution. // The instantiability filter must reject it rather than emit a wrong edge. - ctx.symbols.add('src/user.rs', 'User', 'impl:rs:User', 'Impl'); + ctx.model.symbols.add('src/user.rs', 'User', 'impl:rs:User', 'Impl'); const result = resolveStaticCall('User', 'src/user.rs', ctx); @@ -2171,9 +2490,9 @@ describe('resolveStaticCall', () => { // extractor still resolves correctly. The Struct is also present so that // step-1's lookupClassByName pre-check succeeds (Impl alone isn't in the // classByName index). - ctx.symbols.add('src/user.rs', 'User', 'struct:rs:User', 'Struct'); - ctx.symbols.add('src/user.rs', 'User', 'impl:rs:User', 'Impl'); - ctx.symbols.add('src/user.rs', 'User', 'ctor:rs:User', 'Constructor', { + ctx.model.symbols.add('src/user.rs', 'User', 'struct:rs:User', 'Struct'); + ctx.model.symbols.add('src/user.rs', 'User', 'impl:rs:User', 'Impl'); + ctx.model.symbols.add('src/user.rs', 'User', 'ctor:rs:User', 'Constructor', { returnType: 'User', ownerId: 'impl:rs:User', }); @@ -2186,7 +2505,7 @@ describe('resolveStaticCall', () => { }); it('routes through resolveCallTarget and null-routes Interface constructor-shaped calls', () => { - ctx.symbols.add('src/validator.java', 'IValidator', 'iface:IValidator', 'Interface'); + ctx.model.symbols.add('src/validator.java', 'IValidator', 'iface:IValidator', 'Interface'); ctx.importMap.set('src/app.java', new Set(['src/validator.java'])); const result = _resolveCallTargetForTesting( @@ -2204,7 +2523,7 @@ describe('resolveStaticCall', () => { }); it('routes through resolveCallTarget and null-routes Trait free-form calls', () => { - ctx.symbols.add('src/timestamps.php', 'HasTimestamps', 'trait:HasTimestamps', 'Trait'); + ctx.model.symbols.add('src/timestamps.php', 'HasTimestamps', 'trait:HasTimestamps', 'Trait'); ctx.importMap.set('src/model.php', new Set(['src/timestamps.php'])); const result = _resolveCallTargetForTesting( @@ -2225,7 +2544,7 @@ describe('resolveStaticCall', () => { // so S0 was bypassed and Record free-form calls fell through to the // constructor-form retry path. This test would have silently passed with // the old (wasteful) code path — with the fix, S0 resolves it directly. - ctx.symbols.add('src/User.cs', 'User', 'record:cs:User', 'Record'); + ctx.model.symbols.add('src/User.cs', 'User', 'record:cs:User', 'Record'); ctx.importMap.set('src/App.cs', new Set(['src/User.cs'])); const result = _resolveCallTargetForTesting( @@ -2245,13 +2564,13 @@ describe('resolveStaticCall', () => { // Regression guard: if call.argCount were ever dropped at the S0 call // site, the 2-arg constructor would resolve to the 0-arg overload (or // return null via ambiguity). This test fails in either case. - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/user.ts', 'User', 'ctor:User:0', 'Constructor', { + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'ctor:User:0', 'Constructor', { parameterCount: 0, returnType: 'User', ownerId: 'class:User', }); - ctx.symbols.add('src/user.ts', 'User', 'ctor:User:2', 'Constructor', { + ctx.model.symbols.add('src/user.ts', 'User', 'ctor:User:2', 'Constructor', { parameterCount: 2, returnType: 'User', ownerId: 'class:User', @@ -2285,7 +2604,7 @@ describe('resolveFreeCall', () => { }); it('resolves a free function call via import-scoped resolution', () => { - ctx.symbols.add('src/utils.ts', 'doStuff', 'func:doStuff', 'Function'); + ctx.model.symbols.add('src/utils.ts', 'doStuff', 'func:doStuff', 'Function'); ctx.importMap.set('src/app.ts', new Set(['src/utils.ts'])); const result = resolveFreeCall('doStuff', 'src/app.ts', ctx); @@ -2297,7 +2616,7 @@ describe('resolveFreeCall', () => { }); it('resolves a free function call via same-file resolution', () => { - ctx.symbols.add('src/app.ts', 'helper', 'func:helper', 'Function'); + ctx.model.symbols.add('src/app.ts', 'helper', 'func:helper', 'Function'); const result = resolveFreeCall('helper', 'src/app.ts', ctx); @@ -2313,8 +2632,8 @@ describe('resolveFreeCall', () => { }); it('returns null for ambiguous free function calls (multiple candidates)', () => { - ctx.symbols.add('src/a.ts', 'doStuff', 'func:a:doStuff', 'Function'); - ctx.symbols.add('src/b.ts', 'doStuff', 'func:b:doStuff', 'Function'); + ctx.model.symbols.add('src/a.ts', 'doStuff', 'func:a:doStuff', 'Function'); + ctx.model.symbols.add('src/b.ts', 'doStuff', 'func:b:doStuff', 'Function'); ctx.importMap.set('src/app.ts', new Set(['src/a.ts', 'src/b.ts'])); const result = resolveFreeCall('doStuff', 'src/app.ts', ctx); @@ -2323,7 +2642,7 @@ describe('resolveFreeCall', () => { }); it('delegates to resolveStaticCall for free-form class targets (Swift/Kotlin)', () => { - ctx.symbols.add('src/user.swift', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.swift', 'User', 'class:User', 'Class'); ctx.importMap.set('src/app.swift', new Set(['src/user.swift'])); const result = resolveFreeCall('User', 'src/app.swift', ctx); @@ -2333,7 +2652,7 @@ describe('resolveFreeCall', () => { }); it('delegates to resolveStaticCall for Record free-form targets (C#/Kotlin)', () => { - ctx.symbols.add('src/User.cs', 'User', 'record:cs:User', 'Record'); + ctx.model.symbols.add('src/User.cs', 'User', 'record:cs:User', 'Record'); ctx.importMap.set('src/App.cs', new Set(['src/User.cs'])); const result = resolveFreeCall('User', 'src/App.cs', ctx); @@ -2343,7 +2662,7 @@ describe('resolveFreeCall', () => { }); it('null-routes Trait free-form calls via resolveStaticCall', () => { - ctx.symbols.add('src/timestamps.php', 'HasTimestamps', 'trait:HasTimestamps', 'Trait'); + ctx.model.symbols.add('src/timestamps.php', 'HasTimestamps', 'trait:HasTimestamps', 'Trait'); ctx.importMap.set('src/model.php', new Set(['src/timestamps.php'])); const result = resolveFreeCall('HasTimestamps', 'src/model.php', ctx); @@ -2352,7 +2671,7 @@ describe('resolveFreeCall', () => { }); it('uses tieredOverride when provided', () => { - ctx.symbols.add('src/utils.ts', 'doStuff', 'func:doStuff', 'Function'); + ctx.model.symbols.add('src/utils.ts', 'doStuff', 'func:doStuff', 'Function'); ctx.importMap.set('src/app.ts', new Set(['src/utils.ts'])); const tiered = ctx.resolve('doStuff', 'src/app.ts'); @@ -2374,7 +2693,7 @@ describe('resolveFreeCall', () => { }); it('routes through resolveCallTarget for free-form calls', () => { - ctx.symbols.add('src/utils.ts', 'doStuff', 'func:doStuff', 'Function'); + ctx.model.symbols.add('src/utils.ts', 'doStuff', 'func:doStuff', 'Function'); ctx.importMap.set('src/app.ts', new Set(['src/utils.ts'])); const result = _resolveCallTargetForTesting( @@ -2401,7 +2720,7 @@ describe('resolveFreeCall', () => { // file-extension branching; these guard the dispatch chain per language. it('resolves a Go free function (doStuff())', () => { - ctx.symbols.add('src/helper.go', 'doStuff', 'func:go:doStuff', 'Function'); + ctx.model.symbols.add('src/helper.go', 'doStuff', 'func:go:doStuff', 'Function'); ctx.importMap.set('src/main.go', new Set(['src/helper.go'])); const result = _resolveCallTargetForTesting( @@ -2415,7 +2734,7 @@ describe('resolveFreeCall', () => { }); it('resolves a Python free function (def helper(): ... helper())', () => { - ctx.symbols.add('helpers.py', 'helper', 'func:py:helper', 'Function'); + ctx.model.symbols.add('helpers.py', 'helper', 'func:py:helper', 'Function'); ctx.importMap.set('app.py', new Set(['helpers.py'])); const result = _resolveCallTargetForTesting( @@ -2429,7 +2748,7 @@ describe('resolveFreeCall', () => { }); it('resolves a Rust free function outside any impl block (free_fn())', () => { - ctx.symbols.add('src/helpers.rs', 'free_fn', 'func:rs:free_fn', 'Function'); + ctx.model.symbols.add('src/helpers.rs', 'free_fn', 'func:rs:free_fn', 'Function'); ctx.importMap.set('src/main.rs', new Set(['src/helpers.rs'])); const result = _resolveCallTargetForTesting( @@ -2447,7 +2766,7 @@ describe('resolveFreeCall', () => { // indexing the function directly in its declaring file. The test guards // the dispatch chain for .java files, not the extractor's handling of // static imports specifically. - ctx.symbols.add('src/Utils.java', 'doStuff', 'func:java:doStuff', 'Function'); + ctx.model.symbols.add('src/Utils.java', 'doStuff', 'func:java:doStuff', 'Function'); ctx.importMap.set('src/App.java', new Set(['src/Utils.java'])); const result = _resolveCallTargetForTesting( @@ -2461,7 +2780,7 @@ describe('resolveFreeCall', () => { }); it('resolves a JavaScript module-level function (moduleFn())', () => { - ctx.symbols.add('src/helpers.js', 'moduleFn', 'func:js:moduleFn', 'Function'); + ctx.model.symbols.add('src/helpers.js', 'moduleFn', 'func:js:moduleFn', 'Function'); ctx.importMap.set('src/app.js', new Set(['src/helpers.js'])); const result = _resolveCallTargetForTesting( @@ -2478,10 +2797,10 @@ describe('resolveFreeCall', () => { // differing only in parameter count. it('narrows overloaded free functions by argCount (2-arg overload selected)', () => { - ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:0', 'Function', { + ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:0', 'Function', { parameterCount: 0, }); - ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:2', 'Function', { + ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:2', 'Function', { parameterCount: 2, }); ctx.importMap.set('src/app.ts', new Set(['src/utils.ts'])); @@ -2497,10 +2816,10 @@ describe('resolveFreeCall', () => { }); it('narrows overloaded free functions by argCount (0-arg overload selected)', () => { - ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:0', 'Function', { + ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:0', 'Function', { parameterCount: 0, }); - ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:2', 'Function', { + ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:2', 'Function', { parameterCount: 2, }); ctx.importMap.set('src/app.ts', new Set(['src/utils.ts'])); @@ -2520,7 +2839,7 @@ describe('resolveFreeCall', () => { // so a silent tier-table refactor surfaces here. it('resolves a globally-visible free function via Tier 3 with global confidence', () => { - ctx.symbols.add('lib/global.ts', 'helper', 'func:global:helper', 'Function'); + ctx.model.symbols.add('lib/global.ts', 'helper', 'func:global:helper', 'Function'); // No importMap entry — must fall through to Tier 3 (global). const result = _resolveCallTargetForTesting( @@ -2544,11 +2863,11 @@ describe('resolveFreeCall', () => { // preComputedArgTypes at the disambiguation site. it('disambiguates overloads via preComputedArgTypes (String overload matched)', () => { - ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:str', 'Function', { + ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:str', 'Function', { parameterCount: 1, parameterTypes: ['String'], }); - ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:int', 'Function', { + ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:int', 'Function', { parameterCount: 1, parameterTypes: ['Int'], }); @@ -2566,11 +2885,11 @@ describe('resolveFreeCall', () => { }); it('disambiguates overloads via preComputedArgTypes (Int overload matched)', () => { - ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:str', 'Function', { + ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:str', 'Function', { parameterCount: 1, parameterTypes: ['String'], }); - ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:int', 'Function', { + ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:int', 'Function', { parameterCount: 1, parameterTypes: ['Int'], }); @@ -2600,7 +2919,7 @@ describe('resolveFreeCall', () => { // will need to be updated alongside that work — that is the correct signal. it('null-routes Enum free-form calls (Color() — no instantiable fallback)', () => { - ctx.symbols.add('src/color.ts', 'Color', 'enum:Color', 'Enum'); + ctx.model.symbols.add('src/color.ts', 'Color', 'enum:Color', 'Enum'); ctx.importMap.set('src/app.ts', new Set(['src/color.ts'])); const result = _resolveCallTargetForTesting( @@ -2631,8 +2950,13 @@ describe('resolveFreeCall', () => { it('dedupes Swift extension candidates by shortest file path (free-form retry path)', () => { // Two same-name Class entries, different path lengths. - ctx.symbols.add('src/User.swift', 'User', 'class:User:primary', 'Class'); - ctx.symbols.add('src/Extensions/UserExtensions.swift', 'User', 'class:User:extension', 'Class'); + ctx.model.symbols.add('src/User.swift', 'User', 'class:User:primary', 'Class'); + ctx.model.symbols.add( + 'src/Extensions/UserExtensions.swift', + 'User', + 'class:User:extension', + 'Class', + ); ctx.importMap.set( 'src/App.swift', new Set(['src/User.swift', 'src/Extensions/UserExtensions.swift']), @@ -2673,8 +2997,8 @@ describe('resolveFreeCall', () => { // 'constructor' form, which — per CONSTRUCTOR_TARGET_TYPES — prefers // the Constructor node over the Class node. // 4. Single survivor → returned as the call target. - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/user.ts', 'User', 'ctor:User:ownerless', 'Constructor', { + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'ctor:User:ownerless', 'Constructor', { parameterCount: 0, // No ownerId — this is the pathological extractor output the retry path // exists to handle. @@ -2699,7 +3023,7 @@ describe('resolveFreeCall', () => { // language coverage table in PR #756 review flagged this as uncovered; // this test exercises the `.php` dispatch path for free calls. Matches // the shape of the existing Go/Python/Rust/Java/JS language tests above. - ctx.symbols.add('src/helpers.php', 'helper', 'func:php:helper', 'Function'); + ctx.model.symbols.add('src/helpers.php', 'helper', 'func:php:helper', 'Function'); ctx.importMap.set('src/app.php', new Set(['src/helpers.php'])); const result = _resolveCallTargetForTesting( diff --git a/gitnexus/test/unit/type-env.test.ts b/gitnexus/test/unit/type-env.test.ts index ce771b857..27f449f91 100644 --- a/gitnexus/test/unit/type-env.test.ts +++ b/gitnexus/test/unit/type-env.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect, vi } from 'vitest'; import { buildTypeEnv, type TypeEnvironment } from '../../src/core/ingestion/type-env.js'; import { BindingAccumulator } from '../../src/core/ingestion/binding-accumulator.js'; +import { type SymbolDefinition } from '../../src/core/ingestion/model/symbol-table.js'; import { - createSymbolTable, - type SymbolDefinition, - type SymbolTable, -} from '../../src/core/ingestion/symbol-table.js'; + createSemanticModel, + type SemanticModel, +} from '../../src/core/ingestion/model/semantic-model.js'; import { stripNullable, extractSimpleTypeName, @@ -78,25 +78,6 @@ function flatSize(typeEnv: TypeEnvironment): number { return count; } -const createMockSymbolTable = (overrides: Partial = {}): SymbolTable => ({ - add: () => {}, - lookupExact: () => undefined, - lookupExactFull: () => undefined, - lookupExactAll: () => [], - lookupCallableByName: () => [], - lookupFieldByOwner: () => undefined, - lookupMethodByOwner: () => undefined, - lookupClassByName: () => [], - lookupClassByQualifiedName: () => [], - lookupImplByName: () => [], - getFiles: () => [][Symbol.iterator](), - getStats: () => ({ - fileCount: 0, - }), - clear: () => {}, - ...overrides, -}); - const createClassDef = ( name: string, type: SymbolDefinition['type'] = 'Class', @@ -1191,29 +1172,44 @@ class RepoService { }); describe('destructured call results', () => { - // Minimal mock SymbolTable for call-result return type lookup + // Minimal mock SemanticModel for call-result return type lookup + // (SM-21 inversion — buildTypeEnv takes a SemanticModel via `model:`). const makeSymbolTable = (callables: Array<{ name: string; returnType?: string }>) => ({ - lookupCallableByName: (name: string) => - callables - .filter((c) => c.name === name) - .map((c) => ({ - nodeId: 'n1', - filePath: 'src.ts', - type: 'Function' as const, - returnType: c.returnType, - })), - lookupClassByName: () => [], - lookupExact: () => undefined, - lookupExactFull: () => undefined, - add: () => {}, - getStats: () => ({ fileCount: 0 }), - clear: () => {}, + types: { + lookupClassByName: () => [], + lookupClassByQualifiedName: () => [], + lookupImplByName: () => [], + }, + methods: { + lookupMethodByOwner: () => undefined, + lookupMethodByName: () => [], + }, + fields: { + lookupFieldByOwner: () => undefined, + }, + symbols: { + add: () => {}, + lookupExact: () => undefined, + lookupExactFull: () => undefined, + lookupExactAll: () => [], + lookupCallableByName: (name: string) => + callables + .filter((c) => c.name === name) + .map((c) => ({ + nodeId: 'n1', + filePath: 'src.ts', + type: 'Function' as const, + returnType: c.returnType, + })), + getFiles: () => [][Symbol.iterator](), + getStats: () => ({ fileCount: 0 }), + }, }); it('emits callResult + fieldAccess items for const { x } = fn()', () => { const symbolTable = makeSymbolTable([{ name: 'getUser', returnType: 'User' }]); const tree = parse('const { name } = getUser();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable: symbolTable as any }); + const typeEnv = buildTypeEnv(tree, 'typescript', { model: symbolTable }); // callResult resolves __destr_getUser_N → User // fieldAccess resolves name via User's properties (no Property nodes in mock → undefined) // But the callResult itself IS emitted — verify constructorBindings is still empty @@ -1226,14 +1222,14 @@ class RepoService { 'async function f() { const { data } = await fetchData(); }', TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable: symbolTable as any }); + const typeEnv = buildTypeEnv(tree, 'typescript', { model: symbolTable }); expect(typeEnv.constructorBindings).toEqual([]); }); it('gracefully handles no return type (composable without annotation)', () => { const symbolTable = makeSymbolTable([{ name: 'useUserRole' }]); // no returnType const tree = parse('const { isMaker } = useUserRole();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable: symbolTable as any }); + const typeEnv = buildTypeEnv(tree, 'typescript', { model: symbolTable }); // No return type → callResult unresolved → fieldAccess unresolved expect(flatGet(typeEnv, 'isMaker')).toBeUndefined(); }); @@ -2045,17 +2041,10 @@ class RepoService { `, Kotlin, ); - // User is NOT defined in this file, but SymbolTable knows it's a Class - const mockSymbolTable = { - lookupClassByName: (name: string) => - name === 'User' ? [{ nodeId: 'n1', filePath: 'models.kt', type: 'Class' }] : [], - lookupExact: () => undefined, - lookupExactFull: () => undefined, - add: () => {}, - getStats: () => ({ fileCount: 0 }), - clear: () => {}, - }; - const typeEnv = buildTypeEnv(tree, 'kotlin', { symbolTable: mockSymbolTable as any }); + // User is NOT defined in this file, but SemanticModel knows it's a Class + const model = createSemanticModel(); + model.symbols.add('models.kt', 'User', 'n1', 'Class'); + const typeEnv = buildTypeEnv(tree, 'kotlin', { model }); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -2068,17 +2057,8 @@ class RepoService { `, Kotlin, ); - const mockSymbolTable = { - lookupClassByName: () => [], - lookupCallableByName: () => [], - lookupFieldByOwner: () => undefined, - lookupExact: () => undefined, - lookupExactFull: () => undefined, - add: () => {}, - getStats: () => ({ fileCount: 0 }), - clear: () => {}, - }; - const typeEnv = buildTypeEnv(tree, 'kotlin', { symbolTable: mockSymbolTable as any }); + const model = createSemanticModel(); + const typeEnv = buildTypeEnv(tree, 'kotlin', { model }); expect(flatGet(typeEnv, 'result')).toBeUndefined(); }); @@ -2144,10 +2124,15 @@ def main(): }); describe('lookupClassByName regression coverage', () => { - const makeClassLookupTable = (classDefs: Record) => - createMockSymbolTable({ - lookupClassByName: (name: string) => classDefs[name] ?? [], - }); + const makeClassLookupTable = ( + classDefs: Record, + ): SemanticModel => { + const model = createSemanticModel(); + vi.spyOn(model.types, 'lookupClassByName').mockImplementation( + (name: string) => classDefs[name] ?? [], + ); + return model; + }; it('Python cross-file constructor inference uses lookupClassByName', () => { const tree = parse( @@ -2158,7 +2143,7 @@ def main(): Python, ); const typeEnv = buildTypeEnv(tree, 'python', { - symbolTable: makeClassLookupTable({ + model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'models.py')], }), }); @@ -2174,7 +2159,7 @@ def main(): Python, ); const typeEnv = buildTypeEnv(tree, 'python', { - symbolTable: makeClassLookupTable({}), + model: makeClassLookupTable({}), }); expect(flatGet(typeEnv, 'result')).toBeUndefined(); }); @@ -2188,7 +2173,7 @@ def main(): Python, ); const typeEnv = buildTypeEnv(tree, 'python', { - symbolTable: makeClassLookupTable({ + model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'models.py')], }), }); @@ -2205,7 +2190,7 @@ void run() { CPP, ); const typeEnv = buildTypeEnv(tree, 'cpp', { - symbolTable: makeClassLookupTable({ + model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'models.h')], }), }); @@ -2222,7 +2207,7 @@ void run() { CPP, ); const typeEnv = buildTypeEnv(tree, 'cpp', { - symbolTable: makeClassLookupTable({}), + model: makeClassLookupTable({}), }); expect(flatGet(typeEnv, 'result')).toBeUndefined(); }); @@ -2237,7 +2222,7 @@ end Ruby, ); const typeEnv = buildTypeEnv(tree, 'ruby', { - symbolTable: makeClassLookupTable({ + model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'models/user.rb')], }), }); @@ -2254,7 +2239,7 @@ end Ruby, ); const typeEnv = buildTypeEnv(tree, 'ruby', { - symbolTable: makeClassLookupTable({ + model: makeClassLookupTable({ UserService: [createClassDef('UserService', 'Class', 'models/user_service.rb')], }), }); @@ -2271,7 +2256,7 @@ end Ruby, ); const typeEnv = buildTypeEnv(tree, 'ruby', { - symbolTable: makeClassLookupTable({}), + model: makeClassLookupTable({}), }); expect(flatGet(typeEnv, 'result')).toBeUndefined(); }); @@ -2286,7 +2271,7 @@ void run() { `, ); const typeEnv = buildTypeEnv(tree, 'dart', { - symbolTable: makeClassLookupTable({ + model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'models.dart')], }), }); @@ -2302,7 +2287,7 @@ void run() { `, ); const typeEnv = buildTypeEnv(tree, 'dart', { - symbolTable: makeClassLookupTable({ + model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'models.dart')], }), }); @@ -2318,7 +2303,7 @@ void run() { `, ); const typeEnv = buildTypeEnv(tree, 'dart', { - symbolTable: makeClassLookupTable({}), + model: makeClassLookupTable({}), }); expect(flatGet(typeEnv, 'result')).toBeUndefined(); }); @@ -2334,7 +2319,7 @@ fn run() { Rust, ); const typeEnv = buildTypeEnv(tree, 'rust', { - symbolTable: makeClassLookupTable({ + model: makeClassLookupTable({ UserService: [createClassDef('UserService', 'Struct', 'models.rs')], }), }); @@ -2351,7 +2336,7 @@ fn run() { Rust, ); const typeEnv = buildTypeEnv(tree, 'rust', { - symbolTable: makeClassLookupTable({}), + model: makeClassLookupTable({}), }); expect(flatGet(typeEnv, 'value')).toBeUndefined(); }); @@ -2366,7 +2351,7 @@ func run() { `, ); const typeEnv = buildTypeEnv(tree, 'swift', { - symbolTable: makeClassLookupTable({ + model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'Models/User.swift')], }), }); @@ -2382,7 +2367,7 @@ func run() { `, ); const typeEnv = buildTypeEnv(tree, 'swift', { - symbolTable: makeClassLookupTable({ + model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'Models/User.swift')], }), }); @@ -2398,7 +2383,7 @@ func run() { `, ); const typeEnv = buildTypeEnv(tree, 'swift', { - symbolTable: makeClassLookupTable({}), + model: makeClassLookupTable({}), }); expect(flatGet(typeEnv, 'result')).toBeUndefined(); }); @@ -2413,20 +2398,13 @@ function process(user: User) { `, TypeScript.typescript, ); - const symbolTable = createMockSymbolTable({ - lookupClassByName: (name: string) => - name === 'User' ? [createClassDef('User', 'Class', 'models.ts')] : [], - lookupFieldByOwner: (ownerNodeId: string, fieldName: string) => - ownerNodeId === 'class:User' && fieldName === 'address' - ? { - nodeId: 'prop:User:address', - filePath: 'models.ts', - type: 'Property' as const, - declaredType: 'Address', - } - : undefined, + const model = createSemanticModel(); + model.symbols.add('models.ts', 'User', 'class:User', 'Class'); + model.symbols.add('models.ts', 'address', 'prop:User:address', 'Property', { + ownerId: 'class:User', + declaredType: 'Address', }); - const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); + const typeEnv = buildTypeEnv(tree, 'typescript', { model }); expect(flatGet(typeEnv, 'addr')).toBe('Address'); }); @@ -2439,10 +2417,8 @@ function process(user: User) { `, TypeScript.typescript, ); - const symbolTable = createMockSymbolTable({ - lookupClassByName: () => [], - }); - const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); + const model = createSemanticModel(); + const typeEnv = buildTypeEnv(tree, 'typescript', { model }); expect(flatGet(typeEnv, 'addr')).toBeUndefined(); }); @@ -2455,23 +2431,14 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupCallableByName = vi.fn(() => []); - const symbolTable = createMockSymbolTable({ - lookupClassByName: (name: string) => - name === 'Repo' ? [createClassDef('Repo', 'Class', 'models.ts')] : [], - lookupMethodByOwner: (ownerNodeId: string, methodName: string) => - ownerNodeId === 'class:Repo' && methodName === 'getProfile' - ? { - nodeId: 'method:Repo:getProfile', - filePath: 'models.ts', - type: 'Method', - ownerId: 'class:Repo', - returnType: 'Profile', - } - : undefined, - lookupCallableByName, + const model = createSemanticModel(); + model.symbols.add('models.ts', 'Repo', 'class:Repo', 'Class'); + model.symbols.add('models.ts', 'getProfile', 'method:Repo:getProfile', 'Method', { + ownerId: 'class:Repo', + returnType: 'Profile', }); - const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); + const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); + const typeEnv = buildTypeEnv(tree, 'typescript', { model }); expect(flatGet(typeEnv, 'profile')).toBe('Profile'); expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile'); }); @@ -2485,27 +2452,16 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupCallableByName = vi.fn(() => []); - const symbolTable = createMockSymbolTable({ - lookupClassByName: (name: string) => { - if (name === 'Repo') return [createClassDef('Repo', 'Class', 'models.ts')]; - if (name === 'BaseRepo') return [createClassDef('BaseRepo', 'Class', 'base.ts')]; - return []; - }, - lookupMethodByOwner: (ownerNodeId: string, methodName: string) => - ownerNodeId === 'class:BaseRepo' && methodName === 'getProfile' - ? { - nodeId: 'method:BaseRepo:getProfile', - filePath: 'base.ts', - type: 'Method', - ownerId: 'class:BaseRepo', - returnType: 'Profile', - } - : undefined, - lookupCallableByName, + const model = createSemanticModel(); + model.symbols.add('models.ts', 'Repo', 'class:Repo', 'Class'); + model.symbols.add('base.ts', 'BaseRepo', 'class:BaseRepo', 'Class'); + model.symbols.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile', 'Method', { + ownerId: 'class:BaseRepo', + returnType: 'Profile', }); + const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); const typeEnv = buildTypeEnv(tree, 'typescript', { - symbolTable, + model, parentMap: new Map([['Repo', ['BaseRepo']]]), }); expect(flatGet(typeEnv, 'profile')).toBe('Profile'); @@ -2521,32 +2477,15 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupCallableByName = vi.fn(() => []); - const symbolTable = createMockSymbolTable({ - lookupClassByName: (name: string) => - name === 'Repo' - ? [ - createClassDef('Repo', 'Class', 'models-a.ts'), - { - ...createClassDef('Repo', 'Class', 'models-b.ts'), - nodeId: 'class:Repo:partial', - }, - ] - : [], - lookupMethodByOwner: (ownerNodeId: string, methodName: string) => - ownerNodeId === 'class:Repo:partial' && methodName === 'getProfile' - ? { - nodeId: 'method:Repo:getProfile', - filePath: 'models-b.ts', - type: 'Method', - ownerId: 'class:Repo:partial', - returnType: 'Profile', - } - : undefined, - lookupExactAll: () => [], - lookupCallableByName, + const model = createSemanticModel(); + model.symbols.add('models-a.ts', 'Repo', 'class:Repo', 'Class'); + model.symbols.add('models-b.ts', 'Repo', 'class:Repo:partial', 'Class'); + model.symbols.add('models-b.ts', 'getProfile', 'method:Repo:getProfile', 'Method', { + ownerId: 'class:Repo:partial', + returnType: 'Profile', }); - const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); + const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); + const typeEnv = buildTypeEnv(tree, 'typescript', { model }); expect(flatGet(typeEnv, 'profile')).toBe('Profile'); expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile'); }); @@ -2560,33 +2499,17 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupCallableByName = vi.fn(() => []); - const symbolTable = createMockSymbolTable({ - lookupClassByName: (name: string) => { - if (name === 'Repo') { - return [ - createClassDef('Repo', 'Class', 'models-a.ts'), - { ...createClassDef('Repo', 'Class', 'models-b.ts'), nodeId: 'class:Repo:partial' }, - ]; - } - if (name === 'BaseRepo') return [createClassDef('BaseRepo', 'Class', 'base.ts')]; - return []; - }, - lookupMethodByOwner: (ownerNodeId: string, methodName: string) => - ownerNodeId === 'class:BaseRepo' && methodName === 'getProfile' - ? { - nodeId: 'method:BaseRepo:getProfile', - filePath: 'base.ts', - type: 'Method', - ownerId: 'class:BaseRepo', - returnType: 'Profile', - } - : undefined, - lookupExactAll: () => [], - lookupCallableByName, + const model = createSemanticModel(); + model.symbols.add('models-a.ts', 'Repo', 'class:Repo', 'Class'); + model.symbols.add('models-b.ts', 'Repo', 'class:Repo:partial', 'Class'); + model.symbols.add('base.ts', 'BaseRepo', 'class:BaseRepo', 'Class'); + model.symbols.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile', 'Method', { + ownerId: 'class:BaseRepo', + returnType: 'Profile', }); + const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); const typeEnv = buildTypeEnv(tree, 'typescript', { - symbolTable, + model, parentMap: new Map([['Repo', ['BaseRepo']]]), }); expect(flatGet(typeEnv, 'profile')).toBe('Profile'); @@ -2602,44 +2525,19 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupCallableByName = vi.fn(() => []); - const symbolTable = createMockSymbolTable({ - lookupClassByName: (name: string) => - name === 'Repo' - ? [ - createClassDef('Repo', 'Class', 'models-a.ts'), - { - ...createClassDef('Repo', 'Class', 'models-b.ts'), - nodeId: 'class:Repo:partial', - }, - ] - : [], - lookupMethodByOwner: (ownerNodeId: string, methodName: string) => { - if (methodName !== 'getProfile') return undefined; - if (ownerNodeId === 'class:Repo') { - return { - nodeId: 'method:Repo:getProfile#a', - filePath: 'models-a.ts', - type: 'Method', - ownerId: 'class:Repo', - returnType: 'Profile', - }; - } - if (ownerNodeId === 'class:Repo:partial') { - return { - nodeId: 'method:Repo:getProfile#b', - filePath: 'models-b.ts', - type: 'Method', - ownerId: 'class:Repo:partial', - returnType: 'Profile', - }; - } - return undefined; - }, - lookupExactAll: () => [], - lookupCallableByName, + const model = createSemanticModel(); + model.symbols.add('models-a.ts', 'Repo', 'class:Repo', 'Class'); + model.symbols.add('models-b.ts', 'Repo', 'class:Repo:partial', 'Class'); + model.symbols.add('models-a.ts', 'getProfile', 'method:Repo:getProfile#a', 'Method', { + ownerId: 'class:Repo', + returnType: 'Profile', }); - const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); + model.symbols.add('models-b.ts', 'getProfile', 'method:Repo:getProfile#b', 'Method', { + ownerId: 'class:Repo:partial', + returnType: 'Profile', + }); + const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); + const typeEnv = buildTypeEnv(tree, 'typescript', { model }); expect(flatGet(typeEnv, 'profile')).toBeUndefined(); expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile'); }); @@ -2653,42 +2551,18 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupCallableByName = vi.fn(() => []); - const symbolTable = createMockSymbolTable({ - lookupClassByName: (name: string) => - name === 'Repo' ? [createClassDef('Repo', 'Class', 'models.ts')] : [], - lookupMethodByOwner: (ownerNodeId: string, methodName: string) => - ownerNodeId === 'class:Repo' && methodName === 'getProfile' - ? { - nodeId: 'method:Repo:getProfile#1', - filePath: 'models.ts', - type: 'Method', - ownerId: 'class:Repo', - returnType: 'Profile', - } - : undefined, - lookupExactAll: (filePath: string, name: string) => - filePath === 'models.ts' && name === 'getProfile' - ? [ - { - nodeId: 'method:Repo:getProfile#1', - filePath: 'models.ts', - type: 'Method', - ownerId: 'class:Repo', - returnType: 'Profile', - }, - { - nodeId: 'method:Repo:getProfile#2', - filePath: 'models.ts', - type: 'Method', - ownerId: 'class:Repo', - returnType: 'Profile', - }, - ] - : [], - lookupCallableByName, + const model = createSemanticModel(); + model.symbols.add('models.ts', 'Repo', 'class:Repo', 'Class'); + model.symbols.add('models.ts', 'getProfile', 'method:Repo:getProfile#1', 'Method', { + ownerId: 'class:Repo', + returnType: 'Profile', }); - const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); + model.symbols.add('models.ts', 'getProfile', 'method:Repo:getProfile#2', 'Method', { + ownerId: 'class:Repo', + returnType: 'Profile', + }); + const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); + const typeEnv = buildTypeEnv(tree, 'typescript', { model }); expect(flatGet(typeEnv, 'profile')).toBe('Profile'); expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile'); }); @@ -2702,46 +2576,24 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupCallableByName = vi.fn(() => []); - const symbolTable = createMockSymbolTable({ - lookupClassByName: (name: string) => { - if (name === 'Repo') return [createClassDef('Repo', 'Class', 'models.ts')]; - if (name === 'BaseRepo') return [createClassDef('BaseRepo', 'Class', 'base.ts')]; - return []; - }, - lookupMethodByOwner: (ownerNodeId: string, methodName: string) => - ownerNodeId === 'class:BaseRepo' && methodName === 'getProfile' - ? { - nodeId: 'method:BaseRepo:getProfile', - filePath: 'base.ts', - type: 'Method', - ownerId: 'class:BaseRepo', - returnType: 'Profile', - } - : undefined, - lookupExactAll: (filePath: string, name: string) => - filePath === 'models.ts' && name === 'getProfile' - ? [ - { - nodeId: 'method:Repo:getProfile#1', - filePath: 'models.ts', - type: 'Method', - ownerId: 'class:Repo', - returnType: 'User', - }, - { - nodeId: 'method:Repo:getProfile#2', - filePath: 'models.ts', - type: 'Method', - ownerId: 'class:Repo', - returnType: 'Admin', - }, - ] - : [], - lookupCallableByName, + const model = createSemanticModel(); + model.symbols.add('models.ts', 'Repo', 'class:Repo', 'Class'); + model.symbols.add('base.ts', 'BaseRepo', 'class:BaseRepo', 'Class'); + model.symbols.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile', 'Method', { + ownerId: 'class:BaseRepo', + returnType: 'Profile', }); + model.symbols.add('models.ts', 'getProfile', 'method:Repo:getProfile#1', 'Method', { + ownerId: 'class:Repo', + returnType: 'User', + }); + model.symbols.add('models.ts', 'getProfile', 'method:Repo:getProfile#2', 'Method', { + ownerId: 'class:Repo', + returnType: 'Admin', + }); + const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); const typeEnv = buildTypeEnv(tree, 'typescript', { - symbolTable, + model, parentMap: new Map([['Repo', ['BaseRepo']]]), }); expect(flatGet(typeEnv, 'profile')).toBeUndefined(); @@ -2757,46 +2609,20 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupCallableByName = vi.fn(() => []); - const symbolTable = createMockSymbolTable({ - lookupClassByName: (name: string) => { - if (name === 'Repo') return [createClassDef('Repo', 'Class', 'models.ts')]; - if (name === 'BaseRepo') return [createClassDef('BaseRepo', 'Class', 'base.ts')]; - return []; - }, - lookupMethodByOwner: (ownerNodeId: string, methodName: string) => - ownerNodeId === 'class:BaseRepo' && methodName === 'getProfile' - ? { - nodeId: 'method:BaseRepo:getProfile#1', - filePath: 'base.ts', - type: 'Method', - ownerId: 'class:BaseRepo', - returnType: 'Profile', - } - : undefined, - lookupExactAll: (filePath: string, name: string) => - filePath === 'base.ts' && name === 'getProfile' - ? [ - { - nodeId: 'method:BaseRepo:getProfile#1', - filePath: 'base.ts', - type: 'Method', - ownerId: 'class:BaseRepo', - returnType: 'Profile', - }, - { - nodeId: 'method:BaseRepo:getProfile#2', - filePath: 'base.ts', - type: 'Method', - ownerId: 'class:BaseRepo', - returnType: 'Profile', - }, - ] - : [], - lookupCallableByName, + const model = createSemanticModel(); + model.symbols.add('models.ts', 'Repo', 'class:Repo', 'Class'); + model.symbols.add('base.ts', 'BaseRepo', 'class:BaseRepo', 'Class'); + model.symbols.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile#1', 'Method', { + ownerId: 'class:BaseRepo', + returnType: 'Profile', }); + model.symbols.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile#2', 'Method', { + ownerId: 'class:BaseRepo', + returnType: 'Profile', + }); + const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); const typeEnv = buildTypeEnv(tree, 'typescript', { - symbolTable, + model, parentMap: new Map([['Repo', ['BaseRepo']]]), }); expect(flatGet(typeEnv, 'profile')).toBe('Profile'); @@ -2812,22 +2638,24 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const symbolTable = createSymbolTable(); - symbolTable.add('models.ts', 'Repo', 'class:Repo', 'Class'); - symbolTable.add('base.ts', 'BaseRepo', 'class:BaseRepo', 'Class'); - symbolTable.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile#1', 'Method', { + // SM-21: construct a real SemanticModel and feed it via + // model.symbols.add so the nested registries are populated. + const model = createSemanticModel(); + model.symbols.add('models.ts', 'Repo', 'class:Repo', 'Class'); + model.symbols.add('base.ts', 'BaseRepo', 'class:BaseRepo', 'Class'); + model.symbols.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile#1', 'Method', { ownerId: 'class:BaseRepo', parameterCount: 1, returnType: 'User', }); - symbolTable.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile#2', 'Method', { + model.symbols.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile#2', 'Method', { ownerId: 'class:BaseRepo', parameterCount: 2, returnType: 'Admin', }); - const lookupCallableByName = vi.spyOn(symbolTable, 'lookupCallableByName'); + const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); const typeEnv = buildTypeEnv(tree, 'typescript', { - symbolTable, + model, parentMap: new Map([['Repo', ['BaseRepo']]]), }); expect(flatGet(typeEnv, 'profile')).toBeUndefined(); @@ -5740,24 +5568,20 @@ function process() { }); describe('importedReturnTypes (Phase 14 E3)', () => { - // Minimal mock SymbolTable that returns a known callable - const makeSymbolTable = (callables: Array<{ name: string; returnType?: string }>) => ({ - lookupCallableByName: (name: string) => - callables - .filter((c) => c.name === name) - .map((c) => ({ - nodeId: 'n1', - filePath: 'src.ts', - type: 'Function' as const, - returnType: c.returnType, - })), - lookupClassByName: () => [], - lookupExact: () => undefined, - lookupExactFull: () => undefined, - add: () => {}, - getStats: () => ({ fileCount: 0 }), - clear: () => {}, - }); + // Minimal real SemanticModel populated via model.symbols.add so that + // lookupCallableByName returns Function symbols with the requested + // return types. + const makeSymbolTable = ( + callables: Array<{ name: string; returnType?: string }>, + ): SemanticModel => { + const model = createSemanticModel(); + callables.forEach((c, idx) => { + model.symbols.add('src.ts', c.name, `n${idx}`, 'Function', { + returnType: c.returnType, + }); + }); + return model; + }; it('SymbolTable has unambiguous match → uses it, ignores cross-file', () => { // SymbolTable knows getConfig() returns Config (SymbolType) @@ -5765,7 +5589,7 @@ function process() { const symbolTable = makeSymbolTable([{ name: 'getConfig', returnType: 'Config' }]); const tree = parse('const c = getConfig();', TypeScript.typescript); const typeEnv = buildTypeEnv(tree, 'typescript', { - symbolTable: symbolTable as any, + model: symbolTable, importedReturnTypes: new Map([['getConfig', 'WrongType']]), }); // SymbolTable result (Config) wins over cross-file fallback (WrongType) @@ -5777,7 +5601,7 @@ function process() { const symbolTable = makeSymbolTable([]); const tree = parse('const c = getConfig();', TypeScript.typescript); const typeEnv = buildTypeEnv(tree, 'typescript', { - symbolTable: symbolTable as any, + model: symbolTable, importedReturnTypes: new Map([['getConfig', 'Config']]), }); // Cross-file fallback provides Config @@ -5792,7 +5616,7 @@ function process() { ]); const tree = parse('const r = process();', TypeScript.typescript); const typeEnv = buildTypeEnv(tree, 'typescript', { - symbolTable: symbolTable as any, + model: symbolTable, importedReturnTypes: new Map([['process', 'User']]), }); // Ambiguous → conservative → no binding produced @@ -5812,7 +5636,7 @@ function process() { const symbolTable = makeSymbolTable([{ name: 'getUser', returnType: 'User' }]); const tree = parse('const u = getUser();', TypeScript.typescript); const typeEnv = buildTypeEnv(tree, 'typescript', { - symbolTable: symbolTable as any, + model: symbolTable, importedReturnTypes: new Map([['getUser', 'CrossFileUser']]), }); // SymbolTable result (User) wins From b10d25bbca531bfbc1bd7497031737be780d9fda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sun, 12 Apr 2026 12:31:21 +0100 Subject: [PATCH 21/67] =?UTF-8?q?chore:=20release=20v1.6.0=20=E2=80=94=20u?= =?UTF-8?q?pdate=20CHANGELOG=20and=20package-lock=20(#798)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gitnexus/CHANGELOG.md | 59 ++++++++++++++++++++++++++++++++++++++ gitnexus/package-lock.json | 5 ++-- gitnexus/package.json | 2 +- 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/gitnexus/CHANGELOG.md b/gitnexus/CHANGELOG.md index 081eb9b26..26126a9e7 100644 --- a/gitnexus/CHANGELOG.md +++ b/gitnexus/CHANGELOG.md @@ -2,6 +2,65 @@ All notable changes to GitNexus will be documented in this file. +## [1.6.0] - 2026-04-12 + +### Added +- **SemanticModel architecture refactor (SM-8 through SM-19)** — extracted registries into `model/` module with ISP-compliant interfaces: TypeRegistry, MethodRegistry, FieldRegistry, RegistrationTable, ResolutionContext (#786) + - HeritageMap built from accumulated `ExtractedHeritage[]` for MRO-aware resolution (#739) + - `lookupMethodByOwnerWithMRO` using HeritageMap for cross-class method dispatch (#740) + - MRO fast path before D2 fuzzy widening in call resolution (#741) + - BindingAccumulator for cross-file return type propagation (#743, #763) + - Restructured `resolveUncached` replacing `lookupFuzzy` data source for all tiers (#764) + - Deleted `lookupFuzzy`, `lookupFuzzyCallable`, `globalIndex`, `callableIndex` — replaced with structured lookups (#769) + - Deleted `resolveCallTarget` god-method — replaced with thin dispatcher delegating to `resolveMemberCall` (#744), `resolveStaticCall` (#754), `resolveFreeCall` (#756) (#770) +- **Service group infrastructure** — service boundary detection, contract extractors, sync pipeline, CLI/MCP tools, monorepo fixture; bridge.lbug storage and contract matching expansion (#795) +- **C# interface-to-interface heritage** capture (#789) +- **Vue SFC support** with destructured call result tracking (#604) +- **Java method reference** resolution — `obj::method` as call sites (#622) +- **C/C++ MethodExtractor** config with pure virtual detection (#617) +- **MethodExtractor configs** for Python, PHP, Swift, Dart, Rust, Ruby (#624) +- **METHOD_IMPLEMENTS edges** with overload disambiguation and MethodExtractor unification (#642) +- **Same-arity overload disambiguation** via type-hash suffix (#658) +- **`GITNEXUS_HOME` env var** to customize global directory (#746) +- **Verbose analyze output** prints skipped large file paths (#745) +- **Class name lookup index** for O(1) qualified lookups (#707, #716) +- **`lookupMethodByOwner` index** for O(1) cross-class chain resolution (#665) +- **Fuzzy lookup counters** for performance visibility (#708) + +### Fixed +- **Stack overflow on large PHP files** — iterative AST traversal (#783) +- **Large repository graph loading** failure (#732) +- **Windows multi-repo switching** — false 404 errors and stale repo context (#633) +- **`detect_changes` diff mapping** — map diff hunks to symbol line ranges (#779) +- **HTTP client vs Express route detection** and Spring interface attribution (#780) +- **VECTOR extension** not loaded during DB init for semantic search (#782) +- **tree-sitter-swift** postinstall patch for macOS ARM64 (#788) +- **tree-sitter-c** peer dependency conflict pinned (#723) +- **Constructor indexing** in methodByOwner (#694, #753) +- **Named binding processor** — `lookupExact` replaced with `lookupExactAll` (#755) +- **`.gitnexusignore` negation patterns** now respected (#654) +- **MCP setup** prefers global gitnexus binary over npx (#653) +- **CORS rejection** returns clean error instead of 500 (#646) +- **Array.push stack overflow** — replaced spread with loop (#650) +- **MCP stdout silencing** prevents embedder/pool-adapter conflicts (#645) +- **Web heartbeat** — graceful reconnection replaces aggressive disconnect (#643) +- **Web repo scoping** — backend calls scoped to active repo (#644) +- **OpenCode config path** and FTS extension load order (#781) +- **OnboardingGuide** dev-mode serve command corrected (#725) +- **Security issues** and critical bugs from code review (#709) + +### Changed +- Replaced class-type fuzzy lookups with structured indices in type-env (#733, #734, #736) +- Extracted `CLASS_LIKE_TYPES` constant (#693) + +## [1.5.3] - 2026-04-01 + +### Added +- **TypeScript/JavaScript MethodExtractor** config (#588) + +### Fixed +- **Wiki Azure OpenAI** compat and HTML viewer script injection (#618) + ## [1.5.2] - 2026-04-01 ### Fixed diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 3736d0c46..ee80faecf 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -1,12 +1,13 @@ { "name": "gitnexus", - "version": "1.5.3", + "version": "1.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitnexus", - "version": "1.5.3", + "version": "1.6.0", + "hasInstallScript": true, "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { "@huggingface/transformers": "^3.0.0", diff --git a/gitnexus/package.json b/gitnexus/package.json index 871524702..864f28101 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -1,6 +1,6 @@ { "name": "gitnexus", - "version": "1.5.3", + "version": "1.6.0", "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.", "author": "Abhigyan Patwari", "license": "PolyForm-Noncommercial-1.0.0", From 4d4756fe869bea86d5f10a652bbccd800a830cdc Mon Sep 17 00:00:00 2001 From: ivkond Date: Mon, 13 Apr 2026 10:49:30 +0300 Subject: [PATCH 22/67] feat(group): extractor expansion + manifest extractor (2/4 of #606 split) (#796) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(group): extractor expansion + manifest extractor Part 2 of 4 in the split of #606 (ticket: #792). Follows #795 (bridge.lbug storage foundation, already merged), but this PR has no code-level dependency on #795 — it only imports types and the ContractExtractor interface that existed on upstream main before either PR. It could have been reviewed in parallel with #795. ## What changed Expands the 3 existing contract extractors with substantially more language/framework coverage, and adds a new `manifest-extractor` that resolves `group.yaml`-declared cross-links against the per-repo graph via exact-name lookups. ### New file (228 LOC) - `gitnexus/src/core/group/extractors/manifest-extractor.ts` — exact graph lookup for `group.yaml`-declared cross-links. HTTP paths are canonicalized before Route.name matching; gRPC is resolved by service/method name (NO `.proto`-filename fallback); topic and lib use exact-name match. Falls back to a synthetic `manifest::::` uid when the graph has no matching symbol, so cross-impact traversal still has a stable anchor for the contract. ### Modified extractors (+958 LOC prod) - `extractors/grpc-extractor.ts` (+522) — `.proto` parser with comment and string-literal sanitization (braces inside strings no longer truncate service bodies); package/service/method canonical IDs; server/client detection across Go (`grpc.NewServer`, `RegisterXxxServer`, `XxxGrpc.XxxImplBase`), Java (`@GrpcService`, `BlockingStub`), Python (`servicer_to_server`, `XxxStub`), and TypeScript/Node (`@GrpcMethod`, `ClientGrpc`, `loadPackageDefinition`). - `extractors/http-route-extractor.ts` (+174) — Go gin/echo/stdlib `HandleFunc`, NestJS `@Controller`+`@Get`/etc, Python FastAPI decorators, Java Spring `@RequestMapping`/`@GetMapping`, restTemplate / WebClient / OkHttp consumers. - `extractors/topic-extractor.ts` (+98) — sarama `ProducerMessage{}` struct literal detection (replaces a constructor-anchored regex that missed topics inside producer loops), kafka-go Writer/Reader, Python NATS (`await nc.subscribe`/`await nc.publish`), JetStream helpers. ### Modified and new tests (+1264 LOC) - `grpc-extractor.test.ts` (+539) — full coverage of the new proto parser (strings-with-braces regression, comments-with-braces regression), per-language server/client detection - `http-route-extractor.test.ts` (+240) — per-framework route extraction + normalization edge cases - `topic-extractor.test.ts` (+177) — the sarama in-loop regression, JetStream, Python NATS, kafka-go Writer/Reader - `manifest-extractor.test.ts` (+308 NEW) — HTTP path normalization, gRPC exact lookup with proto-fallback regression, lib and topic exact matching, synthetic-uid fallback behavior ### Self-review fixes folded in Carried forward from the #606 self-review (commit `d15b8cb`): - **HIGH #1** — `manifest-extractor.resolveSymbol` was too fuzzy. Previously used `CONTAINS` on route/name fields plus an unconditional `filePath ENDS WITH '.proto'` fallback for gRPC. Consequences: `/orders` matched `/suborders`, and any repo with any `.proto` file returned a random proto symbol for a gRPC manifest entry. Replaced with exact equality + deterministic `ORDER BY` + synthetic-uid fallback for unresolved manifests. Regression tests included. - **MED #3** — gRPC proto parser brace-depth counting now sanitizes strings and comments first (`stripProtoCommentsAndStrings`). A valid proto with `option deprecated_reason = "use NewService { instead"` used to have its service body closed early by the `"{"` inside the literal, silently dropping methods after the offending string. Regression tests for both string-with-brace and comment-with-brace cases. - **MED #4** — sarama Kafka regex changed from `sarama.NewSyncProducer[\s\S]{0,300}?Topic:` (anchored on constructor, caught only first topic in a loop) to `sarama.ProducerMessage{...Topic:}` (matches every struct literal directly). Regression test with a for-loop that constructs multiple `ProducerMessage`s. - **MED #7** — `manifest-extractor.resolveSymbol` no longer has a silent `catch { /* fall through */ }`. Errors from the graph executor are logged via `console.warn` with link type, contract name, repo key, and error message before falling through to the synthetic-uid path. ## Why Reviewer focus here is pure regex / parser correctness — no storage, no Cypher queries, no algorithmic changes to the cross-link algorithm. Separating this from the bridge foundation PR (#795) meant reviewers could stay in a single mental mode (parsing logic) instead of context-switching between DDL, Cypher, and regex. ## How to verify - `cd gitnexus && npx tsc --noEmit` - `cd gitnexus && npx vitest run test/unit/group/grpc-extractor.test.ts --pool=forks` - `cd gitnexus && npx vitest run test/unit/group/http-route-extractor.test.ts --pool=forks` - `cd gitnexus && npx vitest run test/unit/group/topic-extractor.test.ts --pool=forks` - `cd gitnexus && npx vitest run test/unit/group/manifest-extractor.test.ts --pool=forks` Local pre-push: typecheck clean, all 99 extractor unit tests pass (grpc 43, http 18, topic 30, manifest 8). ## Risk / rollback **Low.** Extractors have no user-facing surface in this PR — they produce `ExtractedContract[]` that is consumed by `sync.ts` in the next split (#793). No existing behavior changes for users who don't run a `group sync`. Rollback = `git revert` of the merge commit; the modifications to `grpc-extractor.ts` / `http-route-extractor.ts` / `topic-extractor.ts` revert to the pre-PR versions that still work (they're subsets of the new functionality). ## Scope discipline (per GUARDRAILS.md) - Only the 8 files above are touched; no drive-by refactors - No CI/release/security config changes - No secrets or machine-specific paths - Content lifted from #606 (CI 11/11 green on `d15b8cb`) ## Dependencies - **Base:** `main` (upstream already includes #795 as `1ff324c`) - **Blocks:** sync pipeline (#793) and the cross-impact feature (#794) - **Tracker issue:** #792 - **Parent PR:** #606 Co-authored-by: Claude * refactor(group): migrate topic-extractor from regex to tree-sitter queries Addresses @magyargergo's feedback on #796 that regex-based lookups should use tree-sitter nodes instead, and that the top-level extractors must NOT carry language dependencies. This is phase 1 of a multi-step migration — topic-extractor first because its patterns are the most uniform (16 "call/annotation with first-arg string literal" variants), which makes it a clean proof of the approach before grpc-extractor and http-route-extractor get the same treatment. ## Architecture: language-agnostic orchestrator + per-language plugins The top-level extractor is a thin orchestrator that never imports a tree-sitter grammar or a query string. Per-language knowledge lives in a new `topic-patterns/` folder with one file per language plus a registry that maps file extensions to compiled plugins: ``` src/core/group/extractors/ ├── tree-sitter-scanner.ts # shared, language-agnostic scanning utilities ├── topic-extractor.ts # thin orchestrator (no grammar imports) └── topic-patterns/ ├── types.ts # TopicMeta, Broker ├── index.ts # registry: extension → compiled provider ├── java.ts # tree-sitter-java + JAVA_TOPIC_PROVIDER ├── go.ts # tree-sitter-go + GO_TOPIC_PROVIDER ├── python.ts # tree-sitter-python + PYTHON_TOPIC_PROVIDER └── node.ts # tree-sitter-javascript + tree-sitter-typescript # → JAVASCRIPT_/TYPESCRIPT_/TSX_TOPIC_PROVIDER ``` **Shared scanner (`tree-sitter-scanner.ts`)** — defines `PatternSpec`, `LanguagePatterns`, `CompiledPatterns` and the `scanFile(parser, plugin, content)` helper. Plugins compile their queries eagerly at module load via `compilePatterns()`, so a broken pattern fails loudly at import time instead of silently at scan time. `unquoteLiteral()` handles single/double/template quotes, Python triple-quoted strings, and Go raw backtick strings. **Per-language plugins** own: - the tree-sitter grammar import (this is the ONLY place in `src/core/group/` where tree-sitter grammars are imported), - the query S-expressions, - the `TopicMeta` payload (role, broker, confidence, symbolName) that the orchestrator receives back on every match. Each plugin uses a `@value` capture name to bind the topic literal node. The JavaScript and TypeScript grammars share AST node names for every construct we query, so `node.ts` defines the pattern sources once and compiles them against `JavaScript`, `TypeScript.typescript`, and `TypeScript.tsx` — exporting three providers because `Parser.Query` objects are NOT portable across grammar instances. **Registry (`topic-patterns/index.ts`)** — maps `.java` → Java provider, `.go` → Go, `.py` → Python, `.js`/`.jsx` → JS, `.ts` → TS, `.tsx` → TSX. Also exports `TOPIC_SCAN_GLOB` so adding a new language is a single file-level edit (drop `topic-patterns/.ts`, import + register it here — zero edits required in `topic-extractor.ts`). **Orchestrator (`topic-extractor.ts`)** — ~110 lines, no grammar or query imports. Per file: `getProviderForFile(rel)` → `scanFile(parser, provider, content)` → `unquoteLiteral(valueText)` → `makeContract(...)`. Reuses one `Parser` instance across files; the scanner calls `setLanguage` per plugin. ## Why this is better than regex 1. **Comments and strings are respected for free.** The old regex would match `// kafkaTemplate.send("fake.topic")` as a real producer; tree-sitter never visits comments or string literals as code nodes, so false positives from commented-out code are eliminated. 2. **Struct/object literal patterns are structural, not textual.** `sarama.ProducerMessage{Topic: "..."}` no longer needs a 300-char lookahead (which was a known cross-match bug partly mitigated by a loop regression test in the self-review). The new query matches a specific `composite_literal` with a specific `qualified_type` and `keyed_element` — exactly one struct literal per match. 3. **No order-of-operations fragility.** Regex for `channel.publish` vs `channel.consume` was independent and file-wide; the AST scopes matches to the specific `call_expression`. 4. **Language-agnostic extension.** Adding Ruby, Rust, or C# topic detection later means dropping one file in `topic-patterns/` — no changes to shared scanner or orchestrator, and no tree-sitter imports leak into top-level code. ## Per-file fault tolerance - Malformed files that tree-sitter can't parse are silently skipped (`parser.parse` is wrapped by `scanFile`). The ingestion pipeline already logs unparseable files at index time. - A syntactically invalid query is caught at `compilePatterns` time, not scan time — broken plugins fail loudly at import. - Per-pattern `matches()` failures are swallowed so one broken query in a plugin doesn't block the rest. ## Tests All 30 existing `topic-extractor.test.ts` tests pass **without any changes to the test file** — they were written as input/output contract tests (given this source file, expect these `ExtractedContract` objects) and that contract is unchanged. Regression coverage includes: - Kafka: Java `@KafkaListener` + `kafkaTemplate.send`; Node `producer.send` + `consumer.subscribe`; Go sarama producer/consumer (sync and async); kafka-go Writer/Reader; Python `KafkaConsumer` + `producer.send/produce` - RabbitMQ: Java `@RabbitListener` + `rabbitTemplate.convertAndSend`; Node `channel.consume/publish/sendToQueue`; Python `basic_consume/ basic_publish` with keyword args - NATS: Go and Node `nc.Subscribe/Publish`; Go and Node JetStream `js.Subscribe/Publish`; Python `await nc.subscribe/publish` Including the regression test for the sarama `ProducerMessage` in-loop case — the AST-based query captures every literal in the file independently, not just the first one after `NewSyncProducer`. ## Neighbor regression check - `topic-extractor.test.ts` — 30/30 pass (rewritten extractor) - `http-route-extractor.test.ts` — 18/18 pass (untouched) - `grpc-extractor.test.ts` — 43/43 pass (untouched) - `manifest-extractor.test.ts` — 8/8 pass (untouched) - Full `npx tsc --noEmit` clean ## Scope discipline (per GUARDRAILS.md) - Only files under `src/core/group/extractors/` are touched; no changes to other extractors, tests, MCP surface, or pipeline.ts. - No CI/release/security config changes, no secrets. - New tree-sitter imports all reference grammars that are already installed as dependencies (`tree-sitter`, `tree-sitter-javascript`, `tree-sitter-typescript`, `tree-sitter-python`, `tree-sitter-java`, `tree-sitter-go` — all in `package.json` for the existing pipeline). ## Phase 2 / phase 3 plan - **Phase 2 (next commit):** rewrite `http-route-extractor.ts` Strategy B (regex fallback) on the same plugin pattern. Graph-assisted Strategy A stays as-is (already uses pipeline-built tree-sitter data via `HANDLES_ROUTE` Cypher queries). - **Phase 3 (commit after):** rewrite `grpc-extractor.ts` for Java / Go / Python / TypeScript detection. `.proto` files are the one outstanding question — there is no `tree-sitter-proto` grammar installed; the in-tree string-sanitizing parser stays as a pragmatic exception with a comment, alternative being to add `tree-sitter-proto` as a dep (open for the maintainer). Co-authored-by: Claude * refactor(group): migrate http-route-extractor Strategy B to tree-sitter plugins Phase 2 of the extractor refactor requested by @magyargergo on #796. Same architecture as the phase 1 topic-extractor rewrite: a thin, language-agnostic orchestrator plus per-language plugins that own tree-sitter grammars and query sources. The top-level extractor file no longer imports any tree-sitter grammar or query string. ## Architecture ``` src/core/group/extractors/ ├── tree-sitter-scanner.ts # shared, language-agnostic primitives ├── http-route-extractor.ts # thin orchestrator (no grammar imports) └── http-patterns/ ├── types.ts # HttpDetection, HttpLanguagePlugin, HttpRole ├── index.ts # registry: ext → plugin + HTTP_SCAN_GLOB ├── java.ts # tree-sitter-java: Spring + RestTemplate/WebClient/OkHttp ├── go.ts # tree-sitter-go: gin/echo/HandleFunc + http/resty consumers ├── python.ts # tree-sitter-python: FastAPI + requests ├── php.ts # tree-sitter-php: Laravel Route::get/... └── node.ts # tree-sitter-javascript + tree-sitter-typescript: # NestJS controllers, Express, fetch, axios ``` **Shared scanner (`tree-sitter-scanner.ts`)** — generalised from phase 1: - `ScanMatch.captures` is now a full `CaptureMap` (every named capture the query binds, not just a single `@value`). Topic extractor updated to read `match.captures.value` accordingly. - New `runCompiledPatterns(plugin, tree)` helper lets plugins run multiple query bundles against the same pre-parsed tree. This is needed for HTTP plugins that combine a class-prefix query with a method-route query (Spring, NestJS). - `scanFile` becomes a thin wrapper over `parser.parse + runCompiledPatterns`. **HTTP plugin shape** — unlike topic plugins, HTTP plugins expose a `scan(tree)` function rather than a flat pattern list. This reflects HTTP's more complex extraction: each detection needs method + path + handler name, and framework patterns like Spring `@RequestMapping` / NestJS `@Controller` require cross-referencing a class-level prefix with method-level annotations. Plugins internally use `compilePatterns` + `runCompiledPatterns` and walk the AST to resolve the class/method relationships. **Per-framework coverage:** - **Java (`java.ts`)** - Spring: `@RequestMapping("/api/v2")` class prefix + `@(Get|Post|Put| Delete|Patch)Mapping("/sub")` method routes, joined via the enclosing `class_declaration` node id. - `RestTemplate.getForObject/postForEntity/put/delete/patchForObject` → method derived from API name. - `WebClient.method(HttpMethod.X, "/path")` → method from `HttpMethod.X` capture. - `new Request.Builder().url("/path")` → OkHttp consumer. - **Go (`go.ts`)** - gin / echo / chi frameworks: `\w+.GET("/path", handler)` captures upper-case verb + handler identifier. - `net/http.HandleFunc("/path", handler)` → provider (default GET). - `http.Get/Post/Head` consumer, `http.NewRequest("METHOD", ...)`, resty `client.R().Get/Post/...`. - **Python (`python.ts`)** - `@app.get("/path")` FastAPI decorators. - `requests.get/post/...` and `requests.request("METHOD", "url")`. - **PHP (`php.ts`)** - Laravel `Route::get/post/.../patch('/path', ...)` via `scoped_call_expression`. Uses `PHP.php_only` to match the existing ingestion pipeline's grammar selection. - **Node (`node.ts`) — JS + TS + TSX** - Pattern sources defined once, compiled against three grammar variants (`JavaScript`, `TypeScript.typescript`, `TypeScript.tsx`) because `Parser.Query` objects are not portable across grammars. Exports three plugins sharing the same `scan` logic. - NestJS: `@Controller('prefix')` decorators are siblings of the class in `export_statement` / `program`; `@Get(':id')` decorators are siblings of the method in `class_body`. The plugin walks decorator → next named sibling to find the decorated class / method, then combines the class prefix with the method path. Only emits NestJS detections when the enclosing class has a real `@Controller` decorator — prevents false positives from generic classes that happen to use `@Get` from another library. - Express: `(router|app).('/path', ...)`. - `fetch(url)` (default GET) + `fetch(url, { method: 'X' })` (uses two queries + a SyntaxNode-id dedupe set so URL literals aren't double-emitted by the options variant). - `axios.get/post/...`. ## Orchestrator changes `http-route-extractor.ts` drops every `scanXxxProviders` / `scanXxxConsumers` regex method and replaces them with a single source-scan loop that delegates to `getPluginForFile(rel).scan(tree)`. The orchestrator still owns: - **Path normalization** (`normalizeHttpPath`, `normalizeConsumerPath`) — language-agnostic string processing shared by both strategies. - **Graph-assisted Strategy A** (`HANDLES_ROUTE` / `FETCHES` / `CONTAINS` Cypher queries) — unchanged in spirit. The only regex helpers it used (`inferMethodFromFileScan`, `pickJavaHandlerName`) are now replaced by a lookup against the plugin's detections for the same file: for each route row, find the detection whose normalized path matches, and pull the HTTP method + handler name from it. - **Per-file parse cache** — the orchestrator parses each relevant file at most once per `extract()` call. Both the graph-assisted enrichment loop and the source-scan fallback share the same `cachedDetections` map, so we never run the plugin twice for the same file. ## Why this is better than the regex version 1. **Comments and strings for free.** The old regex would match `// router.get('/fake')` as a real Express route; tree-sitter never visits string/comment nodes. 2. **Structural controller-prefix.** Spring and NestJS class-prefix joining is now scoped to the enclosing class via `class_declaration` node ids, eliminating file-wide state that broke when a file had multiple controllers. 3. **Precise NestJS disambiguation.** The plugin only emits a NestJS detection when the enclosing class has a real `@Controller` decorator — the old regex would fire on any `@Get(...)` in the file regardless of surrounding context. 4. **Language-agnostic extension.** Adding Ruby / Rust / Kotlin HTTP detection later means dropping one file in `http-patterns/` — no changes to the shared scanner, the orchestrator, or the Strategy A Cypher queries. ## Tests - `http-route-extractor.test.ts` — **18/18 pass** (tests unchanged; they're contract-style input/output tests and the contract shape is unchanged). Covers Spring class prefix, Express, gin/echo, stdlib HandleFunc, NestJS, Laravel, FastAPI for providers and fetch/axios/python-requests/rest-template/webClient/okhttp/go-stdlib/ resty for consumers, plus graph-first Strategy A for both. - `topic-extractor.test.ts` — **30/30 pass** after the `captures.value` API migration. - `grpc-extractor.test.ts` — 43/43 pass (untouched; phase 3). - `manifest-extractor.test.ts` — 8/8 pass (untouched). - `service.test.ts`, `sync.test.ts`, `storage.test.ts` — 41/41 pass. - `npx tsc -p tsconfig.json --noEmit` clean. ## Scope discipline (per GUARDRAILS.md) - Only files under `src/core/group/extractors/` are touched. - No changes to pipeline.ts, MCP surface, ingestion, or tests. - No CI / release / security / secrets changes. - Tree-sitter grammars imported by plugins (`tree-sitter-java`, `tree-sitter-go`, `tree-sitter-python`, `tree-sitter-php`, `tree-sitter-javascript`, `tree-sitter-typescript`) are all already in `package.json` for the existing ingestion pipeline. ## Phase 3 plan - **grpc-extractor** gets the same treatment: plugin-per-language under `grpc-patterns/` for Java / Go / Python / TS detection. `.proto` files remain an open question — no `tree-sitter-proto` grammar is installed, so the in-tree string-sanitizing parser from PR #796's self-review stays as a pragmatic exception unless the maintainer wants us to add `tree-sitter-proto` as a new dep. Co-authored-by: Claude * refactor(group): migrate grpc-extractor source scans to tree-sitter plugins Phase 3 (final) of the extractor refactor requested by @magyargergo on #796. Same architecture as phase 1 (topic) and phase 2 (http): thin language-agnostic orchestrator + per-language plugins that own tree-sitter grammars and query sources. With this commit the top-level extractors under `src/core/group/extractors/` import ZERO tree-sitter grammars and ZERO query strings — every grammar import lives in a `*-patterns/.ts` plugin file, and the orchestrators go through the registry indirection. ## Architecture ``` src/core/group/extractors/ ├── tree-sitter-scanner.ts # shared primitives (unchanged) ├── grpc-extractor.ts # orchestrator (only `.proto` parser left) └── grpc-patterns/ ├── types.ts # GrpcDetection, GrpcLanguagePlugin, GrpcRole ├── index.ts # registry: ext → plugin + GRPC_SCAN_GLOB ├── go.ts # tree-sitter-go: RegisterXxxServer, Unimplemented, NewXxxClient ├── java.ts # tree-sitter-java: @GrpcService + XxxImplBase + newBlockingStub ├── python.ts # tree-sitter-python: add_XxxServicer_to_server + XxxStub └── node.ts # tree-sitter-javascript + tree-sitter-typescript: # @GrpcMethod, @GrpcClient field type, # .getService('Svc'), new XxxServiceClient, # loadPackageDefinition dynamic constructors ``` ## Per-language coverage **Go (`go.ts`)** - Provider: `\w+.RegisterXxxServer(...)` via `call_expression → selector_expression → field_identifier` + JS regex filter `^Register(\w+)Server$`. - Provider: `pb.UnimplementedXxxServer` embedded in a struct via `struct_type → field_declaration_list → field_declaration → qualified_type → type_identifier` + JS filter. - Consumer: `\w+.NewXxxClient(...)` via the same call_expression query + JS filter `^New(\w+)Client$`. **Java (`java.ts`)** - Provider: `class X extends YyyGrpc.YyyImplBase` — two queries handle the scoped and plain forms. `scoped_type_identifier`'s children are positional (no `scope:`/`name:` fields), so the query matches the two `type_identifier` children by position. - `#match? @inner "ImplBase$"` restricts matches at query time. - Whether the class has `@GrpcService` or not controls only the `source` metadata label — the plugin walks the class_declaration's `modifiers` child in JS to detect the marker_annotation. - Consumer: `YyyGrpc.newStub(ch)` / `newBlockingStub(ch)` via a `method_invocation` query with `#match? @method "^new(Blocking)?Stub$"`, service name extracted via `^(\w+)Grpc$` on the object identifier. **Python (`python.ts`)** - Single call-expression query covers both bare identifier and `obj.method` attribute forms: `(call function: [(identifier) @fn (attribute attribute: (identifier) @fn)])`. - Plugin filters `@fn.text` against two JS regexes: `^add_(\w+)Servicer_to_server$` (provider) and `^(\w+)Stub$` (consumer), with a reserved-names ignore list for the Stub case (Mock / Test / Fake / Stub). **Node — JavaScript + TypeScript + TSX (`node.ts`)** - Pattern sources defined once, compiled three times (one per grammar) because `Parser.Query` objects are not portable across grammars. Exports three `GrpcLanguagePlugin`s sharing the same `scan`. - `@GrpcMethod('Service', 'Method')`: decorator query captures the two string literals. Confidence is hard-coded 0.8 regardless of proto map resolution (matches the original regex version's behaviour). - `@GrpcClient(...) field: XxxServiceClient`: decorator query captures the decorator node, plugin walks up to find the enclosing `public_field_definition` (decorators on fields are CHILDREN of the field definition in tree-sitter-typescript, not siblings) and reads its first `type_annotation → type_identifier`, then runs the `^(\w+Service)Client$` JS filter. - `client.getService('AuthService')`: call-expression query on `member_expression.property = "getService"` + string literal arg. - `new XxxServiceClient(...)`: `new_expression` with a bare identifier constructor, filtered by `^(\w+Service)Client$` so generic `new AuthClient(...)` (missing the `Service` infix) does NOT falsely register as a consumer. Preserves the regression test `test_extract_ts_non_service_client_constructor_is_ignored`. - `loadPackageDefinition` dynamic loader: gated on `tree.rootNode.text.includes('loadPackageDefinition')`. When set, `new foo.bar.Xxx(...)` qualified constructors with a capitalised property name register as consumers. ## Orchestrator changes `grpc-extractor.ts` loses every `scanGoProviders` / `scanJavaProviders` / ... helper and replaces them with a single source-scan loop that: 1. Parses each file with the plugin's grammar (one shared `Parser` instance across all files, `setLanguage` called per plugin). 2. Calls `plugin.scan(tree)` to get `GrpcDetection[]`. 3. Converts each detection to an `ExtractedContract` via the private `detectionToContract` helper, which: - Looks the short service name up in the proto map (filled by the `.proto` parser). - Picks confidence = `confidenceWithProto` if resolved, else `confidenceWithoutProto`. - Builds a method-level contract id (`grpc::pkg.Svc/Method`) when the detection carries a `methodName` (TS `@GrpcMethod` only), otherwise a service-level id (`grpc::pkg.Svc/*`). Everything else — the `.proto` parser, `buildProtoContext`, `buildProtoMap`, `resolveProtoConflict`, `serviceContractId`, `stripProtoCommentsAndStrings`, `extractServiceBlocks`, the dedupe function — stays exactly as before. The `.proto` parser is kept as a pragmatic exception to the "no regex in extractors" rule because no `tree-sitter-proto` grammar is installed in the repo; a comment at the top of the file explains this and flags the maintainer option of adding `tree-sitter-proto` as a dependency. ## Why this is better than the regex version 1. **Comments and strings are respected for free.** Matched node types are only code constructs, never text inside comments or string literals. 2. **No false positives on partial names.** The old `(\w+?)Grpc`-style regexes would cross-match unrelated identifiers; structural queries restrict matches to the exact AST shape (`scoped_type_identifier → type_identifier` pairs, `method_invocation → identifier` etc.). 3. **NestJS `@GrpcClient` is structural, not regex-based.** The old regex required a specific textual layout (`@GrpcClient(...) private readonly foo!: XxxServiceClient`); the plugin now walks the AST, so modifier order / optional modifiers / multi-line formatting don't break it. 4. **Language-agnostic extension.** Adding Kotlin / Rust / C# gRPC detection later is a one-file edit in `grpc-patterns/index.ts` — no touches to the shared scanner, the orchestrator, or the proto parser. ## Tests - `grpc-extractor.test.ts` — **43/43 pass** (tests unchanged; the contract shape is identical). Covers .proto parsing (including the brace-inside-string regression), Go provider/consumer, Java @GrpcService / plain ImplBase provider + newBlockingStub consumer, Python servicer + stub, TS @GrpcMethod + @GrpcClient + .getService + new XxxServiceClient + loadPackageDefinition + the `AuthClient` vs `AuthServiceClient` discrimination, dedupe across multiple patterns in one file, proto-aware confidence, and the inherited-package resolution for split proto definitions. - `topic-extractor.test.ts` — 30/30 pass. - `http-route-extractor.test.ts` — 18/18 pass. - `manifest-extractor.test.ts` — 8/8 pass. - `service.test.ts`, `sync.test.ts`, `storage.test.ts` — 41/41 pass. - `npx tsc -p tsconfig.json --noEmit` clean. ## Scope discipline (per GUARDRAILS.md) - Only files under `src/core/group/extractors/` are touched. - No pipeline.ts, MCP surface, ingestion, CI / release / security, or test changes. - New tree-sitter grammar imports (`tree-sitter-go`, `tree-sitter-java`, `tree-sitter-python`, `tree-sitter-javascript`, `tree-sitter-typescript`) are all already installed for the ingestion pipeline. ## End of phase series This commit completes the three-phase extractor refactor: - **Phase 1** (`ea06d11`): topic-extractor → `topic-patterns/` - **Phase 2** (`b6015f6`): http-route-extractor → `http-patterns/` - **Phase 3** (this commit): grpc-extractor → `grpc-patterns/` Every remaining regex-based extractor helper under the `src/core/group/ extractors/` directory is either (a) language-agnostic string processing (path normalization, dedupe keys) or (b) the `.proto` parser, which is documented as an explicit exception. Co-authored-by: Claude * feat(group): add tree-sitter-proto for .proto file parsing Addresses @magyargergo's suggestion on #796 to replace the manual string-sanitizing .proto parser with a tree-sitter grammar. - **Vendored `tree-sitter-proto`** in `vendor/tree-sitter-proto/`. Grammar source from [coder3101/tree-sitter-proto](https://github.com/coder3101/tree-sitter-proto) (latest `grammar.js`), parser.c regenerated with `tree-sitter-cli 0.24` to produce ABI version 14 — compatible with the project's `tree-sitter 0.25` runtime (which supports ABI ≤ 14). Added as `optionalDependency` with `file:./vendor/tree-sitter-proto`. - **New `grpc-patterns/proto.ts` plugin** — uses the same `compilePatterns` + `runCompiledPatterns` infrastructure as every other plugin. Two queries: - `(package (full_ident) @pkg)` — package declaration - `(service (service_name) @service_name (rpc (rpc_name) @rpc_name))` — one match per (service, rpc) pair - **Graceful fallback** — `tree-sitter-proto` is an optional dependency. If it fails to install (platform incompatibility) or fails the runtime smoke-test (`setLanguage` + `parse` on a trivial proto), `PROTO_GRPC_PLUGIN` stays `null` and the orchestrator uses the existing manual parser. The smoke-test catches the `SyntaxNode` TDZ error that occurs in vitest's fork-based test runner. - **Orchestrator updated** — when `hasProtoPlugin` is true, `.proto` files are handled by the plugin loop (they're included in `GRPC_SCAN_GLOB`), and the manual `parseProtoFile` loop is skipped. `buildProtoContext` still runs to build the proto map for cross-referencing source-file detections. 1. **No manual comment/string stripping.** The old parser needed `stripProtoCommentsAndStrings` (110 lines) to avoid counting braces inside comments and string literals. tree-sitter handles this natively. 2. **No brace-depth tracking.** `extractServiceBlocks` used a manual depth counter to find service boundaries. tree-sitter's AST gives us `service` → `service_name` + `rpc` → `rpc_name` directly. 3. **Performance.** tree-sitter's C-based parser is faster than character-by-character JS scanning + regex on large proto files. - `grpc-extractor.test.ts` — **43/43 pass** (unchanged) - All other extractor tests — 99/99 pass - `npx tsc -p tsconfig.json --noEmit` clean Co-authored-by: Claude * chore: add .gitignore for vendored tree-sitter-proto build artifacts https://claude.ai/code/session_01SFUCxgKMMQ8EgRHYw91xPU * fix: correct .gitignore paths for vendored tree-sitter-proto Patterns should be relative to the .gitignore file's directory. https://claude.ai/code/session_01SFUCxgKMMQ8EgRHYw91xPU * refactor(group): address Copilot review feedback on #796 Six fixes suggested by the Copilot AI review: 1. **`normalizeHttpPath` root-path edge case** — stripping trailing slashes on the input `/` produced an empty string, yielding malformed contract ids like `http::GET::`. Now preserves `/` for the root handler/fetch case. 2. **Dedupe `scanFiles` call** — `extract()` was globbing the source-scan file list twice (once for the provider fallback, once for the consumer fallback). Moved to a single lazy call that memoizes the result for the rest of the method. 3. **HTTP `scanFiles` now ignores `**/vendor/**`** — every other extractor's glob already ignored vendored sources; the HTTP one didn't. Fixed for consistency. 4. **`loadPackageDefinition` check is now structural** — was calling `tree.rootNode.text.includes('loadPackageDefinition')` which forces materialization of the entire file text from the parse tree (expensive on large files). Replaced with a dedicated compiled query on `(call_expression function: [(identifier) | (member_expression)])` so the check stays in the AST domain. 5. **`grpc-extractor.ts` header docstring updated** — still claimed ".proto parsing is not tree-sitter-based because no grammar is installed". Now describes the actual behaviour: tree-sitter when `tree-sitter-proto` is available (optionalDependency), manual fallback otherwise. 6. **Eliminated the double proto file parse on the fallback path** — `buildProtoContext` already globs + parses every `.proto` file to build `servicesByName`. On the `!hasProtoPlugin` branch the extractor was globbing + parsing again via the now-removed `parseProtoFile` helper. The fallback branch now iterates the map that `buildProtoContext` already produced to emit provider contracts directly — single pass per proto file. ## Tests - `topic-extractor.test.ts` — 30/30 pass - `http-route-extractor.test.ts` — 18/18 pass - `grpc-extractor.test.ts` — 43/43 pass - `manifest-extractor.test.ts` — 8/8 pass - `npx tsc -p tsconfig.json --noEmit` clean Co-authored-by: Claude * refactor(group): address Claude review feedback (bugs + dedup + hygiene) on #796 Follows up `2f28bfc` with the remaining items from the Claude AI review: ## Bugs **Bug 2 — Label-unaware Cypher queries in `resolveSymbol`.** The manifest-extractor's lookup queries were `MATCH (n) WHERE n.name = $x` with no label filter, so a topic/service/package name could silently match any node type (File, Variable, Import, Folder, …). Added label filters: - `topic` → `(n:Function|Method|Class|Interface)` (topics are best-effort symbol-name matches against listener/publisher symbols) - `grpc` method → `(n:Function|Method)` - `grpc` service → `(n:Class|Interface)` - `lib` → `(n:Package|Module)` All 8 manifest-extractor tests still pass (mock executor is label-agnostic, but the production LadybugDB graph now gets correctly scoped queries). **Bug 8 — Tautological `!handlerName` condition.** `http-route-extractor.ts:extractProvidersGraph` had `let handlerName = null; if (!method || !handlerName) { ... }` — the `!handlerName` clause was always true since there was no intervening assignment. Simplified to always run the plugin-scan lookup (we need the handler name even when `methodFromRouteReason` already resolved the method). ## Clean code / dedup **Design 7 — `readSafe` was copy-pasted in all three orchestrators.** Extracted to `extractors/fs-utils.ts` as the single source of truth for the path-traversal guard. Dropped the three local copies and the now-unused `fs`/`path` imports from topic-extractor. **Style 10 — Language-specific `_test.go` skip in the topic orchestrator.** Was `if (rel.endsWith('_test.go')) continue;` inside the language- agnostic extraction loop. Pushed into the glob's ignore list (`'**/*_test.go'`) alongside the existing `node_modules`, `vendor`, `dist`, `build` entries, with a comment explaining that other languages' test file conventions either live in separate directories (Python `tests/`, Java `src/test/`) or are already covered by the existing ignores. ## Already addressed in `2f28bfc` (mentioned again in Claude review) - Bug 3: `normalizeHttpPath('/')` returns `''` — fixed - Bug 4: double glob + double parse of `.proto` — fixed - Bug 5: `scanFiles` called twice in HTTP — fixed - Bug 6: missing `**/vendor/**` in HTTP glob — fixed - Design 9 partially: `tree.rootNode.text.includes('loadPackageDefinition')` replaced with a dedicated structural query ## Deferred - Bug 1 (`http::*::path` vs `http::GET::path` matching) — out of scope; sync.ts matching logic lands in #793, manifest extractor already emits correct synthetic uids for unresolved HTTP contracts. - Design 9 full (change plugin `scan(tree)` → `scan(tree, source)`) — the only real use case (`loadPackageDefinition` gate) is already fixed via a structural query, so the interface change would be cosmetic churn without a concrete consumer. ## Tests - `topic-extractor.test.ts` — 30/30 pass - `http-route-extractor.test.ts` — 18/18 pass - `grpc-extractor.test.ts` — 43/43 pass - `manifest-extractor.test.ts` — 8/8 pass - `npx tsc -p tsconfig.json --noEmit` clean Co-authored-by: Claude * docs+fix(group): address remaining Claude review items + add pipeline flow chart ## Fixes **Remaining 🔴 — HTTP contract id wildcard format.** Documented the `http::*::` format as an intentional wildcard for manifest links that omit the HTTP method, alongside the explicit-method form (`GET::/path` → `http::GET::/path`). The docblock on `buildContractId` now states both forms, notes that wildcard-aware matching is the responsibility of the sync / cross-impact layer (#793), and recommends the explicit-method form whenever the author knows the method (it round-trips through exact equality without needing wildcard logic downstream). Tests unchanged — the wildcard format is what they've always asserted. **Minor 1 — stale comment at `manifest-extractor.ts:124-126`.** The comment claimed "creates a contract with an empty symbolUid/ref" but the code switched to `manifestSymbolUid(repo, contractId)` a few commits back. Updated to describe the actual synthetic-uid fallback semantics and the cross-impact path that relies on both sides of the join deriving the same uid. **Minor 2 — exhaustiveness guard on `buildContractId`.** The `switch(type)` covered all five current `ContractType` variants but silently returned `undefined` if a new variant was added. Added a `default: const _exhaustive: never = type; throw new Error(...)` clause so the build fails loudly on an unhandled variant. **Minor 3 — `tree.rootNode.text` in `grpc-patterns/node.ts`.** Already fixed in `2f28bfc` via a dedicated structural query (`LOAD_PACKAGE_DEFINITION_SPEC`). No action needed. ## New: pipeline flow chart (per @magyargergo's request) Added `src/core/group/PIPELINE.md` with four Mermaid diagrams: 1. **High-level overview** — `group.yaml` → extractors + manifest → contract matching → `bridge.lbug` → `runGroupImpact`. 2. **Per-repo extractor two-strategy shape** — graph-assisted Strategy A vs. source-scan Strategy B. 3. **Plugin architecture** — orchestrator → registry → per-language `*-patterns/.ts` → `tree-sitter-scanner.ts` → `ExtractedContract`. 4. **Manifest extraction** — label-scoped `resolveSymbol` with the synthetic-uid fallback. 5. **Cross-impact query (#606)** — local impact → bridge join → cross-repo fan-out. Each diagram is annotated with which PRs own which stage (this PR: extractors + manifest; #795: bridge storage; #606: cross-impact runtime) and points at the concrete files/functions involved. ## Tests - 99/99 extractor tests pass - `npx tsc -p tsconfig.json --noEmit` clean Co-authored-by: Claude --------- Co-authored-by: Claude --- gitnexus/package-lock.json | 28 + gitnexus/package.json | 1 + gitnexus/src/core/group/PIPELINE.md | 139 + .../src/core/group/extractors/fs-utils.ts | 23 + .../core/group/extractors/grpc-extractor.ts | 626 +- .../core/group/extractors/grpc-patterns/go.ts | 109 + .../group/extractors/grpc-patterns/index.ts | 53 + .../group/extractors/grpc-patterns/java.ts | 179 + .../group/extractors/grpc-patterns/node.ts | 314 + .../group/extractors/grpc-patterns/proto.ts | 147 + .../group/extractors/grpc-patterns/python.ts | 77 + .../group/extractors/grpc-patterns/types.ts | 54 + .../core/group/extractors/http-patterns/go.ts | 224 + .../group/extractors/http-patterns/index.ts | 50 + .../group/extractors/http-patterns/java.ts | 267 + .../group/extractors/http-patterns/node.ts | 373 + .../group/extractors/http-patterns/php.ts | 79 + .../group/extractors/http-patterns/python.ts | 142 + .../group/extractors/http-patterns/types.ts | 65 + .../group/extractors/http-route-extractor.ts | 486 +- .../group/extractors/manifest-extractor.ts | 268 + .../core/group/extractors/topic-extractor.ts | 283 +- .../group/extractors/topic-patterns/go.ts | 123 + .../group/extractors/topic-patterns/index.ts | 49 + .../group/extractors/topic-patterns/java.ts | 83 + .../group/extractors/topic-patterns/node.ts | 165 + .../group/extractors/topic-patterns/python.ts | 119 + .../group/extractors/topic-patterns/types.ts | 27 + .../group/extractors/tree-sitter-scanner.ts | 193 + .../test/unit/group/grpc-extractor.test.ts | 539 +- .../unit/group/http-route-extractor.test.ts | 240 +- .../unit/group/manifest-extractor.test.ts | 308 + .../test/unit/group/topic-extractor.test.ts | 177 +- gitnexus/vendor/tree-sitter-proto/.gitignore | 3 + gitnexus/vendor/tree-sitter-proto/binding.gyp | 30 + .../bindings/node/binding.cc | 20 + .../bindings/node/index.d.ts | 28 + .../tree-sitter-proto/bindings/node/index.js | 7 + .../vendor/tree-sitter-proto/package.json | 18 + .../tree-sitter-proto/src/node-types.json | 1145 ++ .../vendor/tree-sitter-proto/src/parser.c | 10149 ++++++++++++++++ .../tree-sitter-proto/src/tree_sitter/alloc.h | 54 + .../tree-sitter-proto/src/tree_sitter/array.h | 291 + .../src/tree_sitter/parser.h | 266 + 44 files changed, 17186 insertions(+), 835 deletions(-) create mode 100644 gitnexus/src/core/group/PIPELINE.md create mode 100644 gitnexus/src/core/group/extractors/fs-utils.ts create mode 100644 gitnexus/src/core/group/extractors/grpc-patterns/go.ts create mode 100644 gitnexus/src/core/group/extractors/grpc-patterns/index.ts create mode 100644 gitnexus/src/core/group/extractors/grpc-patterns/java.ts create mode 100644 gitnexus/src/core/group/extractors/grpc-patterns/node.ts create mode 100644 gitnexus/src/core/group/extractors/grpc-patterns/proto.ts create mode 100644 gitnexus/src/core/group/extractors/grpc-patterns/python.ts create mode 100644 gitnexus/src/core/group/extractors/grpc-patterns/types.ts create mode 100644 gitnexus/src/core/group/extractors/http-patterns/go.ts create mode 100644 gitnexus/src/core/group/extractors/http-patterns/index.ts create mode 100644 gitnexus/src/core/group/extractors/http-patterns/java.ts create mode 100644 gitnexus/src/core/group/extractors/http-patterns/node.ts create mode 100644 gitnexus/src/core/group/extractors/http-patterns/php.ts create mode 100644 gitnexus/src/core/group/extractors/http-patterns/python.ts create mode 100644 gitnexus/src/core/group/extractors/http-patterns/types.ts create mode 100644 gitnexus/src/core/group/extractors/manifest-extractor.ts create mode 100644 gitnexus/src/core/group/extractors/topic-patterns/go.ts create mode 100644 gitnexus/src/core/group/extractors/topic-patterns/index.ts create mode 100644 gitnexus/src/core/group/extractors/topic-patterns/java.ts create mode 100644 gitnexus/src/core/group/extractors/topic-patterns/node.ts create mode 100644 gitnexus/src/core/group/extractors/topic-patterns/python.ts create mode 100644 gitnexus/src/core/group/extractors/topic-patterns/types.ts create mode 100644 gitnexus/src/core/group/extractors/tree-sitter-scanner.ts create mode 100644 gitnexus/test/unit/group/manifest-extractor.test.ts create mode 100644 gitnexus/vendor/tree-sitter-proto/.gitignore create mode 100644 gitnexus/vendor/tree-sitter-proto/binding.gyp create mode 100644 gitnexus/vendor/tree-sitter-proto/bindings/node/binding.cc create mode 100644 gitnexus/vendor/tree-sitter-proto/bindings/node/index.d.ts create mode 100644 gitnexus/vendor/tree-sitter-proto/bindings/node/index.js create mode 100644 gitnexus/vendor/tree-sitter-proto/package.json create mode 100644 gitnexus/vendor/tree-sitter-proto/src/node-types.json create mode 100644 gitnexus/vendor/tree-sitter-proto/src/parser.c create mode 100644 gitnexus/vendor/tree-sitter-proto/src/tree_sitter/alloc.h create mode 100644 gitnexus/vendor/tree-sitter-proto/src/tree_sitter/array.h create mode 100644 gitnexus/vendor/tree-sitter-proto/src/tree_sitter/parser.h diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index ee80faecf..a746292e8 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -65,6 +65,7 @@ "optionalDependencies": { "tree-sitter-dart": "github:UserNobody14/tree-sitter-dart#80e23c07b64494f7e21090bb3450223ef0b192f4", "tree-sitter-kotlin": "^0.3.8", + "tree-sitter-proto": "file:./vendor/tree-sitter-proto", "tree-sitter-swift": "^0.6.0" } }, @@ -5296,6 +5297,10 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/tree-sitter-proto": { + "resolved": "vendor/tree-sitter-proto", + "link": true + }, "node_modules/tree-sitter-python": { "version": "0.23.4", "resolved": "https://registry.npmjs.org/tree-sitter-python/-/tree-sitter-python-0.23.4.tgz", @@ -5877,6 +5882,29 @@ "peerDependencies": { "zod": "^3.25.28 || ^4" } + }, + "vendor/tree-sitter-proto": { + "version": "0.4.1", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^8.0.0", + "node-gyp-build": "^4.8.0" + }, + "peerDependencies": { + "tree-sitter": ">=0.21.0" + } + }, + "vendor/tree-sitter-proto/node_modules/node-addon-api": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", + "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^18 || ^20 || >= 21" + } } } } diff --git a/gitnexus/package.json b/gitnexus/package.json index 864f28101..435f9b325 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -87,6 +87,7 @@ "optionalDependencies": { "tree-sitter-dart": "github:UserNobody14/tree-sitter-dart#80e23c07b64494f7e21090bb3450223ef0b192f4", "tree-sitter-kotlin": "^0.3.8", + "tree-sitter-proto": "file:./vendor/tree-sitter-proto", "tree-sitter-swift": "^0.6.0" }, "devDependencies": { diff --git a/gitnexus/src/core/group/PIPELINE.md b/gitnexus/src/core/group/PIPELINE.md new file mode 100644 index 000000000..7730b48e7 --- /dev/null +++ b/gitnexus/src/core/group/PIPELINE.md @@ -0,0 +1,139 @@ +# Group Analysis Pipeline + +Flow chart of the cross-repo contract extraction + matching pipeline. +This covers what runs **inside this PR** (extractors + manifest) and +the downstream handoff to the bridge storage (PR #795) and +cross-impact query (PR #606). + +## High-level overview + +```mermaid +flowchart TD + A[group.yaml] --> B[GroupConfig parser] + B --> C{For each repo
in group} + C --> D[Per-repo LadybugDB
indexed by main pipeline] + + D --> E1[TopicExtractor] + D --> E2[HttpRouteExtractor] + D --> E3[GrpcExtractor] + + E1 --> F[ExtractedContract array
per repo] + E2 --> F + E3 --> F + + B --> M[ManifestExtractor] + M --> G[Manifest contracts
+ cross-links] + + F --> H[Contract matching
exact + wildcard] + G --> H + + H --> I[(bridge.lbug
#795)] + + I --> J[runGroupImpact
#606] + J --> K[CrossRepoImpact] +``` + +## Per-repo extractor pipeline + +Each extractor under `src/core/group/extractors/` follows the same +two-strategy shape: + +```mermaid +flowchart TD + R[RepoHandle + CypherExecutor
for this repo] --> S{Graph-assisted
Strategy A
available?} + + S -->|yes| A1[Cypher query against
per-repo LadybugDB] + A1 --> A2{non-empty
result?} + A2 -->|yes| OUT[ExtractedContract array] + A2 -->|no| B1 + + S -->|no| B1[Source-scan Strategy B] + B1 --> B2[glob repo source files] + B2 --> B3{ext in registry?} + B3 -->|yes| B4[Per-language plugin
scan parsed tree] + B3 -->|no| SKIP[skip file] + B4 --> OUT + + SKIP --> B2 +``` + +**Strategy A** (graph-assisted) uses Cypher over edges already produced +by the main ingestion pipeline: +- HTTP: `HANDLES_ROUTE` / `FETCHES` edges from `(File)-[]->(Route)` +- topic: none (pipeline doesn't yet produce topic nodes — Strategy B only) +- gRPC: none (Strategy B + proto map only) + +**Strategy B** (source-scan) is 100% tree-sitter based after this PR. +Each `*-patterns/.ts` plugin owns its grammar + S-expression +queries; the top-level orchestrator imports neither. + +## Plugin architecture + +```mermaid +flowchart LR + O[Orchestrator
topic|http|grpc-extractor.ts] --> REG[REGISTRY
*-patterns/index.ts] + REG --> P1[java.ts
tree-sitter-java] + REG --> P2[go.ts
tree-sitter-go] + REG --> P3[python.ts
tree-sitter-python] + REG --> P4[node.ts
JS + TS + TSX] + REG --> P5[php.ts
tree-sitter-php
HTTP only] + REG --> P6[proto.ts
tree-sitter-proto
gRPC only, optional] + + P1 --> SCAN[tree-sitter-scanner.ts
compilePatterns + runCompiledPatterns] + P2 --> SCAN + P3 --> SCAN + P4 --> SCAN + P5 --> SCAN + P6 --> SCAN + + SCAN --> DET[Detection objects
TopicMeta / HttpDetection / GrpcDetection] + DET --> O + O --> CT[ExtractedContract array] +``` + +The orchestrator never imports a grammar. Adding a new language / +framework = drop one file in `*-patterns/`, register it in +`index.ts`. No orchestrator edits required. + +## Manifest extraction + +```mermaid +flowchart TD + Y[group.yaml links] --> ME[ManifestExtractor] + ME --> LOOP{for each link} + LOOP --> RES[resolveSymbol
label-scoped Cypher] + RES --> OK{found?} + OK -->|yes| REF[real symbol uid + ref] + OK -->|no| SYN[synthetic uid
manifest::repo::cid] + + REF --> EMIT[emit provider + consumer
Contract objects
+ CrossLink] + SYN --> EMIT + + EMIT --> BRIDGE[(bridge.lbug
#795)] +``` + +Label-scoped queries in `resolveSymbol` keep accidental cross-matches +out: +- `topic` → `(n:Function|Method|Class|Interface)` +- `grpc` method → `(n:Function|Method)`, service → `(n:Class|Interface)` +- `lib` → `(n:Package|Module)` + +## Cross-impact query (PR #606) + +```mermaid +flowchart TD + U[User changes symbol S
in repo R] --> LI[Local impact engine
per-repo uid expansion] + LI --> IDS[Affected uid set] + + IDS --> BR[Bridge query
MATCH Contract WHERE uid IN ids] + BR --> CL[CrossLink traversal] + CL --> OTHER[Matching contract in
other repo] + + OTHER --> FE[Fan-out impact
to consuming repo] + FE --> OUT[CrossRepoImpact
per affected repo] +``` + +The bridge stores every extracted contract keyed by `symbolUid`. +Manifest-sourced contracts use the synthetic uid form so both sides +of the `(local impact) ↔ (bridge query)` join derive the same uid +without coordinating through any shared state. diff --git a/gitnexus/src/core/group/extractors/fs-utils.ts b/gitnexus/src/core/group/extractors/fs-utils.ts new file mode 100644 index 000000000..384f63203 --- /dev/null +++ b/gitnexus/src/core/group/extractors/fs-utils.ts @@ -0,0 +1,23 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +/** + * Safely read a file inside a repo, rejecting any path that escapes + * `repoPath` via `..` traversal or absolute segments. Returns `null` if + * the path is outside the repo or the file can't be read. + * + * Used by every source-scan extractor under this directory. Kept as a + * single shared implementation so the path-traversal guard (security- + * sensitive) lives in exactly one place. + */ +export function readSafe(repoPath: string, rel: string): string | null { + const abs = path.resolve(repoPath, rel); + const base = path.resolve(repoPath); + const relToBase = path.relative(base, abs); + if (relToBase.startsWith('..') || path.isAbsolute(relToBase)) return null; + try { + return fs.readFileSync(abs, 'utf-8'); + } catch { + return null; + } +} diff --git a/gitnexus/src/core/group/extractors/grpc-extractor.ts b/gitnexus/src/core/group/extractors/grpc-extractor.ts index b4cefadc5..c6af9138a 100644 --- a/gitnexus/src/core/group/extractors/grpc-extractor.ts +++ b/gitnexus/src/core/group/extractors/grpc-extractor.ts @@ -1,20 +1,38 @@ -import * as fs from 'node:fs'; import * as path from 'node:path'; import { glob } from 'glob'; +import Parser from 'tree-sitter'; import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js'; import type { ExtractedContract, RepoHandle } from '../types.js'; +import { readSafe } from './fs-utils.js'; +import { + GRPC_SCAN_GLOB, + getPluginForFile, + hasProtoPlugin, + type GrpcDetection, +} from './grpc-patterns/index.js'; -function readSafe(repoPath: string, rel: string): string | null { - const abs = path.resolve(repoPath, rel); - const base = path.resolve(repoPath); - const relToBase = path.relative(base, abs); - if (relToBase.startsWith('..') || path.isAbsolute(relToBase)) return null; - try { - return fs.readFileSync(abs, 'utf-8'); - } catch { - return null; - } -} +/** + * Language-agnostic orchestrator for gRPC (provider + consumer) contract + * extraction. + * + * Two parts: + * + * 1. **`.proto` parsing** — tree-sitter when `tree-sitter-proto` is + * installed (optionalDependency vendored in `vendor/tree-sitter-proto/`), + * via the `.proto` entry in `grpc-patterns/` and `hasProtoPlugin`. + * When the grammar isn't available (platform incompatibility, native + * build failure) the orchestrator falls back to the in-process + * string-sanitizing parser defined below (`stripProtoCommentsAndStrings` + * + `extractServiceBlocks`). The fallback preserves offsets so any + * downstream regex scans run against a sanitized copy without + * affecting line numbers of the original. + * + * 2. **Source-scan providers / consumers** — delegated to per-language + * plugins in `./grpc-patterns/`. The orchestrator imports NO + * tree-sitter grammars or query strings — each plugin owns its own. + */ + +// ─── .proto fallback parser (used only when tree-sitter-proto is absent) ─── function contractId(pkg: string, service: string, method: string): string { const prefix = pkg ? `${pkg}.${service}` : service; @@ -25,20 +43,110 @@ function serviceOnlyContractId(serviceName: string): string { return `grpc::${serviceName}/*`; } +/** + * Replace all .proto comments and string literals with spaces, preserving the + * original length and character offsets of the input. This lets downstream + * regex / brace-depth parsers run on a "sanitized" copy without having to + * understand proto syntax, while any RegExp.exec/index-based lookups that + * were already positional against `content` continue to work against the + * original string. + * + * Supported comment forms: `// line comment`, `/* block comment * /`. + * Supported strings: double-quoted ("…") and single-quoted ('…') with `\` + * escape handling. Raw/unterminated strings are not supported — we stop + * on a line break for line-style comments and on EOF for unterminated + * strings/blocks, which matches how most real proto files parse. + */ +function stripProtoCommentsAndStrings(content: string): string { + const out = new Array(content.length); + let i = 0; + while (i < content.length) { + const ch = content[i]; + const next = content[i + 1]; + + // Line comment: // ... \n + if (ch === '/' && next === '/') { + out[i] = ' '; + out[i + 1] = ' '; + i += 2; + while (i < content.length && content[i] !== '\n') { + out[i] = content[i] === '\r' ? '\r' : ' '; + i++; + } + continue; + } + + // Block comment: /* ... */ + if (ch === '/' && next === '*') { + out[i] = ' '; + out[i + 1] = ' '; + i += 2; + while (i < content.length) { + if (content[i] === '*' && content[i + 1] === '/') { + out[i] = ' '; + out[i + 1] = ' '; + i += 2; + break; + } + // Preserve newlines so line numbers stay stable for downstream code. + out[i] = content[i] === '\n' || content[i] === '\r' ? content[i] : ' '; + i++; + } + continue; + } + + // String literal: "..." or '...' + if (ch === '"' || ch === "'") { + const quote = ch; + out[i] = ' '; // replace opening quote + i++; + while (i < content.length) { + const c = content[i]; + if (c === '\\' && i + 1 < content.length) { + // Skip escaped pair (e.g. \" \n \\) + out[i] = ' '; + out[i + 1] = ' '; + i += 2; + continue; + } + if (c === quote) { + out[i] = ' '; + i++; + break; + } + // Preserve newlines; proto technically disallows unescaped newlines + // inside strings, but real files occasionally have them. + out[i] = c === '\n' || c === '\r' ? c : ' '; + i++; + } + continue; + } + + out[i] = ch; + i++; + } + return out.join(''); +} + function extractServiceBlocks(content: string): Array<{ name: string; body: string }> { const results: Array<{ name: string; body: string }> = []; - // v1: brace-depth only — braces inside comments or string literals are not filtered (see spec Fix 2) + // Sanitize comments and string literals so braces inside them don't + // throw off the depth counter. The sanitized copy has the same length + // and offsets as the original, so we use it ONLY to scan for service + // headers and braces; the service body we return is sliced from the + // ORIGINAL content to preserve exact source text for downstream use. + const sanitized = stripProtoCommentsAndStrings(content); const headerRe = /service\s+(\w+)\s*\{/g; let headerMatch: RegExpExecArray | null; - while ((headerMatch = headerRe.exec(content)) !== null) { + while ((headerMatch = headerRe.exec(sanitized)) !== null) { const serviceName = headerMatch[1]; const bodyStart = headerMatch.index + headerMatch[0].length; let depth = 1; let pos = bodyStart; - while (pos < content.length && depth > 0) { - const ch = content[pos]; + while (pos < sanitized.length && depth > 0) { + const ch = sanitized[pos]; if (ch === '{') depth++; else if (ch === '}') depth--; pos++; @@ -75,6 +183,165 @@ function makeContract( }; } +export interface ProtoServiceInfo { + package: string; + serviceName: string; + methods: string[]; + protoPath: string; +} + +function normalizeProtoPath(rel: string): string { + return rel.replace(/\\/g, '/'); +} + +function extractProtoImports(content: string): string[] { + const imports: string[] = []; + const re = /^\s*import\s+"([^"]+)"\s*;/gm; + let match: RegExpExecArray | null; + while ((match = re.exec(content)) !== null) { + imports.push(match[1]); + } + return imports; +} + +function longestSharedSegmentRun(aPath: string, bPath: string): number { + const a = aPath.split('/').filter(Boolean); + const b = bPath.split('/').filter(Boolean); + let best = 0; + + for (let i = 0; i < a.length; i++) { + for (let j = 0; j < b.length; j++) { + let run = 0; + while (a[i + run] && b[j + run] && a[i + run] === b[j + run]) { + run++; + } + if (run > best) best = run; + } + } + + return best; +} + +async function buildProtoContext(repoPath: string): Promise<{ + packagesByProto: Map; + servicesByName: Map; +}> { + const servicesByName = new Map(); + const protoFiles = await glob('**/*.proto', { + cwd: repoPath, + absolute: false, + nodir: true, + ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**'], + }); + const contents = new Map(); + + for (const rel of protoFiles) { + const content = readSafe(repoPath, rel); + if (!content) continue; + contents.set(normalizeProtoPath(rel), content); + } + + const packagesByProto = new Map(); + + const resolvePackage = (protoPath: string, seen = new Set()): string => { + if (packagesByProto.has(protoPath)) return packagesByProto.get(protoPath) ?? ''; + if (seen.has(protoPath)) return ''; + + const content = contents.get(protoPath); + if (!content) return ''; + + seen.add(protoPath); + const pkgMatch = content.match(/^\s*package\s+([\w.]+)\s*;/m); + if (pkgMatch?.[1]) { + packagesByProto.set(protoPath, pkgMatch[1]); + return pkgMatch[1]; + } + + for (const importPath of extractProtoImports(content)) { + const normalizedImport = normalizeProtoPath(importPath); + const candidates = [ + normalizeProtoPath( + path.posix.normalize(path.posix.join(path.posix.dirname(protoPath), normalizedImport)), + ), + normalizedImport, + ]; + for (const candidate of candidates) { + if (!contents.has(candidate)) continue; + const inheritedPackage = resolvePackage(candidate, seen); + if (inheritedPackage) { + packagesByProto.set(protoPath, inheritedPackage); + return inheritedPackage; + } + } + } + + packagesByProto.set(protoPath, ''); + return ''; + }; + + for (const rel of protoFiles) { + const normalizedRel = normalizeProtoPath(rel); + const content = contents.get(normalizedRel); + if (!content) continue; + const pkg = resolvePackage(normalizedRel); + + const serviceBlocks = extractServiceBlocks(content); + for (const block of serviceBlocks) { + const rpcRe = /rpc\s+(\w+)\s*\(/g; + const methods: string[] = []; + let m: RegExpExecArray | null; + while ((m = rpcRe.exec(block.body)) !== null) { + methods.push(m[1]); + } + const info: ProtoServiceInfo = { + package: pkg, + serviceName: block.name, + methods, + protoPath: normalizedRel, + }; + const existing = servicesByName.get(block.name) ?? []; + existing.push(info); + servicesByName.set(block.name, existing); + } + } + + return { packagesByProto, servicesByName }; +} + +export async function buildProtoMap(repoPath: string): Promise> { + const { servicesByName } = await buildProtoContext(repoPath); + return servicesByName; +} + +export function resolveProtoConflict( + _serviceName: string, + sourceFilePath: string, + candidates: ProtoServiceInfo[], +): ProtoServiceInfo | null { + if (candidates.length === 0) return null; + if (candidates.length === 1) return candidates[0]; + + const sourceDir = normalizeProtoPath(path.dirname(sourceFilePath)); + let best = candidates[0]; + let bestScore = -1; + for (const c of candidates) { + const protoDir = normalizeProtoPath(path.dirname(c.protoPath)); + const sharedRun = longestSharedSegmentRun(sourceDir, protoDir); + if (sharedRun > bestScore) { + bestScore = sharedRun; + best = c; + } + } + return best; +} + +export function serviceContractId(pkg: string, serviceName: string): string { + const prefix = pkg ? `${pkg}.${serviceName}` : serviceName; + return `grpc::${prefix}/*`; +} + +// ─── Orchestrator ──────────────────────────────────────────────────── + export class GrpcExtractor implements ContractExtractor { type = 'grpc' as const; @@ -88,270 +355,111 @@ export class GrpcExtractor implements ContractExtractor { _repo: RepoHandle, ): Promise { const out: ExtractedContract[] = []; + const protoContext = await buildProtoContext(repoPath); + const protoMap = protoContext.servicesByName; - // Proto files — definitive provider source - const protoFiles = await glob('**/*.proto', { - cwd: repoPath, - ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**'], - nodir: true, - }); - for (const rel of protoFiles) { - const content = readSafe(repoPath, rel); - if (content) out.push(...this.parseProtoFile(content, rel)); + // ─── Proto files — definitive provider source ───────────────── + // When tree-sitter-proto is available, .proto files are handled by + // the plugin loop below (they're in GRPC_SCAN_GLOB). Otherwise + // emit provider contracts directly from the proto map that + // `buildProtoContext` already built — no second glob / parse pass. + if (!hasProtoPlugin) { + for (const infos of protoMap.values()) { + for (const info of infos) { + for (const methodName of info.methods) { + const cid = contractId(info.package, info.serviceName, methodName); + out.push( + makeContract( + cid, + 'provider', + info.protoPath, + `${info.serviceName}.${methodName}`, + 0.85, + { + package: info.package, + service: info.serviceName, + method: methodName, + source: 'proto', + }, + ), + ); + } + } + } } - // Source files — server/client detection - const sourceFiles = await glob('**/*.{go,java,py,ts,tsx,js,jsx}', { + // ─── Source files (+ .proto when plugin available) ──────────── + const sourceFiles = await glob(GRPC_SCAN_GLOB, { cwd: repoPath, ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**', '**/dist/**', '**/build/**'], nodir: true, }); + + const parser = new Parser(); for (const rel of sourceFiles) { + const plugin = getPluginForFile(rel); + if (!plugin) continue; const content = readSafe(repoPath, rel); if (!content) continue; - const ext = path.extname(rel).toLowerCase(); - - if (ext === '.go') { - out.push(...this.scanGoProviders(content, rel)); - out.push(...this.scanGoConsumers(content, rel)); - } else if (ext === '.java') { - out.push(...this.scanJavaProviders(content, rel)); - out.push(...this.scanJavaConsumers(content, rel)); - } else if (ext === '.py') { - out.push(...this.scanPythonProviders(content, rel)); - out.push(...this.scanPythonConsumers(content, rel)); - } else if (['.ts', '.tsx', '.js', '.jsx'].includes(ext)) { - out.push(...this.scanTsProviders(content, rel)); + let detections: GrpcDetection[] = []; + try { + parser.setLanguage(plugin.language); + const tree = parser.parse(content); + detections = plugin.scan(tree); + } catch { + continue; + } + for (const d of detections) { + out.push(this.detectionToContract(d, rel, protoMap)); } } return this.dedupe(out); } - private parseProtoFile(content: string, filePath: string): ExtractedContract[] { - const out: ExtractedContract[] = []; - - const pkgMatch = content.match(/^package\s+([\w.]+)\s*;/m); - const pkg = pkgMatch ? pkgMatch[1] : ''; - - for (const { name: serviceName, body } of extractServiceBlocks(content)) { - const rpcRe = /rpc\s+(\w+)\s*\(/g; - let rpcMatch: RegExpExecArray | null; - while ((rpcMatch = rpcRe.exec(body)) !== null) { - const methodName = rpcMatch[1]; - const cid = contractId(pkg, serviceName, methodName); - out.push( - makeContract(cid, 'provider', filePath, `${serviceName}.${methodName}`, 0.85, { - package: pkg, - service: serviceName, - method: methodName, - source: 'proto', - }), - ); - } - } - - return out; - } - - private scanGoProviders(content: string, filePath: string): ExtractedContract[] { - const out: ExtractedContract[] = []; - - // pb.RegisterXxxServer( - const registerRe = /\w+\.Register(\w+)Server\s*\(/g; - let m: RegExpExecArray | null; - while ((m = registerRe.exec(content)) !== null) { - const serviceName = m[1]; - out.push( - makeContract( - serviceOnlyContractId(serviceName), - 'provider', - filePath, - `Register${serviceName}Server`, - 0.8, - { service: serviceName, source: 'go_register' }, - ), - ); - } - - // pb.UnimplementedXxxServer - const unimplRe = /\w+\.Unimplemented(\w+)Server\b/g; - while ((m = unimplRe.exec(content)) !== null) { - const serviceName = m[1]; - out.push( - makeContract( - serviceOnlyContractId(serviceName), - 'provider', - filePath, - `Unimplemented${serviceName}Server`, - 0.8, - { service: serviceName, source: 'go_unimplemented' }, - ), - ); - } - - return out; - } - - private scanGoConsumers(content: string, filePath: string): ExtractedContract[] { - const out: ExtractedContract[] = []; - const re = /\w+\.New(\w+)Client\s*\(/g; - let m: RegExpExecArray | null; - while ((m = re.exec(content)) !== null) { - const serviceName = m[1]; - out.push( - makeContract( - serviceOnlyContractId(serviceName), - 'consumer', - filePath, - `New${serviceName}Client`, - 0.7, - { service: serviceName, source: 'go_client' }, - ), - ); - } - return out; - } - - private scanJavaProviders(content: string, filePath: string): ExtractedContract[] { - const out: ExtractedContract[] = []; - - // @GrpcService - if (content.includes('@GrpcService')) { - const implBaseRe = /extends\s+(\w+)Grpc\.(\w+)ImplBase/; - const m = content.match(implBaseRe); - if (m) { - out.push( - makeContract(serviceOnlyContractId(m[1]), 'provider', filePath, m[2], 0.8, { - service: m[1], - source: 'java_grpc_service', - }), - ); - } else { - // Try extracting service name from class name - const classRe = - /class\s+(\w*?)(?:Grpc)?(?:Service)?\s+extends\s+(\w+)(?:Grpc\.(\w+))?ImplBase/; - const cm = content.match(classRe); - if (cm) { - const svcName = cm[2].replace(/Grpc$/, ''); - out.push( - makeContract(serviceOnlyContractId(svcName), 'provider', filePath, cm[1], 0.8, { - service: svcName, - source: 'java_grpc_service', - }), - ); - } - } - } - - // extends XxxImplBase (without @GrpcService) - if (!content.includes('@GrpcService')) { - const implRe = /extends\s+(\w+?)(?:Grpc\.(\w+))?ImplBase/; - const m = content.match(implRe); - if (m) { - const svcName = m[2] || m[1].replace(/Grpc$/, ''); - out.push( - makeContract(serviceOnlyContractId(svcName), 'provider', filePath, svcName, 0.8, { - service: svcName, - source: 'java_impl_base', - }), - ); - } - } - - return out; - } - - private scanJavaConsumers(content: string, filePath: string): ExtractedContract[] { - const out: ExtractedContract[] = []; - // XxxGrpc.newBlockingStub( or XxxGrpc.newStub( - const re = /(\w+)Grpc\.new(?:Blocking)?Stub\s*\(/g; - let m: RegExpExecArray | null; - while ((m = re.exec(content)) !== null) { - const serviceName = m[1]; - out.push( - makeContract( - serviceOnlyContractId(serviceName), - 'consumer', - filePath, - `${serviceName}Stub`, - 0.7, - { service: serviceName, source: 'java_stub' }, - ), - ); - } - return out; - } - - private scanPythonProviders(content: string, filePath: string): ExtractedContract[] { - const out: ExtractedContract[] = []; - // add_XxxServicer_to_server( - const re = /add_(\w+?)Servicer_to_server\s*\(/g; - let m: RegExpExecArray | null; - while ((m = re.exec(content)) !== null) { - const serviceName = m[1]; - out.push( - makeContract( - serviceOnlyContractId(serviceName), - 'provider', - filePath, - `add_${serviceName}Servicer_to_server`, - 0.8, - { service: serviceName, source: 'python_servicer' }, - ), - ); - } - return out; - } - - private scanPythonConsumers(content: string, filePath: string): ExtractedContract[] { - const out: ExtractedContract[] = []; - // XxxStub( - const re = /(\w+)Stub\s*\(/g; - let m: RegExpExecArray | null; - while ((m = re.exec(content)) !== null) { - const name = m[1]; - // Filter out common false positives - if (['Mock', 'Test', 'Fake', 'Stub'].includes(name)) continue; - out.push( - makeContract(serviceOnlyContractId(name), 'consumer', filePath, `${name}Stub`, 0.7, { - service: name, - source: 'python_stub', - }), - ); - } - return out; - } - - private scanTsProviders(content: string, filePath: string): ExtractedContract[] { - const out: ExtractedContract[] = []; - // @GrpcMethod('ServiceName', 'MethodName') - const re = /@GrpcMethod\s*\(\s*['"](\w+)['"]\s*,\s*['"](\w+)['"]\s*\)/g; - let m: RegExpExecArray | null; - while ((m = re.exec(content)) !== null) { - const serviceName = m[1]; - const methodName = m[2]; - const cid = contractId('', serviceName, methodName); - out.push( - makeContract(cid, 'provider', filePath, `${serviceName}.${methodName}`, 0.8, { - service: serviceName, - method: methodName, - source: 'ts_grpc_method', - }), - ); - } - return out; + /** + * Convert a plugin `GrpcDetection` into a concrete `ExtractedContract` + * by resolving the short service name against the proto map, building + * either a service-level (`grpc::pkg.Svc/*`) or method-level + * (`grpc::pkg.Svc/Method`) contract id, and selecting confidence + * based on whether the proto map had an entry. + */ + private detectionToContract( + d: GrpcDetection, + filePath: string, + protoMap: Map, + ): ExtractedContract { + const candidates = protoMap.get(d.serviceName); + const proto = resolveProtoConflict(d.serviceName, filePath, candidates ?? []); + const pkg = proto?.package ?? ''; + const cid = d.methodName + ? contractId(pkg, d.serviceName, d.methodName) + : proto + ? serviceContractId(pkg, d.serviceName) + : serviceOnlyContractId(d.serviceName); + const confidence = proto ? d.confidenceWithProto : d.confidenceWithoutProto; + const meta: Record = { + service: d.serviceName, + source: d.source, + }; + if (d.methodName) meta.method = d.methodName; + return makeContract(cid, d.role, filePath, d.symbolName, confidence, meta); } private dedupe(items: ExtractedContract[]): ExtractedContract[] { - const seen = new Set(); - const out: ExtractedContract[] = []; + const byKey = new Map(); for (const c of items) { const k = `${c.contractId}|${c.role}|${c.symbolRef.filePath}`; - if (seen.has(k)) continue; - seen.add(k); - out.push(c); + const existing = byKey.get(k); + if ( + !existing || + c.confidence > existing.confidence || + (c.confidence === existing.confidence && + String(c.meta.source) < String(existing.meta.source)) + ) { + byKey.set(k, c); + } } - return out; + return Array.from(byKey.values()); } } diff --git a/gitnexus/src/core/group/extractors/grpc-patterns/go.ts b/gitnexus/src/core/group/extractors/grpc-patterns/go.ts new file mode 100644 index 000000000..b1abbaeb7 --- /dev/null +++ b/gitnexus/src/core/group/extractors/grpc-patterns/go.ts @@ -0,0 +1,109 @@ +import Go from 'tree-sitter-go'; +import { + compilePatterns, + runCompiledPatterns, + type LanguagePatterns, +} from '../tree-sitter-scanner.js'; +import type { GrpcDetection, GrpcLanguagePlugin } from './types.js'; + +/** + * Go gRPC plugin. Detects: + * - Provider: `pb.RegisterXxxServer(...)` calls + * - Provider: `pb.UnimplementedXxxServer` embedded in a struct + * - Consumer: `pb.NewXxxClient(conn)` calls + */ + +const REGISTER_RE = /^Register(\w+)Server$/; +const UNIMPLEMENTED_RE = /^Unimplemented(\w+)Server$/; +const NEW_CLIENT_RE = /^New(\w+)Client$/; + +// Any `xxx.(...)` call — plugin filters the field identifier text. +const SELECTOR_CALL_PATTERNS = compilePatterns({ + name: 'go-grpc-selector-call', + language: Go, + patterns: [ + { + meta: {}, + query: ` + (call_expression + function: (selector_expression + field: (field_identifier) @fn)) + `, + }, + ], +} satisfies LanguagePatterns>); + +// Any `qualified_type` used as a struct field — for `pb.UnimplementedXxxServer`. +const STRUCT_EMBEDDING_PATTERNS = compilePatterns({ + name: 'go-grpc-struct-embedding', + language: Go, + patterns: [ + { + meta: {}, + query: ` + (struct_type + (field_declaration_list + (field_declaration + type: (qualified_type + name: (type_identifier) @field_type)))) + `, + }, + ], +} satisfies LanguagePatterns>); + +export const GO_GRPC_PLUGIN: GrpcLanguagePlugin = { + name: 'go-grpc', + language: Go, + scan(tree) { + const out: GrpcDetection[] = []; + + for (const match of runCompiledPatterns(SELECTOR_CALL_PATTERNS, tree)) { + const fnNode = match.captures.fn; + if (!fnNode) continue; + const fnText = fnNode.text; + + const registerMatch = REGISTER_RE.exec(fnText); + if (registerMatch) { + out.push({ + role: 'provider', + serviceName: registerMatch[1], + symbolName: fnText, + source: 'go_register', + confidenceWithProto: 0.8, + confidenceWithoutProto: 0.65, + }); + continue; + } + + const newClientMatch = NEW_CLIENT_RE.exec(fnText); + if (newClientMatch) { + out.push({ + role: 'consumer', + serviceName: newClientMatch[1], + symbolName: fnText, + source: 'go_client', + confidenceWithProto: 0.75, + confidenceWithoutProto: 0.55, + }); + continue; + } + } + + for (const match of runCompiledPatterns(STRUCT_EMBEDDING_PATTERNS, tree)) { + const fieldNode = match.captures.field_type; + if (!fieldNode) continue; + const unimpl = UNIMPLEMENTED_RE.exec(fieldNode.text); + if (!unimpl) continue; + out.push({ + role: 'provider', + serviceName: unimpl[1], + symbolName: fieldNode.text, + source: 'go_unimplemented', + confidenceWithProto: 0.8, + confidenceWithoutProto: 0.65, + }); + } + + return out; + }, +}; diff --git a/gitnexus/src/core/group/extractors/grpc-patterns/index.ts b/gitnexus/src/core/group/extractors/grpc-patterns/index.ts new file mode 100644 index 000000000..617c14beb --- /dev/null +++ b/gitnexus/src/core/group/extractors/grpc-patterns/index.ts @@ -0,0 +1,53 @@ +import * as path from 'node:path'; +import type { GrpcLanguagePlugin } from './types.js'; +import { GO_GRPC_PLUGIN } from './go.js'; +import { JAVA_GRPC_PLUGIN } from './java.js'; +import { PYTHON_GRPC_PLUGIN } from './python.js'; +import { JAVASCRIPT_GRPC_PLUGIN, TYPESCRIPT_GRPC_PLUGIN, TSX_GRPC_PLUGIN } from './node.js'; +import { PROTO_GRPC_PLUGIN } from './proto.js'; + +export type { GrpcDetection, GrpcLanguagePlugin, GrpcRole } from './types.js'; +export { PROTO_GRPC_PLUGIN, extractPackageFromTree } from './proto.js'; + +/** + * File-extension → gRPC language plugin registry. Mirrors the shape + * of `http-patterns/index.ts` and `topic-patterns/index.ts`. + * + * `.proto` files are registered only when `tree-sitter-proto` is + * available (it's an optionalDependency). When absent, the orchestrator + * falls back to the built-in manual proto parser. + */ +const REGISTRY: Record = { + '.go': GO_GRPC_PLUGIN, + '.java': JAVA_GRPC_PLUGIN, + '.py': PYTHON_GRPC_PLUGIN, + '.js': JAVASCRIPT_GRPC_PLUGIN, + '.jsx': JAVASCRIPT_GRPC_PLUGIN, + '.ts': TYPESCRIPT_GRPC_PLUGIN, + '.tsx': TSX_GRPC_PLUGIN, + ...(PROTO_GRPC_PLUGIN ? { '.proto': PROTO_GRPC_PLUGIN } : {}), +}; + +/** + * Glob for source files worth scanning for gRPC server/client patterns. + * Includes `.proto` when the grammar is available. + */ +export const GRPC_SCAN_GLOB = PROTO_GRPC_PLUGIN + ? '**/*.{go,java,py,ts,tsx,js,jsx,proto}' + : '**/*.{go,java,py,ts,tsx,js,jsx}'; + +/** + * Whether the tree-sitter proto plugin is available. The orchestrator + * uses this to decide between the tree-sitter path and the fallback + * manual parser for `.proto` files. + */ +export const hasProtoPlugin = PROTO_GRPC_PLUGIN !== null; + +/** + * Return the gRPC plugin registered for the given file's extension, + * or `undefined` if the extension is not registered. + */ +export function getPluginForFile(rel: string): GrpcLanguagePlugin | undefined { + const ext = path.extname(rel).toLowerCase(); + return REGISTRY[ext]; +} diff --git a/gitnexus/src/core/group/extractors/grpc-patterns/java.ts b/gitnexus/src/core/group/extractors/grpc-patterns/java.ts new file mode 100644 index 000000000..bf1cf4816 --- /dev/null +++ b/gitnexus/src/core/group/extractors/grpc-patterns/java.ts @@ -0,0 +1,179 @@ +import Parser from 'tree-sitter'; +import Java from 'tree-sitter-java'; +import { + compilePatterns, + runCompiledPatterns, + type LanguagePatterns, +} from '../tree-sitter-scanner.js'; +import type { GrpcDetection, GrpcLanguagePlugin } from './types.js'; + +/** + * Java gRPC plugin. Detects: + * - Provider: classes extending `XxxServiceGrpc.XxxServiceImplBase` + * (with or without a `@GrpcService` annotation; the annotation + * only affects confidence labelling in the original regex version + * — here we emit a single detection per class and pick the source + * label based on whether the annotation is present). + * - Consumer: `XxxServiceGrpc.newBlockingStub(ch)` / + * `XxxServiceGrpc.newStub(ch)` calls. + */ + +const IMPL_BASE_RE = /^(\w+)ImplBase$/; +const GRPC_SUFFIX_RE = /^(\w+)Grpc$/; + +// Classes extending `ScopedType.ScopedType` where the inner name ends +// in ImplBase. Covers `XxxServiceGrpc.XxxServiceImplBase`. +// Note: tree-sitter-java's `scoped_type_identifier` exposes its two +// segments as positional `type_identifier` children, NOT as named +// `scope:`/`name:` fields. We match positionally here and rely on the +// grammar's left-to-right ordering: first child = outer, second = inner. +const SCOPED_IMPL_BASE_PATTERNS = compilePatterns({ + name: 'java-grpc-scoped-impl-base', + language: Java, + patterns: [ + { + meta: {}, + query: ` + (class_declaration + name: (identifier) @class_name + superclass: (superclass + (scoped_type_identifier + (type_identifier) @outer + (type_identifier) @inner (#match? @inner "ImplBase$")))) @class + `, + }, + ], +} satisfies LanguagePatterns>); + +// Classes extending a simple `XxxImplBase` identifier (no scope). +const PLAIN_IMPL_BASE_PATTERNS = compilePatterns({ + name: 'java-grpc-plain-impl-base', + language: Java, + patterns: [ + { + meta: {}, + query: ` + (class_declaration + name: (identifier) @class_name + superclass: (superclass + (type_identifier) @plain_type (#match? @plain_type "ImplBase$"))) @class + `, + }, + ], +} satisfies LanguagePatterns>); + +// gRPC stub factories: `XxxGrpc.newStub(ch)` / `XxxGrpc.newBlockingStub(ch)`. +const STUB_PATTERNS = compilePatterns({ + name: 'java-grpc-stub', + language: Java, + patterns: [ + { + meta: {}, + query: ` + (method_invocation + object: (identifier) @grpc_cls + name: (identifier) @method (#match? @method "^new(Blocking)?Stub$")) + `, + }, + ], +} satisfies LanguagePatterns>); + +/** + * Check whether a `class_declaration` node has a `@GrpcService` + * annotation in its modifiers list. In tree-sitter-java, class-level + * annotations live under `(class_declaration (modifiers (marker_annotation|annotation)))`. + */ +function hasGrpcServiceAnnotation(classNode: Parser.SyntaxNode): boolean { + for (let i = 0; i < classNode.namedChildCount; i++) { + const child = classNode.namedChild(i); + if (!child || child.type !== 'modifiers') continue; + for (let j = 0; j < child.namedChildCount; j++) { + const mod = child.namedChild(j); + if (!mod) continue; + if (mod.type !== 'marker_annotation' && mod.type !== 'annotation') continue; + const nameNode = mod.childForFieldName('name'); + if (nameNode?.text === 'GrpcService') return true; + } + } + return false; +} + +/** + * Given the inner type_identifier text like `AuthServiceImplBase`, + * return the service name (`AuthService`), or null if the text + * doesn't end in `ImplBase`. + */ +function extractServiceFromImplBase(text: string): string | null { + const m = IMPL_BASE_RE.exec(text); + if (!m) return null; + // Strip a trailing `Grpc` on the service name too — the original + // regex replaces `Grpc$` on the extracted prefix. + return m[1].replace(/Grpc$/, ''); +} + +export const JAVA_GRPC_PLUGIN: GrpcLanguagePlugin = { + name: 'java-grpc', + language: Java, + scan(tree) { + const out: GrpcDetection[] = []; + const emittedClassIds = new Set(); + + // ─── Providers: scoped form (`...Grpc.XxxImplBase`) ───────────── + for (const match of runCompiledPatterns(SCOPED_IMPL_BASE_PATTERNS, tree)) { + const classNode = match.captures.class; + const innerNode = match.captures.inner; + if (!classNode || !innerNode) continue; + const serviceName = extractServiceFromImplBase(innerNode.text); + if (!serviceName) continue; + emittedClassIds.add(classNode.id); + const annotated = hasGrpcServiceAnnotation(classNode); + out.push({ + role: 'provider', + serviceName, + symbolName: serviceName, + source: annotated ? 'java_grpc_service' : 'java_impl_base', + confidenceWithProto: 0.8, + confidenceWithoutProto: 0.65, + }); + } + + // ─── Providers: plain form (`XxxImplBase`) ────────────────────── + for (const match of runCompiledPatterns(PLAIN_IMPL_BASE_PATTERNS, tree)) { + const classNode = match.captures.class; + const plainNode = match.captures.plain_type; + if (!classNode || !plainNode) continue; + if (emittedClassIds.has(classNode.id)) continue; + const serviceName = extractServiceFromImplBase(plainNode.text); + if (!serviceName) continue; + emittedClassIds.add(classNode.id); + const annotated = hasGrpcServiceAnnotation(classNode); + out.push({ + role: 'provider', + serviceName, + symbolName: serviceName, + source: annotated ? 'java_grpc_service' : 'java_impl_base', + confidenceWithProto: 0.8, + confidenceWithoutProto: 0.65, + }); + } + + // ─── Consumers: `XxxGrpc.newBlockingStub(...)` / `newStub(...)` ─ + for (const match of runCompiledPatterns(STUB_PATTERNS, tree)) { + const grpcClsNode = match.captures.grpc_cls; + if (!grpcClsNode) continue; + const grpcMatch = GRPC_SUFFIX_RE.exec(grpcClsNode.text); + if (!grpcMatch) continue; + const serviceName = grpcMatch[1]; + out.push({ + role: 'consumer', + serviceName, + symbolName: `${serviceName}Stub`, + source: 'java_stub', + confidenceWithProto: 0.75, + confidenceWithoutProto: 0.55, + }); + } + + return out; + }, +}; diff --git a/gitnexus/src/core/group/extractors/grpc-patterns/node.ts b/gitnexus/src/core/group/extractors/grpc-patterns/node.ts new file mode 100644 index 000000000..033962206 --- /dev/null +++ b/gitnexus/src/core/group/extractors/grpc-patterns/node.ts @@ -0,0 +1,314 @@ +import Parser from 'tree-sitter'; +import JavaScript from 'tree-sitter-javascript'; +import TypeScript from 'tree-sitter-typescript'; +import { + compilePatterns, + runCompiledPatterns, + unquoteLiteral, + type CompiledPatterns, + type LanguagePatterns, + type PatternSpec, +} from '../tree-sitter-scanner.js'; +import type { GrpcDetection, GrpcLanguagePlugin } from './types.js'; + +/** + * Node.js / TypeScript gRPC plugin family. Detects: + * - Provider: NestJS `@GrpcMethod('Service', 'Method')` decorators + * - Consumer: NestJS `@GrpcClient(...) readonly x!: XxxServiceClient` + * - Consumer: `client.getService('AuthService')` + * - Consumer: `new XxxServiceClient(...)` (generated client constructor) + * - Consumer: `new foo.bar.Xxx(...)` when the file uses + * `loadPackageDefinition` (gRPC dynamic proto loader) + * + * As with the HTTP `node.ts`, pattern sources are defined once and + * compiled against three grammar variants (JS / TS / TSX) because + * `Parser.Query` is not portable across grammar objects. + */ + +const SERVICE_CLIENT_RE = /^(\w+Service)Client$/; +const CAPITALIZED_SERVICE_RE = /^[A-Z]\w+$/; + +// @GrpcMethod('Service', 'Method') +const GRPC_METHOD_SPEC: PatternSpec> = { + meta: {}, + query: ` + (decorator + (call_expression + function: (identifier) @dec (#eq? @dec "GrpcMethod") + arguments: (arguments + . [(string) (template_string)] @service + . [(string) (template_string)] @method))) + `, +}; + +// @GrpcClient(...) standalone decorator — the plugin walks to the next +// sibling (a field definition) to read its type annotation. +const GRPC_CLIENT_SPEC: PatternSpec> = { + meta: {}, + query: ` + (decorator + (call_expression + function: (identifier) @dec (#eq? @dec "GrpcClient"))) @grpc_client_decorator + `, +}; + +// `.getService('AuthService')` / `.getService('AuthService')` +const GET_SERVICE_SPEC: PatternSpec> = { + meta: {}, + query: ` + (call_expression + function: (member_expression + property: (property_identifier) @method (#eq? @method "getService")) + arguments: (arguments . [(string) (template_string)] @service)) + `, +}; + +// `new XxxServiceClient(...)` — bare identifier constructor. +const NEW_SIMPLE_CTOR_SPEC: PatternSpec> = { + meta: {}, + query: ` + (new_expression + constructor: (identifier) @ctor) + `, +}; + +// `new foo.bar.XxxService(...)` — qualified constructor. +const NEW_QUALIFIED_CTOR_SPEC: PatternSpec> = { + meta: {}, + query: ` + (new_expression + constructor: (member_expression + property: (property_identifier) @ctor)) + `, +}; + +// Detect whether the file uses `loadPackageDefinition` (gRPC dynamic +// proto loader). Matches either a bare call or an `obj.loadPackageDefinition(...)` +// call. Plugin gates the qualified-constructor consumer on this — +// structural check avoids materializing `tree.rootNode.text` for every file. +const LOAD_PACKAGE_DEFINITION_SPEC: PatternSpec> = { + meta: {}, + query: ` + (call_expression + function: [ + (identifier) @fn (#eq? @fn "loadPackageDefinition") + (member_expression property: (property_identifier) @fn (#eq? @fn "loadPackageDefinition")) + ]) + `, +}; + +interface NodeGrpcPatternBundle { + grpcMethod: CompiledPatterns>; + grpcClient: CompiledPatterns>; + getService: CompiledPatterns>; + newSimpleCtor: CompiledPatterns>; + newQualifiedCtor: CompiledPatterns>; + loadPackageDefinition: CompiledPatterns>; +} + +function compileBundle(language: unknown, name: string): NodeGrpcPatternBundle { + const mk = (spec: PatternSpec>, suffix: string) => + compilePatterns({ + name: `${name}-${suffix}`, + language, + patterns: [spec], + } satisfies LanguagePatterns>); + return { + grpcMethod: mk(GRPC_METHOD_SPEC, 'grpc-method'), + grpcClient: mk(GRPC_CLIENT_SPEC, 'grpc-client'), + getService: mk(GET_SERVICE_SPEC, 'get-service'), + newSimpleCtor: mk(NEW_SIMPLE_CTOR_SPEC, 'new-simple-ctor'), + newQualifiedCtor: mk(NEW_QUALIFIED_CTOR_SPEC, 'new-qualified-ctor'), + loadPackageDefinition: mk(LOAD_PACKAGE_DEFINITION_SPEC, 'load-package-definition'), + }; +} + +const JAVASCRIPT_BUNDLE = compileBundle(JavaScript, 'javascript-grpc'); +const TYPESCRIPT_BUNDLE = compileBundle(TypeScript.typescript, 'typescript-grpc'); +const TSX_BUNDLE = compileBundle(TypeScript.tsx, 'tsx-grpc'); + +/** + * Given a `@GrpcClient(...)` decorator node, find the type annotation + * text of the field it decorates (e.g. `AuthServiceClient`). + * + * In tree-sitter-typescript, decorators on class fields can appear in + * two configurations: + * - As a CHILD of `public_field_definition` alongside the field's + * type annotation (the common case for NestJS `@GrpcClient`). + * - As a SIBLING of the field in `class_body` (for method + * decorators, but kept for resilience against grammar variants). + * We walk the parent container and search for a type annotation. + */ +function resolveGrpcClientFieldType(decoratorNode: Parser.SyntaxNode): string | null { + const parent = decoratorNode.parent; + if (!parent) return null; + + // Case 1: decorator is a child of the field definition — search + // the parent itself (which is the field definition) for a + // type_annotation child. + if (parent.type === 'public_field_definition' || parent.type.endsWith('field_definition')) { + return findFirstTypeAnnotationText(parent); + } + + // Case 2: decorator is a sibling of the field in a class_body — walk + // forward through subsequent siblings until we find a node containing + // a type annotation. + for (let i = 0; i < parent.namedChildCount; i++) { + const child = parent.namedChild(i); + if (child && child.id === decoratorNode.id) { + for (let j = i + 1; j < parent.namedChildCount; j++) { + const next = parent.namedChild(j); + if (!next) continue; + if (next.type === 'decorator') continue; + const typeText = findFirstTypeAnnotationText(next); + if (typeText) return typeText; + return null; + } + return null; + } + } + return null; +} + +/** + * Recursively search `node` for the first `type_annotation` child and + * return the text of its inner `type_identifier`, or null. Handles + * both `public_field_definition` and its variants. + */ +function findFirstTypeAnnotationText(node: Parser.SyntaxNode): string | null { + if (node.type === 'type_annotation') { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child) continue; + if (child.type === 'type_identifier') return child.text; + } + return null; + } + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child) continue; + const found = findFirstTypeAnnotationText(child); + if (found) return found; + } + return null; +} + +function scanBundle(bundle: NodeGrpcPatternBundle, tree: Parser.Tree): GrpcDetection[] { + const out: GrpcDetection[] = []; + + // ─── Provider: @GrpcMethod('Service', 'Method') ────────────────── + for (const match of runCompiledPatterns(bundle.grpcMethod, tree)) { + const svcNode = match.captures.service; + const methodNode = match.captures.method; + if (!svcNode || !methodNode) continue; + const svc = unquoteLiteral(svcNode.text); + const mth = unquoteLiteral(methodNode.text); + if (!svc || !mth) continue; + out.push({ + role: 'provider', + serviceName: svc, + symbolName: `${svc}.${mth}`, + source: 'ts_grpc_method', + methodName: mth, + // @GrpcMethod hard-coded confidence 0.8 in the original code + // regardless of whether the proto map has a match. + confidenceWithProto: 0.8, + confidenceWithoutProto: 0.8, + }); + } + + // ─── Consumer: @GrpcClient() field with XxxServiceClient type ──── + for (const match of runCompiledPatterns(bundle.grpcClient, tree)) { + const decoratorNode = match.captures.grpc_client_decorator; + if (!decoratorNode) continue; + const typeText = resolveGrpcClientFieldType(decoratorNode); + if (!typeText) continue; + const svcMatch = SERVICE_CLIENT_RE.exec(typeText); + if (!svcMatch) continue; + const serviceName = svcMatch[1]; + out.push({ + role: 'consumer', + serviceName, + symbolName: `${serviceName}Client`, + source: 'ts_grpc_client_decorator', + confidenceWithProto: 0.75, + confidenceWithoutProto: 0.55, + }); + } + + // ─── Consumer: client.getService('Service') ─────────────────── + for (const match of runCompiledPatterns(bundle.getService, tree)) { + const svcNode = match.captures.service; + if (!svcNode) continue; + const svc = unquoteLiteral(svcNode.text); + if (!svc) continue; + out.push({ + role: 'consumer', + serviceName: svc, + symbolName: `${svc}Client`, + source: 'ts_client_grpc_get_service', + confidenceWithProto: 0.75, + confidenceWithoutProto: 0.55, + }); + } + + // ─── Consumer: new XxxServiceClient(...) ───────────────────────── + for (const match of runCompiledPatterns(bundle.newSimpleCtor, tree)) { + const ctorNode = match.captures.ctor; + if (!ctorNode) continue; + const svcMatch = SERVICE_CLIENT_RE.exec(ctorNode.text); + if (!svcMatch) continue; + const serviceName = svcMatch[1]; + out.push({ + role: 'consumer', + serviceName, + symbolName: `${serviceName}Client`, + source: 'ts_generated_client', + confidenceWithProto: 0.75, + confidenceWithoutProto: 0.55, + }); + } + + // ─── Consumer: loadPackageDefinition dynamic proto loader ──────── + // Only emit when the file uses loadPackageDefinition, otherwise a + // generic `new foo.bar.Something()` in unrelated code would falsely + // register as a gRPC consumer. Check structurally via a dedicated + // query — avoids materializing `tree.rootNode.text` for the whole + // file (expensive on large files). + const usesLoadPackage = runCompiledPatterns(bundle.loadPackageDefinition, tree).length > 0; + if (usesLoadPackage) { + for (const match of runCompiledPatterns(bundle.newQualifiedCtor, tree)) { + const ctorNode = match.captures.ctor; + if (!ctorNode) continue; + if (!CAPITALIZED_SERVICE_RE.test(ctorNode.text)) continue; + out.push({ + role: 'consumer', + serviceName: ctorNode.text, + symbolName: `${ctorNode.text}Client`, + source: 'ts_load_package_definition', + confidenceWithProto: 0.75, + confidenceWithoutProto: 0.55, + }); + } + } + + return out; +} + +export const JAVASCRIPT_GRPC_PLUGIN: GrpcLanguagePlugin = { + name: 'javascript-grpc', + language: JavaScript, + scan: (tree) => scanBundle(JAVASCRIPT_BUNDLE, tree), +}; + +export const TYPESCRIPT_GRPC_PLUGIN: GrpcLanguagePlugin = { + name: 'typescript-grpc', + language: TypeScript.typescript, + scan: (tree) => scanBundle(TYPESCRIPT_BUNDLE, tree), +}; + +export const TSX_GRPC_PLUGIN: GrpcLanguagePlugin = { + name: 'tsx-grpc', + language: TypeScript.tsx, + scan: (tree) => scanBundle(TSX_BUNDLE, tree), +}; diff --git a/gitnexus/src/core/group/extractors/grpc-patterns/proto.ts b/gitnexus/src/core/group/extractors/grpc-patterns/proto.ts new file mode 100644 index 000000000..69b446e55 --- /dev/null +++ b/gitnexus/src/core/group/extractors/grpc-patterns/proto.ts @@ -0,0 +1,147 @@ +import { createRequire } from 'node:module'; +import { + compilePatterns, + runCompiledPatterns, + type CompiledPatterns, + type LanguagePatterns, +} from '../tree-sitter-scanner.js'; +import type { GrpcDetection, GrpcLanguagePlugin } from './types.js'; + +/** + * Protobuf (.proto) tree-sitter plugin for gRPC contract extraction. + * + * Uses `tree-sitter-proto` (coder3101/tree-sitter-proto) as an + * optionalDependency — if the grammar is not installed (e.g. native + * compilation failed on an unusual platform), the plugin exports + * `null` and the orchestrator falls back to the existing manual + * string-sanitizing parser. + * + * The grammar is vendored in `vendor/tree-sitter-proto/` with + * parser.c regenerated against tree-sitter-cli 0.24 (ABI version 14) + * so it is compatible with the project's tree-sitter 0.25 runtime. + */ + +const _require = createRequire(import.meta.url); +let ProtoGrammar: unknown = null; +try { + ProtoGrammar = _require('tree-sitter-proto'); +} catch { + // Grammar not installed — PROTO_GRPC_PLUGIN will be null. +} + +let PACKAGE_PATTERNS: CompiledPatterns> | null = null; +let SERVICE_PATTERNS: CompiledPatterns> | null = null; + +if (ProtoGrammar) { + try { + // Validate that the grammar actually loads end-to-end: compile queries + // AND parse + walk a trivial proto file. tree-sitter's internal + // `initializeLanguageNodeClasses` can fail with a TDZ error in some + // test runners (vitest forks) when SyntaxNode isn't fully initialized + // yet. Catching that here ensures `PROTO_GRPC_PLUGIN` stays null and + // the orchestrator falls back to the manual parser. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const _Parser = _require('tree-sitter') as any; + // Smoke-test: parse + setLanguage to verify the grammar is + // end-to-end compatible with this tree-sitter runtime. + const _testParser = new _Parser(); + _testParser.setLanguage(ProtoGrammar); + _testParser.parse('service X { rpc Y (R) returns (R); }'); + + PACKAGE_PATTERNS = compilePatterns({ + name: 'proto-package', + language: ProtoGrammar, + patterns: [ + { + meta: {}, + query: `(package (full_ident) @pkg)`, + }, + ], + } satisfies LanguagePatterns>); + + SERVICE_PATTERNS = compilePatterns({ + name: 'proto-service', + language: ProtoGrammar, + patterns: [ + { + meta: {}, + query: ` + (service + (service_name) @service_name + (rpc + (rpc_name) @rpc_name)) + `, + }, + ], + } satisfies LanguagePatterns>); + } catch { + // Compilation failed (grammar ABI mismatch?) — fall back to null. + PACKAGE_PATTERNS = null; + SERVICE_PATTERNS = null; + ProtoGrammar = null; + } +} + +function buildPlugin(): GrpcLanguagePlugin | null { + if (!ProtoGrammar || !PACKAGE_PATTERNS || !SERVICE_PATTERNS) return null; + const pkgPatterns = PACKAGE_PATTERNS; + const svcPatterns = SERVICE_PATTERNS; + + return { + name: 'proto-grpc', + language: ProtoGrammar, + scan(tree) { + const out: GrpcDetection[] = []; + + // Extract `package` declaration (first match wins). + let pkg = ''; + for (const match of runCompiledPatterns(pkgPatterns, tree)) { + const pkgNode = match.captures.pkg; + if (pkgNode) { + pkg = pkgNode.text; + break; + } + } + + // Extract `service → rpc` pairs. The query returns one match per + // (service, rpc) combination thanks to the nested structure. + for (const match of runCompiledPatterns(svcPatterns, tree)) { + const serviceNode = match.captures.service_name; + const rpcNode = match.captures.rpc_name; + if (!serviceNode || !rpcNode) continue; + const serviceName = serviceNode.text; + const methodName = rpcNode.text; + out.push({ + role: 'provider', + serviceName, + symbolName: `${serviceName}.${methodName}`, + source: 'proto', + methodName, + // Proto definitions are the canonical source of truth — always + // high confidence regardless of cross-referencing. + confidenceWithProto: 0.85, + confidenceWithoutProto: 0.85, + }); + } + + return out; + }, + }; +} + +/** + * The proto plugin, or `null` if tree-sitter-proto is not available. + * The orchestrator checks this at import time and decides whether to + * use the tree-sitter path or the fallback manual parser. + */ +export const PROTO_GRPC_PLUGIN: GrpcLanguagePlugin | null = buildPlugin(); + +/** The package declaration text from a proto file's tree. */ +export function extractPackageFromTree(tree: import('tree-sitter').Tree): string { + if (!PACKAGE_PATTERNS) return ''; + for (const match of runCompiledPatterns(PACKAGE_PATTERNS, tree)) { + const pkgNode = match.captures.pkg; + if (pkgNode) return pkgNode.text; + } + return ''; +} diff --git a/gitnexus/src/core/group/extractors/grpc-patterns/python.ts b/gitnexus/src/core/group/extractors/grpc-patterns/python.ts new file mode 100644 index 000000000..a19896c1f --- /dev/null +++ b/gitnexus/src/core/group/extractors/grpc-patterns/python.ts @@ -0,0 +1,77 @@ +import Python from 'tree-sitter-python'; +import { + compilePatterns, + runCompiledPatterns, + type LanguagePatterns, +} from '../tree-sitter-scanner.js'; +import type { GrpcDetection, GrpcLanguagePlugin } from './types.js'; + +/** + * Python gRPC plugin. Detects: + * - Provider: `add_XxxServicer_to_server(...)` calls (bare identifier + * or qualified attribute form `auth_pb2_grpc.add_XxxServicer_to_server`) + * - Consumer: `XxxStub(channel)` calls (bare or `auth_pb2_grpc.XxxStub`) + */ + +const ADD_SERVICER_RE = /^add_(\w+)Servicer_to_server$/; +const STUB_RE = /^(\w+)Stub$/; +/** Reserved names that would produce garbage service names. */ +const STUB_IGNORE = new Set(['Mock', 'Test', 'Fake', 'Stub']); + +// Any call whose target is either a bare identifier or an attribute +// access (`obj.method`). The plugin filters the function name in JS. +const CALL_PATTERNS = compilePatterns({ + name: 'python-grpc-call', + language: Python, + patterns: [ + { + meta: {}, + query: ` + (call + function: [ + (identifier) @fn + (attribute attribute: (identifier) @fn) + ]) + `, + }, + ], +} satisfies LanguagePatterns>); + +export const PYTHON_GRPC_PLUGIN: GrpcLanguagePlugin = { + name: 'python-grpc', + language: Python, + scan(tree) { + const out: GrpcDetection[] = []; + for (const match of runCompiledPatterns(CALL_PATTERNS, tree)) { + const fnNode = match.captures.fn; + if (!fnNode) continue; + const fnText = fnNode.text; + + const addServicer = ADD_SERVICER_RE.exec(fnText); + if (addServicer) { + out.push({ + role: 'provider', + serviceName: addServicer[1], + symbolName: fnText, + source: 'python_servicer', + confidenceWithProto: 0.8, + confidenceWithoutProto: 0.65, + }); + continue; + } + + const stubMatch = STUB_RE.exec(fnText); + if (stubMatch && !STUB_IGNORE.has(stubMatch[1])) { + out.push({ + role: 'consumer', + serviceName: stubMatch[1], + symbolName: fnText, + source: 'python_stub', + confidenceWithProto: 0.75, + confidenceWithoutProto: 0.55, + }); + } + } + return out; + }, +}; diff --git a/gitnexus/src/core/group/extractors/grpc-patterns/types.ts b/gitnexus/src/core/group/extractors/grpc-patterns/types.ts new file mode 100644 index 000000000..606d9629b --- /dev/null +++ b/gitnexus/src/core/group/extractors/grpc-patterns/types.ts @@ -0,0 +1,54 @@ +import type Parser from 'tree-sitter'; + +/** + * Shared types for the grpc-extractor language plugins. + * + * Each plugin lives in its own file (java.ts, go.ts, ...) and owns the + * tree-sitter grammar import + query sources. The top-level + * `grpc-extractor.ts` orchestrator only knows about this type module + * and the plugin registry (`./index.ts`). It MUST NOT import any + * grammar or query text directly. + */ + +export type GrpcRole = 'provider' | 'consumer'; + +/** + * One raw gRPC detection produced by a plugin's `scan()` function. The + * orchestrator uses the proto map to resolve the full package-qualified + * contract id and choose a confidence based on whether the proto was + * found. + * + * Most patterns produce service-level detections; `TS @GrpcMethod` is + * the only pattern that captures an explicit `methodName`, producing + * a method-level contract (`grpc::pkg.Service/Method`). + */ +export interface GrpcDetection { + role: GrpcRole; + /** Short service name, e.g. `"AuthService"`. */ + serviceName: string; + /** Symbol name emitted into the contract's symbolRef. */ + symbolName: string; + /** Metadata source label (goes into `meta.source`). */ + source: string; + /** Explicit method name; set only by TS `@GrpcMethod`. */ + methodName?: string; + /** Confidence when the proto map resolves the service. */ + confidenceWithProto: number; + /** Confidence when the proto map has no entry. */ + confidenceWithoutProto: number; +} + +/** + * One language-scoped gRPC plugin. Plugins own the tree-sitter grammar + * and a `scan(tree)` function that returns zero or more + * `GrpcDetection`s. The plugin is free to run multiple compiled query + * bundles and walk the AST to cross-reference captures. + * + * `language` is typed `unknown` for the same reason as in + * `tree-sitter-scanner.ts`. + */ +export interface GrpcLanguagePlugin { + name: string; + language: unknown; + scan(tree: Parser.Tree): GrpcDetection[]; +} diff --git a/gitnexus/src/core/group/extractors/http-patterns/go.ts b/gitnexus/src/core/group/extractors/http-patterns/go.ts new file mode 100644 index 000000000..afbfaad56 --- /dev/null +++ b/gitnexus/src/core/group/extractors/http-patterns/go.ts @@ -0,0 +1,224 @@ +import Go from 'tree-sitter-go'; +import { + compilePatterns, + runCompiledPatterns, + unquoteLiteral, + type LanguagePatterns, +} from '../tree-sitter-scanner.js'; +import type { HttpDetection, HttpLanguagePlugin } from './types.js'; + +/** + * Go HTTP plugin. Handles: + * - gin / echo / chi framework routing — `r.GET("/path", handler)` + * - net/http stdlib — `http.HandleFunc("/path", handler)` + * - net/http consumer — `http.Get(...)`, `http.NewRequest("METHOD", ...)` + * - resty consumer — `client.R().Delete("/path")` + */ + +// ─── Provider: framework routing ────────────────────────────────────── +// Matches `\w+\.GET(...)` etc. (gin, echo, chi all share this shape). +// Captures the HTTP method (field name), path literal, and handler +// identifier passed as the second argument. +const FRAMEWORK_ROUTE_PATTERNS = compilePatterns({ + name: 'go-framework-route', + language: Go, + patterns: [ + { + meta: {}, + query: ` + (call_expression + function: (selector_expression + field: (field_identifier) @http_method (#match? @http_method "^(GET|POST|PUT|DELETE|PATCH)$")) + arguments: (argument_list + (interpreted_string_literal) @path + (identifier) @handler)) + `, + }, + ], +} satisfies LanguagePatterns>); + +// ─── Provider: net/http `http.HandleFunc("/p", handler)` ───────────── +const HANDLE_FUNC_PATTERNS = compilePatterns({ + name: 'go-handle-func', + language: Go, + patterns: [ + { + meta: {}, + query: ` + (call_expression + function: (selector_expression + operand: (identifier) @pkg (#eq? @pkg "http") + field: (field_identifier) @fn (#eq? @fn "HandleFunc")) + arguments: (argument_list + (interpreted_string_literal) @path + (identifier) @handler)) + `, + }, + ], +} satisfies LanguagePatterns>); + +// ─── Consumer: net/http stdlib Get / Post / Head ───────────────────── +const HTTP_CLIENT_METHOD_TO_HTTP: Record = { + Get: 'GET', + Post: 'POST', + Head: 'GET', // HEAD has no body semantics we care about — treat as GET for contract matching +}; + +const HTTP_CLIENT_PATTERNS = compilePatterns({ + name: 'go-http-client', + language: Go, + patterns: [ + { + meta: {}, + query: ` + (call_expression + function: (selector_expression + operand: (identifier) @pkg (#eq? @pkg "http") + field: (field_identifier) @fn (#match? @fn "^(Get|Post|Head)$")) + arguments: (argument_list . (interpreted_string_literal) @path)) + `, + }, + ], +} satisfies LanguagePatterns>); + +// ─── Consumer: net/http `http.NewRequest("METHOD", "/path", ...)` ──── +const NEW_REQUEST_PATTERNS = compilePatterns({ + name: 'go-new-request', + language: Go, + patterns: [ + { + meta: {}, + query: ` + (call_expression + function: (selector_expression + operand: (identifier) @pkg (#eq? @pkg "http") + field: (field_identifier) @fn (#eq? @fn "NewRequest")) + arguments: (argument_list + . + (interpreted_string_literal) @http_method + (interpreted_string_literal) @path)) + `, + }, + ], +} satisfies LanguagePatterns>); + +// ─── Consumer: resty `client.R().Delete("/path")` ───────────────────── +// Matches any chained call whose receiver is `something.R()` and whose +// method name is an HTTP verb. This is how go-resty's fluent API looks. +const RESTY_PATTERNS = compilePatterns({ + name: 'go-resty', + language: Go, + patterns: [ + { + meta: {}, + query: ` + (call_expression + function: (selector_expression + operand: (call_expression + function: (selector_expression + field: (field_identifier) @r (#eq? @r "R"))) + field: (field_identifier) @http_method (#match? @http_method "^(Get|Post|Put|Delete|Patch)$")) + arguments: (argument_list . (interpreted_string_literal) @path)) + `, + }, + ], +} satisfies LanguagePatterns>); + +export const GO_HTTP_PLUGIN: HttpLanguagePlugin = { + name: 'go-http', + language: Go, + scan(tree) { + const out: HttpDetection[] = []; + + // Framework providers: r.GET/POST/... with handler identifier + for (const match of runCompiledPatterns(FRAMEWORK_ROUTE_PATTERNS, tree)) { + const methodNode = match.captures.http_method; + const pathNode = match.captures.path; + const handlerNode = match.captures.handler; + if (!methodNode || !pathNode) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'provider', + framework: 'go-framework', + method: methodNode.text.toUpperCase(), + path, + name: handlerNode?.text ?? null, + confidence: 0.8, + }); + } + + // net/http HandleFunc: default method GET + for (const match of runCompiledPatterns(HANDLE_FUNC_PATTERNS, tree)) { + const pathNode = match.captures.path; + const handlerNode = match.captures.handler; + if (!pathNode) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'provider', + framework: 'go-stdlib', + method: 'GET', + path, + name: handlerNode?.text ?? null, + confidence: 0.8, + }); + } + + // net/http client: http.Get/Post/Head + for (const match of runCompiledPatterns(HTTP_CLIENT_PATTERNS, tree)) { + const fnNode = match.captures.fn; + const pathNode = match.captures.path; + if (!fnNode || !pathNode) continue; + const httpMethod = HTTP_CLIENT_METHOD_TO_HTTP[fnNode.text]; + if (!httpMethod) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'consumer', + framework: 'go-stdlib', + method: httpMethod, + path, + name: null, + confidence: 0.7, + }); + } + + // net/http NewRequest + for (const match of runCompiledPatterns(NEW_REQUEST_PATTERNS, tree)) { + const methodNode = match.captures.http_method; + const pathNode = match.captures.path; + if (!methodNode || !pathNode) continue; + const method = unquoteLiteral(methodNode.text); + const path = unquoteLiteral(pathNode.text); + if (method === null || path === null) continue; + out.push({ + role: 'consumer', + framework: 'go-stdlib', + method: method.toUpperCase(), + path, + name: null, + confidence: 0.7, + }); + } + + // resty + for (const match of runCompiledPatterns(RESTY_PATTERNS, tree)) { + const methodNode = match.captures.http_method; + const pathNode = match.captures.path; + if (!methodNode || !pathNode) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'consumer', + framework: 'go-resty', + method: methodNode.text.toUpperCase(), + path, + name: null, + confidence: 0.7, + }); + } + + return out; + }, +}; diff --git a/gitnexus/src/core/group/extractors/http-patterns/index.ts b/gitnexus/src/core/group/extractors/http-patterns/index.ts new file mode 100644 index 000000000..e33d32a79 --- /dev/null +++ b/gitnexus/src/core/group/extractors/http-patterns/index.ts @@ -0,0 +1,50 @@ +import * as path from 'node:path'; +import type { HttpLanguagePlugin } from './types.js'; +import { JAVA_HTTP_PLUGIN } from './java.js'; +import { GO_HTTP_PLUGIN } from './go.js'; +import { PYTHON_HTTP_PLUGIN } from './python.js'; +import { PHP_HTTP_PLUGIN } from './php.js'; +import { JAVASCRIPT_HTTP_PLUGIN, TYPESCRIPT_HTTP_PLUGIN, TSX_HTTP_PLUGIN } from './node.js'; + +export type { HttpDetection, HttpLanguagePlugin, HttpRole } from './types.js'; + +/** + * File-extension → HTTP language plugin registry. The top-level + * orchestrator (`http-route-extractor.ts`) looks up the plugin for each + * file it visits and delegates the tree-sitter scanning to the plugin. + * + * Keys are lowercase extensions including the leading dot. To add a + * new language, drop a `http-patterns/.ts` that exports a + * `HttpLanguagePlugin`, import it here and register the extension(s). + * No edits to `http-route-extractor.ts` are required. + */ +const REGISTRY: Record = { + '.java': JAVA_HTTP_PLUGIN, + '.go': GO_HTTP_PLUGIN, + '.py': PYTHON_HTTP_PLUGIN, + '.php': PHP_HTTP_PLUGIN, + '.js': JAVASCRIPT_HTTP_PLUGIN, + '.jsx': JAVASCRIPT_HTTP_PLUGIN, + '.ts': TYPESCRIPT_HTTP_PLUGIN, + '.tsx': TSX_HTTP_PLUGIN, +}; + +/** + * Glob for files worth scanning for HTTP routes. Kept alongside the + * registry so adding a new language widens the glob in one edit. + * + * `.vue` / `.svelte` files are intentionally omitted for the source-scan + * path — they need their own grammar-aware extraction and the existing + * regex fallback for them was never very accurate. The graph-assisted + * Strategy A still handles them via the ingestion pipeline. + */ +export const HTTP_SCAN_GLOB = '**/*.{ts,tsx,js,jsx,java,go,py,php}'; + +/** + * Return the HTTP plugin registered for the given file's extension, + * or `undefined` if the extension is not registered. + */ +export function getPluginForFile(rel: string): HttpLanguagePlugin | undefined { + const ext = path.extname(rel).toLowerCase(); + return REGISTRY[ext]; +} diff --git a/gitnexus/src/core/group/extractors/http-patterns/java.ts b/gitnexus/src/core/group/extractors/http-patterns/java.ts new file mode 100644 index 000000000..484f74fb2 --- /dev/null +++ b/gitnexus/src/core/group/extractors/http-patterns/java.ts @@ -0,0 +1,267 @@ +import Parser from 'tree-sitter'; +import Java from 'tree-sitter-java'; +import { + compilePatterns, + runCompiledPatterns, + unquoteLiteral, + type LanguagePatterns, +} from '../tree-sitter-scanner.js'; +import type { HttpDetection, HttpLanguagePlugin } from './types.js'; + +/** + * Java HTTP plugin. Handles: + * - Spring `@RequestMapping` class prefixes + `@(Get|Post|...)Mapping` method annotations + * - Spring `RestTemplate.getForObject/...`, `WebClient.method(HttpMethod.X, ...)` + * - OkHttp `new Request.Builder().url("...")` + * + * The plugin runs two pattern bundles: one to collect class-level + * `@RequestMapping` prefixes keyed by the enclosing class node, and a + * second to match method-level annotations. The `scan` function walks + * up from each matched annotation to find its enclosing class and + * combines the prefix with the method path. + */ + +const METHOD_ANNOTATION_TO_HTTP: Record = { + GetMapping: 'GET', + PostMapping: 'POST', + PutMapping: 'PUT', + DeleteMapping: 'DELETE', + PatchMapping: 'PATCH', +}; + +// ─── Provider: Spring class-level @RequestMapping prefix ────────────── +const SPRING_CLASS_PREFIX_PATTERNS = compilePatterns({ + name: 'java-spring-class-prefix', + language: Java, + patterns: [ + { + meta: {}, + query: ` + (class_declaration + (modifiers + (annotation + name: (identifier) @ann (#eq? @ann "RequestMapping") + arguments: (annotation_argument_list (string_literal) @prefix)))) @class + `, + }, + ], +} satisfies LanguagePatterns>); + +// ─── Provider: Spring @(Get|Post|...)Mapping method annotations ─────── +const SPRING_METHOD_ROUTE_PATTERNS = compilePatterns({ + name: 'java-spring-method-route', + language: Java, + patterns: [ + { + meta: {}, + query: ` + (method_declaration + (modifiers + (annotation + name: (identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$") + arguments: (annotation_argument_list (string_literal) @path))) + name: (identifier) @method_name) @method + `, + }, + ], +} satisfies LanguagePatterns>); + +// ─── Consumer: Spring RestTemplate (object-named + method-named) ────── +// RestTemplate.getForObject / getForEntity → GET +// RestTemplate.postForObject / postForEntity → POST +// RestTemplate.put → PUT +// RestTemplate.delete → DELETE +// RestTemplate.patchForObject → PATCH +const REST_TEMPLATE_TO_HTTP: Record = { + getForObject: 'GET', + getForEntity: 'GET', + postForObject: 'POST', + postForEntity: 'POST', + put: 'PUT', + delete: 'DELETE', + patchForObject: 'PATCH', +}; + +interface RestTemplateMeta { + framework: 'spring-rest-template'; +} + +const REST_TEMPLATE_PATTERNS = compilePatterns({ + name: 'java-rest-template', + language: Java, + patterns: [ + { + meta: { framework: 'spring-rest-template' }, + query: ` + (method_invocation + object: (identifier) @obj (#eq? @obj "restTemplate") + name: (identifier) @method + arguments: (argument_list . (string_literal) @path)) + `, + }, + ], +} satisfies LanguagePatterns); + +// ─── Consumer: Spring WebClient — webClient.method(HttpMethod.X, "path") ─ +const WEB_CLIENT_PATTERNS = compilePatterns({ + name: 'java-web-client', + language: Java, + patterns: [ + { + meta: {}, + query: ` + (method_invocation + object: (identifier) @obj (#eq? @obj "webClient") + name: (identifier) @method (#eq? @method "method") + arguments: (argument_list + (field_access + object: (identifier) @httpMethodCls (#eq? @httpMethodCls "HttpMethod") + field: (identifier) @http_method) + (string_literal) @path)) + `, + }, + ], +} satisfies LanguagePatterns>); + +// ─── Consumer: OkHttp `new Request.Builder().url("path")` ───────────── +// Note: `Request.Builder` is a `scoped_type_identifier` whose text includes +// the dot, so `#eq?` against the literal string matches cleanly (no need +// to escape a regex dot). +const OK_HTTP_PATTERNS = compilePatterns({ + name: 'java-okhttp', + language: Java, + patterns: [ + { + meta: {}, + query: ` + (method_invocation + object: (object_creation_expression + type: (scoped_type_identifier) @type (#eq? @type "Request.Builder")) + name: (identifier) @method (#eq? @method "url") + arguments: (argument_list . (string_literal) @path)) + `, + }, + ], +} satisfies LanguagePatterns>); + +/** + * Find the nearest enclosing class_declaration ancestor for a node, or + * null if the node is top-level. Tree-sitter's SyntaxNode.parent walks + * one level at a time. + */ +function findEnclosingClass(node: Parser.SyntaxNode): Parser.SyntaxNode | null { + let cur: Parser.SyntaxNode | null = node.parent; + while (cur) { + if (cur.type === 'class_declaration') return cur; + cur = cur.parent; + } + return null; +} + +/** + * Join a class-level prefix and a method-level path into a single URL + * path. Mirrors the semantics of the original regex implementation: + * strip trailing slashes on the prefix, then ensure a single slash + * between prefix and method path. + */ +function joinPath(prefix: string, methodPath: string): string { + const cleanPrefix = prefix.replace(/^\/+/, '').replace(/\/+$/, ''); + const cleanSub = methodPath.replace(/^\/+/, ''); + if (!cleanPrefix) return `/${cleanSub}`; + return `/${cleanPrefix}/${cleanSub}`; +} + +export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { + name: 'java-http', + language: Java, + scan(tree) { + const out: HttpDetection[] = []; + + // ─── Providers: Spring class prefix + method annotations ──────── + const prefixByClassId = new Map(); + for (const match of runCompiledPatterns(SPRING_CLASS_PREFIX_PATTERNS, tree)) { + const prefixNode = match.captures.prefix; + const classNode = match.captures.class; + if (!prefixNode || !classNode) continue; + const prefix = unquoteLiteral(prefixNode.text); + if (prefix !== null) prefixByClassId.set(classNode.id, prefix); + } + + for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) { + const annNode = match.captures.ann; + const pathNode = match.captures.path; + const nameNode = match.captures.method_name; + const methodNode = match.captures.method; + if (!annNode || !pathNode || !methodNode) continue; + const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text]; + if (!httpMethod) continue; + const rawPath = unquoteLiteral(pathNode.text); + if (rawPath === null) continue; + const enclosingClass = findEnclosingClass(methodNode); + const prefix = enclosingClass ? (prefixByClassId.get(enclosingClass.id) ?? '') : ''; + const fullPath = joinPath(prefix, rawPath); + out.push({ + role: 'provider', + framework: 'spring', + method: httpMethod, + path: fullPath, + name: nameNode?.text ?? null, + confidence: 0.8, + }); + } + + // ─── Consumers: RestTemplate ──────────────────────────────────── + for (const match of runCompiledPatterns(REST_TEMPLATE_PATTERNS, tree)) { + const methodNode = match.captures.method; + const pathNode = match.captures.path; + if (!methodNode || !pathNode) continue; + const httpMethod = REST_TEMPLATE_TO_HTTP[methodNode.text]; + if (!httpMethod) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'consumer', + framework: 'spring-rest-template', + method: httpMethod, + path, + name: null, + confidence: 0.7, + }); + } + + // ─── Consumers: WebClient.method(HttpMethod.X, "path") ────────── + for (const match of runCompiledPatterns(WEB_CLIENT_PATTERNS, tree)) { + const httpMethodNode = match.captures.http_method; + const pathNode = match.captures.path; + if (!httpMethodNode || !pathNode) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'consumer', + framework: 'spring-web-client', + method: httpMethodNode.text.toUpperCase(), + path, + name: null, + confidence: 0.7, + }); + } + + // ─── Consumers: OkHttp Request.Builder().url("path") ──────────── + for (const match of runCompiledPatterns(OK_HTTP_PATTERNS, tree)) { + const pathNode = match.captures.path; + if (!pathNode) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'consumer', + framework: 'okhttp', + method: 'GET', + path, + name: null, + confidence: 0.7, + }); + } + + return out; + }, +}; diff --git a/gitnexus/src/core/group/extractors/http-patterns/node.ts b/gitnexus/src/core/group/extractors/http-patterns/node.ts new file mode 100644 index 000000000..587f48e8c --- /dev/null +++ b/gitnexus/src/core/group/extractors/http-patterns/node.ts @@ -0,0 +1,373 @@ +import Parser from 'tree-sitter'; +import JavaScript from 'tree-sitter-javascript'; +import TypeScript from 'tree-sitter-typescript'; +import { + compilePatterns, + runCompiledPatterns, + unquoteLiteral, + type CompiledPatterns, + type LanguagePatterns, + type PatternSpec, +} from '../tree-sitter-scanner.js'; +import type { HttpDetection, HttpLanguagePlugin } from './types.js'; + +/** + * Node.js / TypeScript HTTP plugin family. Handles: + * - NestJS `@Controller('prefix')` classes with `@Get(':id')` methods + * - Express `router.get(...)` / `app.post(...)` providers + * - `fetch(url)` / `fetch(url, { method: 'POST' })` consumers + * - `axios.get(url)` / `axios.delete(url)` consumers + * + * Because the JavaScript and TypeScript tree-sitter grammars share + * node type names for every construct we query, pattern sources are + * defined once and compiled against each grammar variant. The plugin + * exports three `HttpLanguagePlugin`s (JS, TS, TSX) that share the + * same `scan` function but bind to different grammars. + */ + +// ─── Provider: NestJS — class-level @Controller('prefix') ──────────── +// In tree-sitter-typescript decorators are NOT children of +// class_declaration / method_definition — they're siblings in the +// surrounding class_body / program node. We therefore match the +// decorator standalone and walk to its related class/method in JS. +const NEST_CONTROLLER_SPEC: PatternSpec> = { + meta: {}, + query: ` + (decorator + (call_expression + function: (identifier) @dec (#eq? @dec "Controller") + arguments: (arguments . [(string) (template_string)] @prefix))) @ctrl_decorator + `, +}; + +// ─── Provider: NestJS — method-level @Get/@Post/... decorators ─────── +// Matches either `@Get('path')` or `@Get()`. The `@path` capture is +// optional — when the first argument isn't a string, the plugin falls +// back to '/' for the method-level path. +const NEST_METHOD_SPEC: PatternSpec> = { + meta: {}, + query: ` + (decorator + (call_expression + function: (identifier) @dec (#match? @dec "^(Get|Post|Put|Delete|Patch)$") + arguments: (arguments) @args)) @method_decorator + `, +}; + +// ─── Provider: Express — router.get/app.post/... ───────────────────── +const EXPRESS_SPEC: PatternSpec> = { + meta: {}, + query: ` + (call_expression + function: (member_expression + object: (identifier) @obj (#match? @obj "^(router|app)$") + property: (property_identifier) @http_method (#match? @http_method "^(get|post|put|delete|patch)$")) + arguments: (arguments . [(string) (template_string)] @path)) + `, +}; + +// ─── Consumer: fetch(url) with NO options ───────────────────────────── +const FETCH_NO_OPTIONS_SPEC: PatternSpec> = { + meta: {}, + query: ` + (call_expression + function: (identifier) @fn (#eq? @fn "fetch") + arguments: (arguments . [(string) (template_string)] @path .)) + `, +}; + +// ─── Consumer: fetch(url, { method: 'X', ... }) ────────────────────── +const FETCH_WITH_OPTIONS_SPEC: PatternSpec> = { + meta: {}, + query: ` + (call_expression + function: (identifier) @fn (#eq? @fn "fetch") + arguments: (arguments + . [(string) (template_string)] @path + (object + (pair + key: (property_identifier) @key (#eq? @key "method") + value: (string) @http_method)))) + `, +}; + +// ─── Consumer: axios.get/post/... ──────────────────────────────────── +const AXIOS_SPEC: PatternSpec> = { + meta: {}, + query: ` + (call_expression + function: (member_expression + object: (identifier) @obj (#eq? @obj "axios") + property: (property_identifier) @http_method (#match? @http_method "^(get|post|put|delete|patch)$")) + arguments: (arguments . [(string) (template_string)] @path)) + `, +}; + +interface NodePatternBundle { + controller: CompiledPatterns>; + methodDecorator: CompiledPatterns>; + express: CompiledPatterns>; + fetchNoOptions: CompiledPatterns>; + fetchWithOptions: CompiledPatterns>; + axios: CompiledPatterns>; +} + +function compileBundle(language: unknown, name: string): NodePatternBundle { + const mk = (spec: PatternSpec>, suffix: string) => + compilePatterns({ + name: `${name}-${suffix}`, + language, + patterns: [spec], + } satisfies LanguagePatterns>); + return { + controller: mk(NEST_CONTROLLER_SPEC, 'nest-controller'), + methodDecorator: mk(NEST_METHOD_SPEC, 'nest-method-decorator'), + express: mk(EXPRESS_SPEC, 'express'), + fetchNoOptions: mk(FETCH_NO_OPTIONS_SPEC, 'fetch-no-options'), + fetchWithOptions: mk(FETCH_WITH_OPTIONS_SPEC, 'fetch-with-options'), + axios: mk(AXIOS_SPEC, 'axios'), + }; +} + +const JAVASCRIPT_BUNDLE = compileBundle(JavaScript, 'javascript-http'); +const TYPESCRIPT_BUNDLE = compileBundle(TypeScript.typescript, 'typescript-http'); +const TSX_BUNDLE = compileBundle(TypeScript.tsx, 'tsx-http'); + +const NEST_DECORATOR_TO_HTTP: Record = { + Get: 'GET', + Post: 'POST', + Put: 'PUT', + Delete: 'DELETE', + Patch: 'PATCH', +}; + +/** + * Find the nearest enclosing class_declaration for a node, or null. + */ +function findEnclosingClass(node: Parser.SyntaxNode): Parser.SyntaxNode | null { + let cur: Parser.SyntaxNode | null = node.parent; + while (cur) { + if (cur.type === 'class_declaration') return cur; + cur = cur.parent; + } + return null; +} + +function joinPath(prefix: string, sub: string): string { + const cleanPrefix = prefix.replace(/^\/+/, '').replace(/\/+$/, ''); + const cleanSub = sub.replace(/^\/+/, ''); + if (!cleanPrefix) return `/${cleanSub}`; + return `/${cleanPrefix}/${cleanSub}`; +} + +/** + * For a standalone `decorator` node (child of class_body / program), + * find the related `class_declaration` node that it decorates. In + * tree-sitter-typescript the decorator is placed before the class + * declaration as a sibling (when decorating a class) or inside the + * class_body before a method_definition (when decorating a method); + * we walk the parent chain until we find the enclosing class. + */ +function findDecoratedClass(decoratorNode: Parser.SyntaxNode): Parser.SyntaxNode | null { + const parent = decoratorNode.parent; + if (!parent) return null; + // Case 1: decorator is a sibling of the class_declaration at program / + // export_statement level. Walk forward through siblings until we find + // the class_declaration this decorator belongs to. + for (let i = 0; i < parent.namedChildCount; i++) { + const child = parent.namedChild(i); + if (child && child.id === decoratorNode.id) { + for (let j = i + 1; j < parent.namedChildCount; j++) { + const next = parent.namedChild(j); + if (!next) continue; + if (next.type === 'decorator') continue; // adjacent decorators stack + if (next.type === 'class_declaration') return next; + if (next.type === 'export_statement') { + // `export class Foo { ... }` wraps the declaration. + for (let k = 0; k < next.namedChildCount; k++) { + const inner = next.namedChild(k); + if (inner?.type === 'class_declaration') return inner; + } + } + break; + } + break; + } + } + // Case 2: decorator is inside a class_body (decorating a method) — + // walk up to the enclosing class_declaration. + return findEnclosingClass(decoratorNode); +} + +/** + * For a method-level decorator node (child of class_body before a + * method_definition), find the method_definition it decorates. + */ +function findDecoratedMethod(decoratorNode: Parser.SyntaxNode): Parser.SyntaxNode | null { + const parent = decoratorNode.parent; + if (!parent || parent.type !== 'class_body') return null; + for (let i = 0; i < parent.namedChildCount; i++) { + const child = parent.namedChild(i); + if (child && child.id === decoratorNode.id) { + for (let j = i + 1; j < parent.namedChildCount; j++) { + const next = parent.namedChild(j); + if (!next) continue; + if (next.type === 'decorator') continue; + if (next.type === 'method_definition') return next; + return null; + } + return null; + } + } + return null; +} + +function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection[] { + const out: HttpDetection[] = []; + + // NestJS: collect `@Controller('prefix')` class decorators, keyed by + // the `class_declaration` they decorate. + const prefixByClassId = new Map(); + for (const match of runCompiledPatterns(bundle.controller, tree)) { + const prefixNode = match.captures.prefix; + const decoratorNode = match.captures.ctrl_decorator; + if (!prefixNode || !decoratorNode) continue; + const prefix = unquoteLiteral(prefixNode.text); + if (prefix === null) continue; + const classNode = findDecoratedClass(decoratorNode); + if (!classNode) continue; + prefixByClassId.set(classNode.id, prefix); + } + + // NestJS: method-level @Get/@Post/... decorators. The decorator's + // arguments list may be empty (`@Get()`), a string (`@Get('path')`), + // or something else (which we skip). + for (const match of runCompiledPatterns(bundle.methodDecorator, tree)) { + const decNode = match.captures.dec; + const argsNode = match.captures.args; + const decoratorNode = match.captures.method_decorator; + if (!decNode || !argsNode || !decoratorNode) continue; + const httpMethod = NEST_DECORATOR_TO_HTTP[decNode.text]; + if (!httpMethod) continue; + const methodNode = findDecoratedMethod(decoratorNode); + if (!methodNode) continue; + const enclosingClass = findEnclosingClass(methodNode); + // Only emit NestJS detections when the class actually has a + // @Controller decorator — without it, the match is almost certainly + // something else (e.g. an unrelated library using similar names). + if (!enclosingClass || !prefixByClassId.has(enclosingClass.id)) continue; + const prefix = prefixByClassId.get(enclosingClass.id) ?? ''; + + let rawPath = '/'; + const firstArg = argsNode.namedChild(0); + if (firstArg && (firstArg.type === 'string' || firstArg.type === 'template_string')) { + const unquoted = unquoteLiteral(firstArg.text); + if (unquoted !== null) rawPath = unquoted; + } + + // Get the method name from the decorated method_definition. + const methodNameNode = methodNode.childForFieldName('name'); + const name = methodNameNode?.text ?? null; + + out.push({ + role: 'provider', + framework: 'nest', + method: httpMethod, + path: joinPath(prefix, rawPath), + name, + confidence: 0.8, + }); + } + + // Express: router/app.(...) + for (const match of runCompiledPatterns(bundle.express, tree)) { + const methodNode = match.captures.http_method; + const pathNode = match.captures.path; + if (!methodNode || !pathNode) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'provider', + framework: 'express', + method: methodNode.text.toUpperCase(), + path, + name: 'handler', + confidence: 0.8, + }); + } + + // Consumer: fetch with options { method: 'X' } + const fetchSeen = new Set(); + for (const match of runCompiledPatterns(bundle.fetchWithOptions, tree)) { + const pathNode = match.captures.path; + const methodNode = match.captures.http_method; + if (!pathNode || !methodNode) continue; + const path = unquoteLiteral(pathNode.text); + const method = unquoteLiteral(methodNode.text); + if (path === null || method === null) continue; + fetchSeen.add(pathNode.id); + out.push({ + role: 'consumer', + framework: 'fetch', + method: method.toUpperCase(), + path, + name: null, + confidence: 0.7, + }); + } + + // Consumer: plain fetch(path) — default GET. Skip path nodes we already + // matched with the options variant so we don't double-emit. + for (const match of runCompiledPatterns(bundle.fetchNoOptions, tree)) { + const pathNode = match.captures.path; + if (!pathNode) continue; + if (fetchSeen.has(pathNode.id)) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'consumer', + framework: 'fetch', + method: 'GET', + path, + name: null, + confidence: 0.7, + }); + } + + // Consumer: axios.(url) + for (const match of runCompiledPatterns(bundle.axios, tree)) { + const methodNode = match.captures.http_method; + const pathNode = match.captures.path; + if (!methodNode || !pathNode) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'consumer', + framework: 'axios', + method: methodNode.text.toUpperCase(), + path, + name: null, + confidence: 0.7, + }); + } + + return out; +} + +export const JAVASCRIPT_HTTP_PLUGIN: HttpLanguagePlugin = { + name: 'javascript-http', + language: JavaScript, + scan: (tree) => scanBundle(JAVASCRIPT_BUNDLE, tree), +}; + +export const TYPESCRIPT_HTTP_PLUGIN: HttpLanguagePlugin = { + name: 'typescript-http', + language: TypeScript.typescript, + scan: (tree) => scanBundle(TYPESCRIPT_BUNDLE, tree), +}; + +export const TSX_HTTP_PLUGIN: HttpLanguagePlugin = { + name: 'tsx-http', + language: TypeScript.tsx, + scan: (tree) => scanBundle(TSX_BUNDLE, tree), +}; diff --git a/gitnexus/src/core/group/extractors/http-patterns/php.ts b/gitnexus/src/core/group/extractors/http-patterns/php.ts new file mode 100644 index 000000000..ae91c141b --- /dev/null +++ b/gitnexus/src/core/group/extractors/http-patterns/php.ts @@ -0,0 +1,79 @@ +import PHP from 'tree-sitter-php'; +import { + compilePatterns, + runCompiledPatterns, + unquoteLiteral, + type LanguagePatterns, +} from '../tree-sitter-scanner.js'; +import type { HttpDetection, HttpLanguagePlugin } from './types.js'; + +/** + * PHP HTTP plugin — Laravel `Route::get/post/...` declarations. + * + * The pipeline already uses `PHP.php_only` for ingesting plain `.php` + * files (see `core/tree-sitter/parser-loader.ts`), and we do the same + * here so Laravel route files are parsed with the right grammar dialect. + */ + +const LARAVEL_PATTERNS = compilePatterns({ + name: 'php-laravel', + language: PHP.php_only, + patterns: [ + { + meta: {}, + query: ` + (scoped_call_expression + scope: (name) @scope (#eq? @scope "Route") + name: (name) @method (#match? @method "^(get|post|put|delete|patch)$") + arguments: (arguments . (argument (string) @path))) + `, + }, + ], +} satisfies LanguagePatterns>); + +/** + * Extract the inner text of a PHP `string` node. The tree-sitter-php + * grammar wraps single / double-quoted literals differently depending + * on content; we try both the raw `text` (with quotes) through + * `unquoteLiteral`, and a fallback via the `string_value` / `string_content` + * child nodes. + */ +function phpStringText(node: import('tree-sitter').SyntaxNode): string | null { + // Most single-quoted strings expose their inner content through the + // full node text (including quotes), which unquoteLiteral strips. + const direct = unquoteLiteral(node.text); + if (direct !== null && direct !== node.text) return direct; + // Fall back to child string_content / string_value node if present. + for (const child of node.children) { + if (child.type === 'string_content' || child.type === 'string_value') { + return child.text; + } + } + return direct; +} + +export const PHP_HTTP_PLUGIN: HttpLanguagePlugin = { + name: 'php-http', + language: PHP.php_only, + scan(tree) { + const out: HttpDetection[] = []; + + for (const match of runCompiledPatterns(LARAVEL_PATTERNS, tree)) { + const methodNode = match.captures.method; + const pathNode = match.captures.path; + if (!methodNode || !pathNode) continue; + const path = phpStringText(pathNode); + if (path === null) continue; + out.push({ + role: 'provider', + framework: 'laravel', + method: methodNode.text.toUpperCase(), + path, + name: 'route', + confidence: 0.8, + }); + } + + return out; + }, +}; diff --git a/gitnexus/src/core/group/extractors/http-patterns/python.ts b/gitnexus/src/core/group/extractors/http-patterns/python.ts new file mode 100644 index 000000000..27ddf6633 --- /dev/null +++ b/gitnexus/src/core/group/extractors/http-patterns/python.ts @@ -0,0 +1,142 @@ +import Python from 'tree-sitter-python'; +import { + compilePatterns, + runCompiledPatterns, + unquoteLiteral, + type LanguagePatterns, +} from '../tree-sitter-scanner.js'; +import type { HttpDetection, HttpLanguagePlugin } from './types.js'; + +/** + * Python HTTP plugin. Handles: + * - FastAPI `@app.get("/path")` provider decorators + * - `requests.get/post/...("url")` consumer calls + * - Generic `requests.request("METHOD", "url")` consumer calls + */ + +const FASTAPI_VERBS: Record = { + get: 'GET', + post: 'POST', + put: 'PUT', + delete: 'DELETE', + patch: 'PATCH', +}; + +// ─── Provider: FastAPI @app.get/... ────────────────────────────────── +const FASTAPI_PATTERNS = compilePatterns({ + name: 'python-fastapi', + language: Python, + patterns: [ + { + meta: {}, + query: ` + (decorator + (call + function: (attribute + object: (identifier) @obj (#eq? @obj "app") + attribute: (identifier) @method (#match? @method "^(get|post|put|delete|patch)$")) + arguments: (argument_list . (string) @path))) + `, + }, + ], +} satisfies LanguagePatterns>); + +// ─── Consumer: requests.get/post/... ────────────────────────────────── +const REQUESTS_VERB_PATTERNS = compilePatterns({ + name: 'python-requests-verb', + language: Python, + patterns: [ + { + meta: {}, + query: ` + (call + function: (attribute + object: (identifier) @obj (#eq? @obj "requests") + attribute: (identifier) @method (#match? @method "^(get|post|put|delete|patch)$")) + arguments: (argument_list . (string) @path)) + `, + }, + ], +} satisfies LanguagePatterns>); + +// ─── Consumer: requests.request("METHOD", "url") ───────────────────── +const REQUESTS_GENERIC_PATTERNS = compilePatterns({ + name: 'python-requests-generic', + language: Python, + patterns: [ + { + meta: {}, + query: ` + (call + function: (attribute + object: (identifier) @obj (#eq? @obj "requests") + attribute: (identifier) @method (#eq? @method "request")) + arguments: (argument_list . (string) @http_method (string) @path)) + `, + }, + ], +} satisfies LanguagePatterns>); + +export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = { + name: 'python-http', + language: Python, + scan(tree) { + const out: HttpDetection[] = []; + + // Providers: FastAPI + for (const match of runCompiledPatterns(FASTAPI_PATTERNS, tree)) { + const methodNode = match.captures.method; + const pathNode = match.captures.path; + if (!methodNode || !pathNode) continue; + const httpMethod = FASTAPI_VERBS[methodNode.text]; + if (!httpMethod) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'provider', + framework: 'fastapi', + method: httpMethod, + path, + name: null, + confidence: 0.8, + }); + } + + // Consumers: requests. + for (const match of runCompiledPatterns(REQUESTS_VERB_PATTERNS, tree)) { + const methodNode = match.captures.method; + const pathNode = match.captures.path; + if (!methodNode || !pathNode) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'consumer', + framework: 'python-requests', + method: methodNode.text.toUpperCase(), + path, + name: null, + confidence: 0.7, + }); + } + + // Consumers: requests.request("METHOD", "url") + for (const match of runCompiledPatterns(REQUESTS_GENERIC_PATTERNS, tree)) { + const methodNode = match.captures.http_method; + const pathNode = match.captures.path; + if (!methodNode || !pathNode) continue; + const methodRaw = unquoteLiteral(methodNode.text); + const path = unquoteLiteral(pathNode.text); + if (methodRaw === null || path === null) continue; + out.push({ + role: 'consumer', + framework: 'python-requests', + method: methodRaw.toUpperCase(), + path, + name: null, + confidence: 0.7, + }); + } + + return out; + }, +}; diff --git a/gitnexus/src/core/group/extractors/http-patterns/types.ts b/gitnexus/src/core/group/extractors/http-patterns/types.ts new file mode 100644 index 000000000..6df0ede28 --- /dev/null +++ b/gitnexus/src/core/group/extractors/http-patterns/types.ts @@ -0,0 +1,65 @@ +import type Parser from 'tree-sitter'; + +/** + * Shared types for the http-route-extractor language plugins. + * + * Each plugin lives in its own file (java.ts, node.ts, ...) and owns + * the tree-sitter grammar import + queries. The top-level + * `http-route-extractor.ts` orchestrator only knows about this type + * module and the plugin registry (`./index.ts`). It MUST NOT import + * any grammar or query text directly — language-specific knowledge + * belongs in the plugins. + */ + +export type HttpRole = 'provider' | 'consumer'; + +/** + * One raw HTTP detection produced by a plugin's `scan()` function. The + * orchestrator converts this into a full `ExtractedContract` by running + * path normalization and building the contract id. + * + * `path` is the raw literal string as it appeared in source (with + * `${...}` template placeholders still in place); the orchestrator + * runs the appropriate normalizer for provider vs. consumer paths. + */ +export interface HttpDetection { + role: HttpRole; + /** Short framework label, e.g. `'spring'`, `'nest'`, `'express'`. */ + framework: string; + /** HTTP method in upper case (`'GET'`, `'POST'`, ...). */ + method: string; + /** Raw path literal as seen in source (template placeholders intact). */ + path: string; + /** + * Symbol name of the handler (for providers) or calling function + * (for consumers) when the plugin can determine it structurally. + * Null when no good candidate is available. + */ + name: string | null; + /** Confidence in (0, 1]. Source-scan plugins typically use 0.7–0.8. */ + confidence: number; +} + +/** + * One language-scoped HTTP plugin. The plugin owns the tree-sitter + * grammar and the `scan` function that translates a parsed tree into + * zero or more `HttpDetection`s. Plugins are free to run multiple + * compiled pattern bundles internally (see the shared scanner's + * `runCompiledPatterns` helper). + * + * `language` is typed as `unknown` for the same reason as + * `LanguagePatterns.language` in `tree-sitter-scanner.ts` — the + * grammar modules export different shapes. + */ +export interface HttpLanguagePlugin { + /** Human-readable plugin name for diagnostics. */ + name: string; + /** tree-sitter grammar object (passed to the shared parser). */ + language: unknown; + /** + * Scan a parsed tree and return zero or more HTTP detections. Plugins + * must not throw — they should swallow per-match errors so a single + * malformed construct does not abort the whole file. + */ + scan(tree: Parser.Tree): HttpDetection[]; +} diff --git a/gitnexus/src/core/group/extractors/http-route-extractor.ts b/gitnexus/src/core/group/extractors/http-route-extractor.ts index ebb4c668d..0b07090e1 100644 --- a/gitnexus/src/core/group/extractors/http-route-extractor.ts +++ b/gitnexus/src/core/group/extractors/http-route-extractor.ts @@ -1,8 +1,34 @@ -import * as fs from 'node:fs'; import * as path from 'node:path'; import { glob } from 'glob'; +import Parser from 'tree-sitter'; import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js'; import type { ExtractedContract, RepoHandle } from '../types.js'; +import { readSafe } from './fs-utils.js'; +import { getPluginForFile, HTTP_SCAN_GLOB, type HttpDetection } from './http-patterns/index.js'; + +/** + * Language-agnostic orchestrator for HTTP route (provider + consumer) + * contract extraction. Two strategies, in order of preference per role: + * + * 1. **Graph-assisted (Strategy A)** — if a per-repo LadybugDB executor + * is available, read `HANDLES_ROUTE` / `FETCHES` Cypher edges that + * the ingestion pipeline already produced via tree-sitter. This is + * the preferred path because the graph has richer symbol metadata + * (real uids, class/method structure, etc.). + * + * 2. **Source-scan fallback (Strategy B)** — parse files directly with + * the per-language plugin registry in `./http-patterns/`. Used when + * the graph has no routes/fetches for this repo (e.g. a repo that + * hasn't been indexed yet, or whose indexer doesn't know the + * framework). Each plugin owns its tree-sitter grammar and query + * sources — this orchestrator imports NO grammars or query strings. + * + * Adding a new language for Strategy B is a one-file edit in + * `http-patterns/index.ts`: register a new `HttpLanguagePlugin` and + * widen `HTTP_SCAN_GLOB` if needed. + */ + +// ─── Graph-assisted queries ────────────────────────────────────────── const HANDLES_ROUTE_QUERY = ` MATCH (handlerFile:File)-[r:CodeRelation {type: 'HANDLES_ROUTE'}]->(route:Route) @@ -23,14 +49,56 @@ WHERE sym.startLine IS NOT NULL RETURN sym.id AS uid, sym.name AS name, sym.filePath AS filePath, labels(sym) AS labels ORDER BY sym.startLine`; +// ─── Path normalization (shared between provider / consumer paths) ── + +/** + * Canonicalize a provider-side HTTP path for contract-id generation: + * - strip query string + * - lower-case + * - drop trailing slash + * - collapse `:id`, `{id}`, `[id]` path params into a single `{param}` + */ export function normalizeHttpPath(p: string): string { let s = p.trim().split('?')[0].toLowerCase().replace(/\/+$/, ''); s = s.replace(/:\w+/g, '{param}'); s = s.replace(/\{[^}]+\}/g, '{param}'); s = s.replace(/\[[^\]]+\]/g, '{param}'); - return s; + // Preserve root: after stripping trailing slashes, the root "/" + // collapses to "" which would produce malformed contract ids like + // `http::GET::`. Restore a single slash for the root case. + return s === '' ? '/' : s; } +/** + * Consumer-side normalization is more aggressive: + * - template literals (`${x}`) → `{param}` + * - strip protocol + host if the URL is absolute + * - numeric segments → `{param}` (so `/api/orders/42` → `/api/orders/{param}`) + */ +function normalizeConsumerPath(url: string): string { + const templated = url.replace(/\$\{[^}]+\}/g, '{param}').trim(); + let pathOnly = templated; + if (/^https?:\/\//i.test(templated)) { + try { + pathOnly = new URL(templated).pathname; + } catch { + pathOnly = templated.replace(/^https?:\/\/[^/]+/i, ''); + } + } + const normalized = normalizeHttpPath(pathOnly || '/'); + const segments = normalized + .split('/') + .filter(Boolean) + .map((segment) => (/^\d+$/.test(segment) ? '{param}' : segment)); + return `/${segments.join('/')}`.replace(/\/+$/, '') || '/'; +} + +function contractIdFor(method: string, pathNorm: string): string { + return `http::${method.toUpperCase()}::${pathNorm}`; +} + +// ─── Graph row helpers ─────────────────────────────────────────────── + function methodFromRouteReason(reason: string): string | null { const r = reason || ''; if (/GetMapping|decorator-Get/i.test(r)) return 'GET'; @@ -41,50 +109,6 @@ function methodFromRouteReason(reason: string): string | null { return null; } -function contractIdFor(method: string, pathNorm: string): string { - return `http::${method.toUpperCase()}::${pathNorm}`; -} - -function readSafe(repoPath: string, rel: string): string | null { - const abs = path.resolve(repoPath, rel); - const base = path.resolve(repoPath); - const relToBase = path.relative(base, abs); - if (relToBase.startsWith('..') || path.isAbsolute(relToBase)) return null; - try { - return fs.readFileSync(abs, 'utf-8'); - } catch { - return null; - } -} - -function pickJavaHandlerName( - content: string, - routePath: string, - httpMethod: string, -): string | null { - const tail = routePath.split('/').filter(Boolean).pop() || ''; - const mapNames: Record = { - GET: 'GetMapping', - POST: 'PostMapping', - PUT: 'PutMapping', - DELETE: 'DeleteMapping', - PATCH: 'PatchMapping', - }; - const ann = mapNames[httpMethod] || 'GetMapping'; - const lines = content.split(/\r?\n/); - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - if (!line.includes(`@${ann}`)) continue; - if (!line.includes(`"${tail}"`) && !line.includes(`'${tail}'`) && tail && !line.includes(tail)) - continue; - for (let j = i + 1; j < Math.min(i + 8, lines.length); j++) { - const m = lines[j].match(/(?:public|protected|private)\s+[\w<>,\s\[\]]+\s+(\w+)\s*\(/); - if (m) return m[1]; - } - } - return null; -} - function pickSymbolUid( rows: Record[], preferredName: string | null, @@ -114,6 +138,8 @@ function pickSymbolUid( }; } +// ─── Orchestrator ──────────────────────────────────────────────────── + export class HttpRouteExtractor implements ContractExtractor { type = 'http' as const; @@ -124,20 +150,76 @@ export class HttpRouteExtractor implements ContractExtractor { async extract( dbExecutor: CypherExecutor | null, repoPath: string, - repo: RepoHandle, + _repo: RepoHandle, ): Promise { - const graphP = dbExecutor != null ? await this.extractProvidersGraph(dbExecutor, repoPath) : []; - const providers = graphP.length > 0 ? graphP : await this.extractProvidersSourceScan(repoPath); + // Parse each file at most once and reuse the plugin results across + // both graph-assisted enrichment and source-scan emission. + const parser = new Parser(); + const cachedDetections = new Map(); + const getDetections = (rel: string): HttpDetection[] => { + const cached = cachedDetections.get(rel); + if (cached) return cached; + const plugin = getPluginForFile(rel); + if (!plugin) { + cachedDetections.set(rel, []); + return []; + } + const content = readSafe(repoPath, rel); + if (!content) { + cachedDetections.set(rel, []); + return []; + } + try { + parser.setLanguage(plugin.language); + const tree = parser.parse(content); + const detections = plugin.scan(tree); + cachedDetections.set(rel, detections); + return detections; + } catch { + cachedDetections.set(rel, []); + return []; + } + }; - const graphC = dbExecutor != null ? await this.extractConsumersGraph(dbExecutor, repoPath) : []; - const consumers = graphC.length > 0 ? graphC : await this.extractConsumersSourceScan(repoPath); + // Glob the source-scan file list at most once per extract() — + // both provider and consumer fallback paths share the same list. + let scannedFiles: string[] | null = null; + const getScannedFiles = async (): Promise => { + if (scannedFiles) return scannedFiles; + scannedFiles = await this.scanFiles(repoPath); + return scannedFiles; + }; + + const graphProviders = + dbExecutor != null ? await this.extractProvidersGraph(dbExecutor, getDetections) : []; + const providers = + graphProviders.length > 0 + ? graphProviders + : this.extractProvidersSourceScan(await getScannedFiles(), getDetections); + + const graphConsumers = + dbExecutor != null ? await this.extractConsumersGraph(dbExecutor, getDetections) : []; + const consumers = + graphConsumers.length > 0 + ? graphConsumers + : this.extractConsumersSourceScan(await getScannedFiles(), getDetections); return [...providers, ...consumers]; } + private async scanFiles(repoPath: string): Promise { + return glob(HTTP_SCAN_GLOB, { + cwd: repoPath, + ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**', '**/vendor/**'], + nodir: true, + }); + } + + // ─── Graph-assisted providers ────────────────────────────────────── + private async extractProvidersGraph( db: CypherExecutor, - repoPath: string, + getDetections: (rel: string) => HttpDetection[], ): Promise { const out: ExtractedContract[] = []; let rows: Record[]; @@ -152,16 +234,26 @@ export class HttpRouteExtractor implements ContractExtractor { const routePath = String(row.routePath ?? ''); const routeSource = String(row.routeSource ?? row.routeReason ?? ''); let method = methodFromRouteReason(routeSource); - const content = readSafe(repoPath, filePath); - if (!method && content) { - method = this.inferMethodFromFileScan(content, routePath, 'provider'); + + // Look up handler name (and backfill method if missing) from the + // plugin's scan of the handler file. This replaces the old + // regex-based `inferMethodFromFileScan` and `pickJavaHandlerName` + // helpers — tree-sitter gives both pieces of information + // structurally. Always run the lookup: even when method is set by + // `methodFromRouteReason`, we still need the handler name. + const detections = filePath ? getDetections(filePath) : []; + const providerDetections = detections.filter((d) => d.role === 'provider'); + let handlerName: string | null = null; + const normalizedRoute = normalizeHttpPath(routePath); + const match = providerDetections.find((d) => normalizeHttpPath(d.path) === normalizedRoute); + if (match) { + if (!method) method = match.method; + handlerName = match.name; } if (!method) method = 'GET'; const pathNorm = normalizeHttpPath(routePath); const cid = contractIdFor(method, pathNorm); - const handlerName = - content && routePath ? pickJavaHandlerName(content, routePath, method) : null; let symbolUid = ''; let symbolName = path.basename(filePath) || 'handler'; @@ -201,157 +293,44 @@ export class HttpRouteExtractor implements ContractExtractor { return out; } - private inferMethodFromFileScan( - content: string, - routePath: string, - _role: string, - ): string | null { - const tail = routePath.split('/').filter(Boolean).pop() || ''; - for (const m of ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'] as const) { - const mapNames: Record = { - GET: 'GetMapping', - POST: 'PostMapping', - PUT: 'PutMapping', - DELETE: 'DeleteMapping', - PATCH: 'PatchMapping', - }; - if ( - content.includes(`@${mapNames[m]}`) && - (content.includes(tail) || routePath.includes(tail)) - ) { - return m; - } - } - return null; - } + // ─── Source-scan providers ───────────────────────────────────────── - private async extractProvidersSourceScan(repoPath: string): Promise { - const files = await glob('**/*.{ts,tsx,js,jsx,java,vue,svelte,php,py}', { - cwd: repoPath, - ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**'], - nodir: true, - }); + private extractProvidersSourceScan( + files: string[], + getDetections: (rel: string) => HttpDetection[], + ): ExtractedContract[] { const out: ExtractedContract[] = []; for (const rel of files) { - const content = readSafe(repoPath, rel); - if (!content) continue; - out.push(...this.scanSpringProviders(content, rel)); - out.push(...this.scanExpressProviders(content, rel)); - out.push(...this.scanLaravelProviders(content, rel)); - out.push(...this.scanFastApiProviders(content, rel)); + const detections = getDetections(rel); + for (const d of detections) { + if (d.role !== 'provider') continue; + const pathNorm = normalizeHttpPath(d.path); + out.push({ + contractId: contractIdFor(d.method, pathNorm), + type: 'http', + role: 'provider', + symbolUid: '', + symbolRef: { filePath: rel, name: d.name ?? 'handler' }, + symbolName: d.name ?? 'handler', + confidence: d.confidence, + meta: { + method: d.method, + path: pathNorm, + pathSegments: pathNorm.split('/').filter(Boolean), + extractionStrategy: 'source_scan', + framework: d.framework, + }, + }); + } } return this.dedupeContracts(out); } - private dedupeContracts(items: ExtractedContract[]): ExtractedContract[] { - const seen = new Set(); - const out: ExtractedContract[] = []; - for (const c of items) { - const k = `${c.contractId}|${c.symbolRef.filePath}|${c.symbolRef.name}`; - if (seen.has(k)) continue; - seen.add(k); - out.push(c); - } - return out; - } - - private scanSpringProviders(content: string, filePath: string): ExtractedContract[] { - const out: ExtractedContract[] = []; - - // Skip Feign/client interfaces — annotated methods in interfaces are - // consumers (Feign, JAX-RS proxies), not provider endpoints. - // Anchored to line start (with optional access modifier) so we do not - // match "interface" inside comments or string literals. - if ( - /^\s*(?:public\s+)?interface\s+\w+/m.test(content) && - !/@(?:Rest)?Controller\b/.test(content) - ) { - return out; - } - - let classPrefix = ''; - const classRm = content.match(/@RequestMapping\s*\(\s*"([^"]+)"/); - if (classRm) classPrefix = classRm[1].replace(/\/+$/, ''); - - const re = /@(Get|Post|Put|Delete|Patch)Mapping\s*\(\s*"([^"]+)"/gi; - let m: RegExpExecArray | null; - while ((m = re.exec(content)) !== null) { - const method = m[1].toUpperCase(); - let p = m[2]; - if (classPrefix) p = `${classPrefix}/${p.replace(/^\//, '')}`; - const pathNorm = normalizeHttpPath(p); - const sub = content.slice(m.index); - const nameM = sub.match(/(?:public|protected|private)\s+[\w<>,\s\[\]]+\s+(\w+)\s*\(/); - const name = nameM ? nameM[1] : m[0]; - out.push(this.makeProvider(filePath, method, pathNorm, name, 0.8)); - } - return out; - } - - private scanExpressProviders(content: string, filePath: string): ExtractedContract[] { - const out: ExtractedContract[] = []; - const re = /(?:router|app)\.(get|post|put|delete|patch)\s*\(\s*['"]([^'"]+)['"]/gi; - let m: RegExpExecArray | null; - while ((m = re.exec(content)) !== null) { - const method = m[1].toUpperCase(); - const pathNorm = normalizeHttpPath(m[2]); - out.push(this.makeProvider(filePath, method, pathNorm, 'handler', 0.8)); - } - return out; - } - - private scanLaravelProviders(content: string, filePath: string): ExtractedContract[] { - const out: ExtractedContract[] = []; - const re = /Route::(get|post|put|delete|patch)\s*\(\s*['"]([^'"]+)['"]/gi; - let m: RegExpExecArray | null; - while ((m = re.exec(content)) !== null) { - const method = m[1].toUpperCase(); - const pathNorm = normalizeHttpPath(m[2]); - out.push(this.makeProvider(filePath, method, pathNorm, 'route', 0.8)); - } - return out; - } - - private scanFastApiProviders(content: string, filePath: string): ExtractedContract[] { - const out: ExtractedContract[] = []; - const re = /@app\.(get|post|put|delete|patch)\s*\(\s*['"]([^'"]+)['"]/gi; - let m: RegExpExecArray | null; - while ((m = re.exec(content)) !== null) { - const method = m[1].toUpperCase(); - const pathNorm = normalizeHttpPath(m[2]); - out.push(this.makeProvider(filePath, method, pathNorm, 'handler', 0.8)); - } - return out; - } - - private makeProvider( - filePath: string, - method: string, - pathNorm: string, - name: string, - confidence: number, - ): ExtractedContract { - const cid = contractIdFor(method, pathNorm); - return { - contractId: cid, - type: 'http', - role: 'provider', - symbolUid: '', - symbolRef: { filePath, name }, - symbolName: name, - confidence, - meta: { - method, - path: pathNorm, - pathSegments: pathNorm.split('/').filter(Boolean), - extractionStrategy: 'source_scan', - }, - }; - } + // ─── Graph-assisted consumers ────────────────────────────────────── private async extractConsumersGraph( db: CypherExecutor, - repoPath: string, + getDetections: (rel: string) => HttpDetection[], ): Promise { const out: ExtractedContract[] = []; let rows: Record[]; @@ -365,11 +344,14 @@ export class HttpRouteExtractor implements ContractExtractor { const routePath = String(row.routePath ?? ''); const pathNorm = normalizeHttpPath(routePath); let method = 'GET'; - const content = readSafe(repoPath, filePath); - if (content) { - const inferred = this.inferFetchMethod(content, pathNorm); - if (inferred) method = inferred; - } + // Prefer the plugin's detected method if we can find a matching + // fetch/axios call in the same file. + const detections = filePath ? getDetections(filePath) : []; + const inferred = detections.find( + (d) => d.role === 'consumer' && normalizeConsumerPath(d.path) === pathNorm, + ); + if (inferred) method = inferred.method; + const cid = contractIdFor(method, pathNorm); let symbolUid = ''; let symbolName = 'fetch'; @@ -407,81 +389,47 @@ export class HttpRouteExtractor implements ContractExtractor { return out; } - private inferFetchMethod(content: string, pathNorm: string): string | null { - const esc = pathNorm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const fetchRe = new RegExp( - `fetch\\s*\\(\\s*['"\`]([^'"\`]*${esc}[^'"\`]*)['"\`]\\s*,\\s*\\{[^}]*method:\\s*['"](\\w+)['"]`, - 'i', - ); - const m = content.match(fetchRe); - if (m) return m[2].toUpperCase(); - return null; - } + // ─── Source-scan consumers ───────────────────────────────────────── - private async extractConsumersSourceScan(repoPath: string): Promise { - const files = await glob('**/*.{ts,tsx,js,jsx,vue,svelte}', { - cwd: repoPath, - ignore: ['**/node_modules/**', '**/.git/**'], - nodir: true, - }); + private extractConsumersSourceScan( + files: string[], + getDetections: (rel: string) => HttpDetection[], + ): ExtractedContract[] { const out: ExtractedContract[] = []; for (const rel of files) { - const content = readSafe(repoPath, rel); - if (!content) continue; - out.push(...this.scanFetchConsumers(content, rel)); - out.push(...this.scanAxiosConsumers(content, rel)); + const detections = getDetections(rel); + for (const d of detections) { + if (d.role !== 'consumer') continue; + const pathNorm = normalizeConsumerPath(d.path); + out.push({ + contractId: contractIdFor(d.method, pathNorm), + type: 'http', + role: 'consumer', + symbolUid: '', + symbolRef: { filePath: rel, name: 'fetch' }, + symbolName: 'fetch', + confidence: d.confidence, + meta: { + method: d.method, + path: pathNorm, + extractionStrategy: 'source_scan', + framework: d.framework, + }, + }); + } } return this.dedupeContracts(out); } - private scanFetchConsumers(content: string, filePath: string): ExtractedContract[] { + private dedupeContracts(items: ExtractedContract[]): ExtractedContract[] { + const seen = new Set(); const out: ExtractedContract[] = []; - const re = - /fetch\s*\(\s*['"`]([^'"`]+)['"`](?:\s*,\s*\{[^}]*method:\s*['"](\w+)['"][^}]*\})?\s*\)/gi; - let m: RegExpExecArray | null; - while ((m = re.exec(content)) !== null) { - const pathNorm = normalizeHttpPath(this.templateToPattern(m[1])); - const method = (m[2] || 'GET').toUpperCase(); - out.push(this.makeConsumer(filePath, method, pathNorm, 0.7)); + for (const c of items) { + const k = `${c.contractId}|${c.symbolRef.filePath}|${c.symbolRef.name}`; + if (seen.has(k)) continue; + seen.add(k); + out.push(c); } return out; } - - private templateToPattern(url: string): string { - return url.replace(/\$\{[^}]+\}/g, '{param}'); - } - - private scanAxiosConsumers(content: string, filePath: string): ExtractedContract[] { - const out: ExtractedContract[] = []; - const re = /axios\.(get|post|put|delete|patch)\s*\(\s*[`'"]([^`'"]+)[`'"]/gi; - let m: RegExpExecArray | null; - while ((m = re.exec(content)) !== null) { - const method = m[1].toUpperCase(); - const pathNorm = normalizeHttpPath(this.templateToPattern(m[2])); - out.push(this.makeConsumer(filePath, method, pathNorm, 0.7)); - } - return out; - } - - private makeConsumer( - filePath: string, - method: string, - pathNorm: string, - confidence: number, - ): ExtractedContract { - return { - contractId: contractIdFor(method, pathNorm), - type: 'http', - role: 'consumer', - symbolUid: '', - symbolRef: { filePath, name: 'fetch' }, - symbolName: 'fetch', - confidence, - meta: { - method, - path: pathNorm, - extractionStrategy: 'source_scan', - }, - }; - } } diff --git a/gitnexus/src/core/group/extractors/manifest-extractor.ts b/gitnexus/src/core/group/extractors/manifest-extractor.ts new file mode 100644 index 000000000..29c8f9b21 --- /dev/null +++ b/gitnexus/src/core/group/extractors/manifest-extractor.ts @@ -0,0 +1,268 @@ +import type { ContractType, CrossLink, GroupManifestLink, StoredContract } from '../types.js'; +import type { CypherExecutor } from '../contract-extractor.js'; + +export interface ManifestExtractResult { + contracts: StoredContract[]; + crossLinks: CrossLink[]; +} + +/** + * Canonicalize an HTTP path for matching against Route.name in the graph. + * Mirrors core/ingestion/pipeline.ts ensureSlash semantics: + * - Ensures a leading slash. + * - Strips trailing slashes (except the root "/"). + * - Normalizes consecutive slashes. + * - Does NOT lowercase (route matching is case-sensitive). + */ +function normalizeRoutePath(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) return '/'; + const withLeading = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; + const collapsed = withLeading.replace(/\/+/g, '/'); + if (collapsed === '/') return '/'; + return collapsed.replace(/\/+$/, ''); +} + +/** + * Stable synthetic symbolUid for a manifest-declared contract whose target + * symbol could not be resolved against the per-repo graph (resolveSymbol + * returned null). Two reasons we don't leave the uid empty: + * + * 1. The bridge stores Contract nodes keyed in part by symbolUid; an empty + * uid means downstream Cypher queries that anchor on `provider.symbolUid` + * can't tell two different unresolved manifest contracts apart. + * 2. The cross-impact bridge query in cross-impact.ts joins local impact + * results to bridge contracts via `WHERE provider.symbolUid IN $localUids`. + * If the local impact engine produces a deterministic identifier for the + * unresolved target, it must agree with the value the bridge stored. A + * synthetic uid keyed off (repo, contractId) is the only thing both sides + * can derive without knowing about each other. + * + * Format: `manifest::::`. Stable across syncs, scoped to a + * single repo within a group, and never collides with real indexer uids + * (which never start with `manifest::`). + */ +export function manifestSymbolUid(repo: string, contractId: string): string { + return `manifest::${repo}::${contractId}`; +} + +export class ManifestExtractor { + async extractFromManifest( + links: GroupManifestLink[], + dbExecutors?: Map, + ): Promise { + const contracts: StoredContract[] = []; + const crossLinks: CrossLink[] = []; + + for (const link of links) { + const contractId = this.buildContractId(link.type, link.contract); + + const providerRepo = link.role === 'provider' ? link.from : link.to; + const consumerRepo = link.role === 'provider' ? link.to : link.from; + + const providerSymbol = await this.resolveSymbol(providerRepo, link, dbExecutors); + const consumerSymbol = await this.resolveSymbol(consumerRepo, link, dbExecutors); + const providerRef = providerSymbol || { filePath: '', name: link.contract }; + const consumerRef = consumerSymbol || { filePath: '', name: link.contract }; + // When the resolver finds a real graph symbol we keep its uid, otherwise + // fall back to the deterministic synthetic uid (see manifestSymbolUid). + const providerUid = providerSymbol?.uid || manifestSymbolUid(providerRepo, contractId); + const consumerUid = consumerSymbol?.uid || manifestSymbolUid(consumerRepo, contractId); + + contracts.push({ + contractId, + type: link.type, + role: 'provider', + symbolUid: providerUid, + symbolRef: providerRef, + symbolName: link.contract, + confidence: 1.0, + meta: { source: 'manifest' }, + repo: providerRepo, + }); + + contracts.push({ + contractId, + type: link.type, + role: 'consumer', + symbolUid: consumerUid, + symbolRef: consumerRef, + symbolName: link.contract, + confidence: 1.0, + meta: { source: 'manifest' }, + repo: consumerRepo, + }); + + crossLinks.push({ + from: { repo: consumerRepo, symbolUid: consumerUid, symbolRef: consumerRef }, + to: { repo: providerRepo, symbolUid: providerUid, symbolRef: providerRef }, + type: link.type, + contractId, + matchType: 'manifest', + confidence: 1.0, + }); + } + + return { contracts, crossLinks }; + } + + private async resolveSymbol( + repoPathKey: string, + link: GroupManifestLink, + dbExecutors?: Map, + ): Promise<{ filePath: string; name: string; uid: string } | null> { + const executor = dbExecutors?.get(repoPathKey); + if (!executor) return null; + + // NOTE: All lookups use EXACT equality on the relevant name field and + // deterministic ORDER BY before LIMIT 1. Previous versions used CONTAINS + // for fuzzy matching (plus an unconditional ".proto" fallback for gRPC) + // which produced silent false positives: e.g. manifest "/orders" would + // match "/suborders", and a gRPC manifest entry in a repo with any + // .proto file would attach to a random proto symbol. + // + // If resolveSymbol returns null, the extractor falls back to a + // deterministic synthetic uid via `manifestSymbolUid(repo, contractId)` + // (see the function's docstring for why synthetic rather than empty). + // Cross-impact still works: the bridge query joins on the synthetic + // uid, and the local impact engine derives the same uid for the + // unresolved symbol — name-based hints are the additional safety net. + try { + let rows: Record[]; + if (link.type === 'http') { + // Route.name is the canonicalized URL path (see + // core/ingestion/pipeline.ts ensureSlash + generateId('Route', ...)). + // Normalize the manifest contract the same way so a user-written + // "/api/orders" matches "api/orders" in the graph. + const normalized = normalizeRoutePath(link.contract); + rows = await executor( + `MATCH (handler)-[r:CodeRelation {type: 'HANDLES_ROUTE'}]->(route:Route) + WHERE route.name = $normalized + RETURN handler.id AS uid, handler.name AS name, handler.filePath AS filePath + ORDER BY handler.filePath ASC + LIMIT 1`, + { normalized }, + ); + } else if (link.type === 'topic') { + // Topic names aren't a first-class NodeLabel in the graph — + // topics are referenced by function/method symbols (Kafka + // listeners, publishers). Restrict to symbol-like labels to + // avoid cross-matching Files/Variables/Imports that happen to + // share the topic name. + rows = await executor( + `MATCH (n:Function|Method|Class|Interface) WHERE n.name = $contract + RETURN n.id AS uid, n.name AS name, n.filePath AS filePath + ORDER BY n.filePath ASC + LIMIT 1`, + { contract: link.contract }, + ); + } else if (link.type === 'grpc') { + // Contract is "Service/Method" or just "Service" (or package.Service + // variants). Prefer matching by method name when present, otherwise + // by service name. NO .proto path fallback — that's guaranteed to + // return a wrong symbol in any repo with more than one proto file. + // Label filters scope lookups: methods → Function|Method, services + // → Class|Interface (no label match = no silent wrong hits on + // File/Variable nodes that happen to share the name). + const parts = link.contract.split('/'); + const serviceName = parts[0]?.trim() ?? ''; + const methodName = parts[1]?.trim() ?? ''; + if (methodName) { + rows = await executor( + `MATCH (n:Function|Method) WHERE n.name = $methodName + RETURN n.id AS uid, n.name AS name, n.filePath AS filePath + ORDER BY n.filePath ASC + LIMIT 1`, + { methodName }, + ); + } else if (serviceName) { + rows = await executor( + `MATCH (n:Class|Interface) WHERE n.name = $serviceName + RETURN n.id AS uid, n.name AS name, n.filePath AS filePath + ORDER BY n.filePath ASC + LIMIT 1`, + { serviceName }, + ); + } else { + rows = []; + } + } else if (link.type === 'lib') { + // Only exact match on the symbol's name. Previous fallback to + // CONTAINS on n.filePath would promote "react" to "react-native" + // or "@types/react" — silent wrong attribution. Restrict to + // package-level labels so we don't return arbitrary symbols + // named after a library. + rows = await executor( + `MATCH (n:Package|Module) WHERE n.name = $contract + RETURN n.id AS uid, n.name AS name, n.filePath AS filePath + ORDER BY n.filePath ASC + LIMIT 1`, + { contract: link.contract }, + ); + } else { + return null; + } + if (rows.length > 0) { + return { + filePath: rows[0].filePath as string, + name: rows[0].name as string, + uid: String(rows[0].uid ?? ''), + }; + } + } catch (err) { + // Log but don't throw: a broken graph query in one repo shouldn't + // fail the whole manifest extraction. Unresolved contracts still + // get a synthetic symbolUid below, so cross-impact can proceed. + const message = err instanceof Error ? err.message : String(err); + console.warn( + `[manifest-extractor] resolveSymbol failed for ${link.type}:${link.contract} ` + + `in ${repoPathKey}: ${message}`, + ); + } + return null; + } + + /** + * Build a canonical contract id for a manifest link. + * + * HTTP is the only type with two valid forms: + * - Explicit method: `"GET::/api/orders"` → `"http::GET::/api/orders"` + * (matches exactly against `HttpRouteExtractor` provider/consumer + * contracts, which are also keyed by `http::::`). + * - Method-agnostic: `"/api/orders"` → `"http::*::/api/orders"` + * — the `*` is a wildcard and is intended to match any concrete + * HTTP method on that path. Wildcard-aware matching is the + * responsibility of the sync / cross-impact layer (see #793); + * downstream code should treat `http::*::` as matching + * every `http::::` for the same path. + * + * Recommend the explicit-method form in group.yaml whenever the + * manifest author knows the method — it round-trips through exact + * equality matching without requiring wildcard logic downstream. + * + * NOTE on exhaustiveness: the switch covers every current + * `ContractType` variant and falls through to a `never` assertion so + * TypeScript fails the build if a new variant is added without a + * corresponding case. + */ + private buildContractId(type: ContractType, contract: string): string { + switch (type) { + case 'http': { + if (/^[A-Za-z]+::/.test(contract)) return `http::${contract}`; + return `http::*::${contract}`; + } + case 'grpc': + return `grpc::${contract}`; + case 'topic': + return `topic::${contract}`; + case 'lib': + return `lib::${contract}`; + case 'custom': + return `custom::${contract}`; + default: { + const _exhaustive: never = type; + throw new Error(`Unhandled ContractType: ${String(_exhaustive)}`); + } + } + } +} diff --git a/gitnexus/src/core/group/extractors/topic-extractor.ts b/gitnexus/src/core/group/extractors/topic-extractor.ts index c27b419bb..1fbccac8a 100644 --- a/gitnexus/src/core/group/extractors/topic-extractor.ts +++ b/gitnexus/src/core/group/extractors/topic-extractor.ts @@ -1,214 +1,49 @@ -import * as fs from 'node:fs'; -import * as path from 'node:path'; import { glob } from 'glob'; +import Parser from 'tree-sitter'; import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js'; import type { ExtractedContract, RepoHandle } from '../types.js'; +import { readSafe } from './fs-utils.js'; +import { scanFile, unquoteLiteral } from './tree-sitter-scanner.js'; +import { + TOPIC_SCAN_GLOB, + getProviderForFile, + type Broker, + type TopicMeta, +} from './topic-patterns/index.js'; -type Broker = 'kafka' | 'rabbitmq' | 'nats'; +/** + * Language-agnostic orchestrator for topic (message broker) contract + * extraction. All grammar-specific knowledge lives in `topic-patterns/*` + * — this file must not import any tree-sitter grammar directly. + * + * Flow per file: + * 1. `getProviderForFile(rel)` → compiled plugin (or `undefined` if the + * file's extension isn't registered, in which case we skip it). + * 2. `scanFile(parser, provider, content)` → list of `{meta, valueText}` + * pairs, one per matched literal. + * 3. `unquoteLiteral(valueText)` → the raw topic string. + * 4. `makeContract(topic, meta, relPath)` → `ExtractedContract`. + * + * Adding a new language is a one-file edit in `topic-patterns/index.ts`. + */ -function readSafe(repoPath: string, rel: string): string | null { - const abs = path.resolve(repoPath, rel); - const base = path.resolve(repoPath); - const relToBase = path.relative(base, abs); - if (relToBase.startsWith('..') || path.isAbsolute(relToBase)) return null; - try { - return fs.readFileSync(abs, 'utf-8'); - } catch { - return null; - } -} - -function makeContract( - topicName: string, - role: 'provider' | 'consumer', - filePath: string, - symbolName: string, - confidence: number, - broker: Broker, -): ExtractedContract { +function makeContract(topicName: string, meta: TopicMeta, filePath: string): ExtractedContract { return { contractId: `topic::${topicName}`, type: 'topic', - role, + role: meta.role, symbolUid: '', - symbolRef: { filePath: filePath.replace(/\\/g, '/'), name: symbolName }, - symbolName, - confidence, + symbolRef: { filePath: filePath.replace(/\\/g, '/'), name: meta.symbolName }, + symbolName: meta.symbolName, + confidence: meta.confidence, meta: { - broker, + broker: meta.broker satisfies Broker, topicName, - extractionStrategy: 'source_scan', + extractionStrategy: 'tree_sitter', }, }; } -interface PatternDef { - regex: RegExp; - role: 'provider' | 'consumer'; - broker: Broker; - confidence: number; - topicGroup: number; - symbolName: string; -} - -// --- Kafka patterns --- -const KAFKA_PATTERNS: PatternDef[] = [ - // Java: @KafkaListener(topics = "xxx") - { - regex: /@KafkaListener\s*\(\s*topics\s*=\s*"([^"]+)"/g, - role: 'consumer', - broker: 'kafka', - confidence: 0.8, - topicGroup: 1, - symbolName: 'kafkaListener', - }, - // Java: kafkaTemplate.send("xxx" - { - regex: /kafkaTemplate\.send\s*\(\s*"([^"]+)"/gi, - role: 'provider', - broker: 'kafka', - confidence: 0.8, - topicGroup: 1, - symbolName: 'kafkaTemplate.send', - }, - // Node: producer.send({ topic: 'xxx' - { - regex: /producer\.send\s*\(\s*\{\s*topic:\s*['"]([^'"]+)['"]/g, - role: 'provider', - broker: 'kafka', - confidence: 0.8, - topicGroup: 1, - symbolName: 'producer.send', - }, - // Node: consumer.subscribe({ topic: 'xxx' - { - regex: /consumer\.subscribe\s*\(\s*\{\s*topic:\s*['"]([^'"]+)['"]/g, - role: 'consumer', - broker: 'kafka', - confidence: 0.8, - topicGroup: 1, - symbolName: 'consumer.subscribe', - }, - // Go: consumer.ConsumePartition("xxx" - { - regex: /\.ConsumePartition\s*\(\s*"([^"]+)"/g, - role: 'consumer', - broker: 'kafka', - confidence: 0.7, - topicGroup: 1, - symbolName: 'ConsumePartition', - }, - // Python: KafkaConsumer('xxx' - { - regex: /KafkaConsumer\s*\(\s*['"]([^'"]+)['"]/g, - role: 'consumer', - broker: 'kafka', - confidence: 0.7, - topicGroup: 1, - symbolName: 'KafkaConsumer', - }, - // Python: producer.send('xxx' or producer.produce('xxx' - { - regex: /producer\.(?:send|produce)\s*\(\s*['"]([^'"]+)['"]/g, - role: 'provider', - broker: 'kafka', - confidence: 0.7, - topicGroup: 1, - symbolName: 'producer.send', - }, -]; - -// --- RabbitMQ patterns --- -const RABBITMQ_PATTERNS: PatternDef[] = [ - // Java: @RabbitListener(queues = "xxx") - { - regex: /@RabbitListener\s*\(\s*queues\s*=\s*"([^"]+)"/g, - role: 'consumer', - broker: 'rabbitmq', - confidence: 0.8, - topicGroup: 1, - symbolName: 'rabbitListener', - }, - // Java: rabbitTemplate.convertAndSend("xxx" - { - regex: /rabbitTemplate\.convertAndSend\s*\(\s*"([^"]+)"/gi, - role: 'provider', - broker: 'rabbitmq', - confidence: 0.8, - topicGroup: 1, - symbolName: 'rabbitTemplate.convertAndSend', - }, - // Node: channel.consume("xxx" - { - regex: /channel\.consume\s*\(\s*"([^"]+)"/g, - role: 'consumer', - broker: 'rabbitmq', - confidence: 0.8, - topicGroup: 1, - symbolName: 'channel.consume', - }, - // Node: channel.publish("xxx" - { - regex: /channel\.publish\s*\(\s*"([^"]+)"/g, - role: 'provider', - broker: 'rabbitmq', - confidence: 0.8, - topicGroup: 1, - symbolName: 'channel.publish', - }, - // Node: channel.sendToQueue("xxx" - { - regex: /channel\.sendToQueue\s*\(\s*"([^"]+)"/g, - role: 'provider', - broker: 'rabbitmq', - confidence: 0.8, - topicGroup: 1, - symbolName: 'channel.sendToQueue', - }, - // Python: channel.basic_consume(queue='xxx' - { - regex: /channel\.basic_consume\s*\(\s*queue\s*=\s*['"]([^'"]+)['"]/g, - role: 'consumer', - broker: 'rabbitmq', - confidence: 0.7, - topicGroup: 1, - symbolName: 'basic_consume', - }, - // Python: channel.basic_publish(exchange='xxx' - { - regex: /channel\.basic_publish\s*\([^)]*exchange\s*=\s*['"]([^'"]+)['"]/g, - role: 'provider', - broker: 'rabbitmq', - confidence: 0.7, - topicGroup: 1, - symbolName: 'basic_publish', - }, -]; - -// --- NATS patterns --- -const NATS_PATTERNS: PatternDef[] = [ - // Go/Node: nc.Subscribe("xxx" or nc.subscribe("xxx" - { - regex: /nc\.(?:S|s)ubscribe\s*\(\s*"([^"]+)"/g, - role: 'consumer', - broker: 'nats', - confidence: 0.8, - topicGroup: 1, - symbolName: 'nc.Subscribe', - }, - // Go/Node: nc.Publish("xxx" or nc.publish("xxx" - { - regex: /nc\.(?:P|p)ublish\s*\(\s*"([^"]+)"/g, - role: 'provider', - broker: 'nats', - confidence: 0.8, - topicGroup: 1, - symbolName: 'nc.Publish', - }, -]; - -const ALL_PATTERNS: PatternDef[] = [...KAFKA_PATTERNS, ...RABBITMQ_PATTERNS, ...NATS_PATTERNS]; - export class TopicExtractor implements ContractExtractor { type = 'topic' as const; @@ -221,46 +56,48 @@ export class TopicExtractor implements ContractExtractor { repoPath: string, _repo: RepoHandle, ): Promise { - const files = await glob('**/*.{ts,tsx,js,jsx,java,go,py}', { + const files = await glob(TOPIC_SCAN_GLOB, { cwd: repoPath, - ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**', '**/dist/**', '**/build/**'], + ignore: [ + '**/node_modules/**', + '**/.git/**', + '**/vendor/**', + '**/dist/**', + '**/build/**', + // Language-level test file conventions. Go test files + // `*_test.go` live next to source; other languages either use + // separate test directories (Python's `tests/`, Java's + // `src/test/`) or are already covered by the dist/build ignores. + // Pushed to the glob level so the orchestrator stays + // language-agnostic. + '**/*_test.go', + ], nodir: true, }); + // One parser reused across files; the scanner calls `setLanguage` per + // file based on which plugin the registry returns. + const parser = new Parser(); const out: ExtractedContract[] = []; + for (const rel of files) { + const provider = getProviderForFile(rel); + if (!provider) continue; + const content = readSafe(repoPath, rel); if (!content) continue; - out.push(...this.scanFile(content, rel)); - } - return this.dedupe(out); - } - - private scanFile(content: string, filePath: string): ExtractedContract[] { - const out: ExtractedContract[] = []; - - for (const pattern of ALL_PATTERNS) { - // Reset regex state for each file - const re = new RegExp(pattern.regex.source, pattern.regex.flags); - let m: RegExpExecArray | null; - while ((m = re.exec(content)) !== null) { - const topicName = m[pattern.topicGroup]; + const matches = scanFile(parser, provider, content); + for (const match of matches) { + const valueNode = match.captures.value; + if (!valueNode) continue; + const topicName = unquoteLiteral(valueNode.text); if (!topicName) continue; - out.push( - makeContract( - topicName, - pattern.role, - filePath, - pattern.symbolName, - pattern.confidence, - pattern.broker, - ), - ); + out.push(makeContract(topicName, match.meta, rel)); } } - return out; + return this.dedupe(out); } private dedupe(items: ExtractedContract[]): ExtractedContract[] { diff --git a/gitnexus/src/core/group/extractors/topic-patterns/go.ts b/gitnexus/src/core/group/extractors/topic-patterns/go.ts new file mode 100644 index 000000000..df3bab095 --- /dev/null +++ b/gitnexus/src/core/group/extractors/topic-patterns/go.ts @@ -0,0 +1,123 @@ +import Go from 'tree-sitter-go'; +import { compilePatterns, type LanguagePatterns } from '../tree-sitter-scanner.js'; +import type { TopicMeta } from './types.js'; + +/** + * Go topic extraction patterns. + * + * Detects Sarama, segmentio/kafka-go and nats.go producer/consumer APIs: + * - `X.ConsumePartition("topic", ...)` + * - `sarama.ProducerMessage{Topic: "xxx"}` + * - `kafka.Writer{Topic: "xxx"}` / `kafka.WriterConfig{Topic: ...}` + * - `kafka.Reader{Topic: "xxx"}` / `kafka.ReaderConfig{Topic: ...}` + * - `nc.Subscribe("topic", ...)` / `js.Subscribe("topic", ...)` + * - `nc.Publish("topic", ...)` / `js.Publish("topic", ...)` + * + * Every query MUST bind `@value` to the topic literal node. + */ +const GO_TOPIC_SPEC: LanguagePatterns = { + name: 'go-topic', + language: Go, + patterns: [ + { + meta: { + role: 'consumer', + broker: 'kafka', + confidence: 0.7, + symbolName: 'ConsumePartition', + }, + query: ` + (call_expression + function: (selector_expression + field: (field_identifier) @method (#eq? @method "ConsumePartition")) + arguments: (argument_list . (interpreted_string_literal) @value)) + `, + }, + { + meta: { + role: 'provider', + broker: 'kafka', + confidence: 0.75, + symbolName: 'sarama.ProducerMessage', + }, + query: ` + (composite_literal + type: (qualified_type + package: (package_identifier) @pkg (#eq? @pkg "sarama") + name: (type_identifier) @ty (#eq? @ty "ProducerMessage")) + body: (literal_value + (keyed_element + (literal_element (identifier) @field (#eq? @field "Topic")) + (literal_element (interpreted_string_literal) @value)))) + `, + }, + { + meta: { + role: 'provider', + broker: 'kafka', + confidence: 0.75, + symbolName: 'kafka.Writer', + }, + query: ` + (composite_literal + type: (qualified_type + package: (package_identifier) @pkg (#eq? @pkg "kafka") + name: (type_identifier) @ty (#match? @ty "^(Writer|WriterConfig)$")) + body: (literal_value + (keyed_element + (literal_element (identifier) @field (#eq? @field "Topic")) + (literal_element (interpreted_string_literal) @value)))) + `, + }, + { + meta: { + role: 'consumer', + broker: 'kafka', + confidence: 0.75, + symbolName: 'kafka.Reader', + }, + query: ` + (composite_literal + type: (qualified_type + package: (package_identifier) @pkg (#eq? @pkg "kafka") + name: (type_identifier) @ty (#match? @ty "^(Reader|ReaderConfig)$")) + body: (literal_value + (keyed_element + (literal_element (identifier) @field (#eq? @field "Topic")) + (literal_element (interpreted_string_literal) @value)))) + `, + }, + { + meta: { + role: 'consumer', + broker: 'nats', + confidence: 0.8, + symbolName: 'nc.Subscribe', + }, + query: ` + (call_expression + function: (selector_expression + operand: (identifier) @obj (#match? @obj "^(nc|js)$") + field: (field_identifier) @method (#match? @method "^[Ss]ubscribe$")) + arguments: (argument_list . (interpreted_string_literal) @value)) + `, + }, + { + meta: { + role: 'provider', + broker: 'nats', + confidence: 0.8, + symbolName: 'nc.Publish', + }, + query: ` + (call_expression + function: (selector_expression + operand: (identifier) @obj (#match? @obj "^(nc|js)$") + field: (field_identifier) @method (#match? @method "^[Pp]ublish$")) + arguments: (argument_list . (interpreted_string_literal) @value)) + `, + }, + ], +}; + +export const GO_TOPIC_PROVIDER = compilePatterns(GO_TOPIC_SPEC); diff --git a/gitnexus/src/core/group/extractors/topic-patterns/index.ts b/gitnexus/src/core/group/extractors/topic-patterns/index.ts new file mode 100644 index 000000000..b6e1b8c1f --- /dev/null +++ b/gitnexus/src/core/group/extractors/topic-patterns/index.ts @@ -0,0 +1,49 @@ +import * as path from 'node:path'; +import type { CompiledPatterns } from '../tree-sitter-scanner.js'; +import type { TopicMeta } from './types.js'; +import { JAVA_TOPIC_PROVIDER } from './java.js'; +import { GO_TOPIC_PROVIDER } from './go.js'; +import { PYTHON_TOPIC_PROVIDER } from './python.js'; +import { + JAVASCRIPT_TOPIC_PROVIDER, + TYPESCRIPT_TOPIC_PROVIDER, + TSX_TOPIC_PROVIDER, +} from './node.js'; + +export type { TopicMeta, Broker } from './types.js'; + +/** + * File-extension → compiled-plugin registry for topic extraction. The + * top-level orchestrator (`topic-extractor.ts`) looks up the plugin for + * each file it visits and delegates the scanning to `tree-sitter-scanner`. + * + * Keys are lowercase extensions including the leading dot. To add a new + * language, drop a `topic-patterns/.ts` that exports a compiled + * provider, import it here and register the extension(s). No edits to + * `topic-extractor.ts` are required. + */ +const REGISTRY: Record> = { + '.java': JAVA_TOPIC_PROVIDER, + '.go': GO_TOPIC_PROVIDER, + '.py': PYTHON_TOPIC_PROVIDER, + '.js': JAVASCRIPT_TOPIC_PROVIDER, + '.jsx': JAVASCRIPT_TOPIC_PROVIDER, + '.ts': TYPESCRIPT_TOPIC_PROVIDER, + '.tsx': TSX_TOPIC_PROVIDER, +}; + +/** + * Glob pattern for files worth scanning. Kept here so adding a new + * language to the registry also widens the glob automatically via a + * single edit. + */ +export const TOPIC_SCAN_GLOB = '**/*.{ts,tsx,js,jsx,java,go,py}'; + +/** + * Return the compiled provider registered for the given file's + * extension, or `undefined` if the extension is not registered. + */ +export function getProviderForFile(rel: string): CompiledPatterns | undefined { + const ext = path.extname(rel).toLowerCase(); + return REGISTRY[ext]; +} diff --git a/gitnexus/src/core/group/extractors/topic-patterns/java.ts b/gitnexus/src/core/group/extractors/topic-patterns/java.ts new file mode 100644 index 000000000..d126f25ce --- /dev/null +++ b/gitnexus/src/core/group/extractors/topic-patterns/java.ts @@ -0,0 +1,83 @@ +import Java from 'tree-sitter-java'; +import { compilePatterns, type LanguagePatterns } from '../tree-sitter-scanner.js'; +import type { TopicMeta } from './types.js'; + +/** + * Java topic extraction patterns. + * + * Detects Kafka and RabbitMQ (Spring conventions) producer/consumer APIs: + * - `@KafkaListener(topics = "xxx")` + * - `@RabbitListener(queues = "xxx")` + * - `kafkaTemplate.send("xxx", ...)` + * - `rabbitTemplate.convertAndSend("xxx", ...)` + * + * Every query MUST bind `@value` to the topic literal node. + */ +const JAVA_TOPIC_SPEC: LanguagePatterns = { + name: 'java-topic', + language: Java, + patterns: [ + { + meta: { + role: 'consumer', + broker: 'kafka', + confidence: 0.8, + symbolName: 'kafkaListener', + }, + query: ` + (annotation + name: (identifier) @name (#eq? @name "KafkaListener") + arguments: (annotation_argument_list + (element_value_pair + key: (identifier) @key (#eq? @key "topics") + value: (string_literal) @value))) + `, + }, + { + meta: { + role: 'consumer', + broker: 'rabbitmq', + confidence: 0.8, + symbolName: 'rabbitListener', + }, + query: ` + (annotation + name: (identifier) @name (#eq? @name "RabbitListener") + arguments: (annotation_argument_list + (element_value_pair + key: (identifier) @key (#eq? @key "queues") + value: (string_literal) @value))) + `, + }, + { + meta: { + role: 'provider', + broker: 'kafka', + confidence: 0.8, + symbolName: 'kafkaTemplate.send', + }, + query: ` + (method_invocation + object: (identifier) @obj (#eq? @obj "kafkaTemplate") + name: (identifier) @method (#eq? @method "send") + arguments: (argument_list . (string_literal) @value)) + `, + }, + { + meta: { + role: 'provider', + broker: 'rabbitmq', + confidence: 0.8, + symbolName: 'rabbitTemplate.convertAndSend', + }, + query: ` + (method_invocation + object: (identifier) @obj (#eq? @obj "rabbitTemplate") + name: (identifier) @method (#eq? @method "convertAndSend") + arguments: (argument_list . (string_literal) @value)) + `, + }, + ], +}; + +export const JAVA_TOPIC_PROVIDER = compilePatterns(JAVA_TOPIC_SPEC); diff --git a/gitnexus/src/core/group/extractors/topic-patterns/node.ts b/gitnexus/src/core/group/extractors/topic-patterns/node.ts new file mode 100644 index 000000000..68f3a4ef8 --- /dev/null +++ b/gitnexus/src/core/group/extractors/topic-patterns/node.ts @@ -0,0 +1,165 @@ +import JavaScript from 'tree-sitter-javascript'; +import TypeScript from 'tree-sitter-typescript'; +import { + compilePatterns, + type LanguagePatterns, + type PatternSpec, +} from '../tree-sitter-scanner.js'; +import type { TopicMeta } from './types.js'; + +/** + * Node.js / TypeScript topic extraction patterns. + * + * Detects kafkajs, amqplib (RabbitMQ), and nats.js producer/consumer APIs: + * - `producer.send({ topic: 'xxx', ... })` (kafkajs) + * - `consumer.subscribe({ topic: 'xxx', ... })` (kafkajs) + * - `channel.consume("queue", ...)` / `channel.publish(...)` / `channel.sendToQueue(...)` + * - `nc.subscribe("topic")` / `js.subscribe("topic")` + * - `nc.publish("topic", ...)` / `js.publish("topic", ...)` + * + * The JavaScript and TypeScript tree-sitter grammars share node type + * names for every construct we query here, so the pattern sources are + * defined once and compiled against each grammar variant. We export three + * providers because Parser.Query objects are NOT portable across grammar + * instances — `.js` files use the JavaScript grammar, `.ts` uses + * TypeScript.typescript, and `.tsx` uses TypeScript.tsx. + * + * Every query MUST bind `@value` to the topic literal node. + */ +const NODE_TOPIC_PATTERNS: PatternSpec[] = [ + { + meta: { + role: 'provider', + broker: 'kafka', + confidence: 0.8, + symbolName: 'producer.send', + }, + query: ` + (call_expression + function: (member_expression + object: (identifier) @obj (#eq? @obj "producer") + property: (property_identifier) @prop (#eq? @prop "send")) + arguments: (arguments + (object + (pair + key: (property_identifier) @key (#eq? @key "topic") + value: [(string) (template_string)] @value)))) + `, + }, + { + meta: { + role: 'consumer', + broker: 'kafka', + confidence: 0.8, + symbolName: 'consumer.subscribe', + }, + query: ` + (call_expression + function: (member_expression + object: (identifier) @obj (#eq? @obj "consumer") + property: (property_identifier) @prop (#eq? @prop "subscribe")) + arguments: (arguments + (object + (pair + key: (property_identifier) @key (#eq? @key "topic") + value: [(string) (template_string)] @value)))) + `, + }, + { + meta: { + role: 'consumer', + broker: 'rabbitmq', + confidence: 0.8, + symbolName: 'channel.consume', + }, + query: ` + (call_expression + function: (member_expression + object: (identifier) @obj (#eq? @obj "channel") + property: (property_identifier) @prop (#eq? @prop "consume")) + arguments: (arguments . [(string) (template_string)] @value)) + `, + }, + { + meta: { + role: 'provider', + broker: 'rabbitmq', + confidence: 0.8, + symbolName: 'channel.publish', + }, + query: ` + (call_expression + function: (member_expression + object: (identifier) @obj (#eq? @obj "channel") + property: (property_identifier) @prop (#eq? @prop "publish")) + arguments: (arguments . [(string) (template_string)] @value)) + `, + }, + { + meta: { + role: 'provider', + broker: 'rabbitmq', + confidence: 0.8, + symbolName: 'channel.sendToQueue', + }, + query: ` + (call_expression + function: (member_expression + object: (identifier) @obj (#eq? @obj "channel") + property: (property_identifier) @prop (#eq? @prop "sendToQueue")) + arguments: (arguments . [(string) (template_string)] @value)) + `, + }, + { + meta: { + role: 'consumer', + broker: 'nats', + confidence: 0.8, + symbolName: 'nc.subscribe', + }, + query: ` + (call_expression + function: (member_expression + object: (identifier) @obj (#match? @obj "^(nc|js)$") + property: (property_identifier) @prop (#match? @prop "^[Ss]ubscribe$")) + arguments: (arguments . [(string) (template_string)] @value)) + `, + }, + { + meta: { + role: 'provider', + broker: 'nats', + confidence: 0.8, + symbolName: 'nc.publish', + }, + query: ` + (call_expression + function: (member_expression + object: (identifier) @obj (#match? @obj "^(nc|js)$") + property: (property_identifier) @prop (#match? @prop "^[Pp]ublish$")) + arguments: (arguments . [(string) (template_string)] @value)) + `, + }, +]; + +const JAVASCRIPT_TOPIC_SPEC: LanguagePatterns = { + name: 'javascript-topic', + language: JavaScript, + patterns: NODE_TOPIC_PATTERNS, +}; + +const TYPESCRIPT_TOPIC_SPEC: LanguagePatterns = { + name: 'typescript-topic', + language: TypeScript.typescript, + patterns: NODE_TOPIC_PATTERNS, +}; + +const TSX_TOPIC_SPEC: LanguagePatterns = { + name: 'tsx-topic', + language: TypeScript.tsx, + patterns: NODE_TOPIC_PATTERNS, +}; + +export const JAVASCRIPT_TOPIC_PROVIDER = compilePatterns(JAVASCRIPT_TOPIC_SPEC); +export const TYPESCRIPT_TOPIC_PROVIDER = compilePatterns(TYPESCRIPT_TOPIC_SPEC); +export const TSX_TOPIC_PROVIDER = compilePatterns(TSX_TOPIC_SPEC); diff --git a/gitnexus/src/core/group/extractors/topic-patterns/python.ts b/gitnexus/src/core/group/extractors/topic-patterns/python.ts new file mode 100644 index 000000000..d84cae999 --- /dev/null +++ b/gitnexus/src/core/group/extractors/topic-patterns/python.ts @@ -0,0 +1,119 @@ +import Python from 'tree-sitter-python'; +import { compilePatterns, type LanguagePatterns } from '../tree-sitter-scanner.js'; +import type { TopicMeta } from './types.js'; + +/** + * Python topic extraction patterns. + * + * Detects kafka-python, pika (RabbitMQ), and nats-py producer/consumer APIs: + * - `KafkaConsumer('topic', ...)` + * - `producer.send('topic', ...)` / `producer.produce('topic', ...)` + * - `channel.basic_consume(queue='xxx', ...)` + * - `channel.basic_publish(exchange='xxx', ...)` + * - `await nc.subscribe('topic')` + * - `await nc.publish('topic', ...)` + * + * Every query MUST bind `@value` to the topic literal node. + */ +const PYTHON_TOPIC_SPEC: LanguagePatterns = { + name: 'python-topic', + language: Python, + patterns: [ + { + meta: { + role: 'consumer', + broker: 'kafka', + confidence: 0.7, + symbolName: 'KafkaConsumer', + }, + query: ` + (call + function: (identifier) @func (#eq? @func "KafkaConsumer") + arguments: (argument_list . (string) @value)) + `, + }, + { + meta: { + role: 'provider', + broker: 'kafka', + confidence: 0.7, + symbolName: 'producer.send', + }, + query: ` + (call + function: (attribute + object: (identifier) @obj (#eq? @obj "producer") + attribute: (identifier) @method (#match? @method "^(send|produce)$")) + arguments: (argument_list . (string) @value)) + `, + }, + { + meta: { + role: 'consumer', + broker: 'rabbitmq', + confidence: 0.7, + symbolName: 'basic_consume', + }, + query: ` + (call + function: (attribute + object: (identifier) @obj (#eq? @obj "channel") + attribute: (identifier) @method (#eq? @method "basic_consume")) + arguments: (argument_list + (keyword_argument + name: (identifier) @kw (#eq? @kw "queue") + value: (string) @value))) + `, + }, + { + meta: { + role: 'provider', + broker: 'rabbitmq', + confidence: 0.7, + symbolName: 'basic_publish', + }, + query: ` + (call + function: (attribute + object: (identifier) @obj (#eq? @obj "channel") + attribute: (identifier) @method (#eq? @method "basic_publish")) + arguments: (argument_list + (keyword_argument + name: (identifier) @kw (#eq? @kw "exchange") + value: (string) @value))) + `, + }, + { + meta: { + role: 'consumer', + broker: 'nats', + confidence: 0.75, + symbolName: 'nc.subscribe', + }, + query: ` + (call + function: (attribute + object: (identifier) @obj (#eq? @obj "nc") + attribute: (identifier) @method (#eq? @method "subscribe")) + arguments: (argument_list . (string) @value)) + `, + }, + { + meta: { + role: 'provider', + broker: 'nats', + confidence: 0.75, + symbolName: 'nc.publish', + }, + query: ` + (call + function: (attribute + object: (identifier) @obj (#eq? @obj "nc") + attribute: (identifier) @method (#eq? @method "publish")) + arguments: (argument_list . (string) @value)) + `, + }, + ], +}; + +export const PYTHON_TOPIC_PROVIDER = compilePatterns(PYTHON_TOPIC_SPEC); diff --git a/gitnexus/src/core/group/extractors/topic-patterns/types.ts b/gitnexus/src/core/group/extractors/topic-patterns/types.ts new file mode 100644 index 000000000..3a27f21d3 --- /dev/null +++ b/gitnexus/src/core/group/extractors/topic-patterns/types.ts @@ -0,0 +1,27 @@ +/** + * Shared types for the topic-extractor language plugins. + * + * Each plugin lives in its own file (java.ts, go.ts, ...) and owns the + * tree-sitter grammar import + query sources. The top-level + * `topic-extractor.ts` orchestrator only knows about this type module and + * the plugin registry (`./index.ts`). It MUST NOT import any grammar or + * query text directly — that's the whole point of the split. + */ + +export type Broker = 'kafka' | 'rabbitmq' | 'nats'; + +/** + * Per-pattern payload every topic plugin attaches to its query. Whatever + * the pattern matches, the orchestrator receives this object verbatim + * and uses it to build an `ExtractedContract`. + * + * Plugins produce one `TopicMeta` per pattern (not per match) because a + * single query uniquely identifies its broker/role/confidence triple. + */ +export interface TopicMeta { + role: 'provider' | 'consumer'; + broker: Broker; + confidence: number; + /** Short human-readable label of the API being detected. */ + symbolName: string; +} diff --git a/gitnexus/src/core/group/extractors/tree-sitter-scanner.ts b/gitnexus/src/core/group/extractors/tree-sitter-scanner.ts new file mode 100644 index 000000000..cd50456aa --- /dev/null +++ b/gitnexus/src/core/group/extractors/tree-sitter-scanner.ts @@ -0,0 +1,193 @@ +import Parser from 'tree-sitter'; + +/** + * Shared, language-agnostic tree-sitter scanning utilities used by group + * extractors (topic, http, grpc, ...). + * + * Design goals: + * - The top-level extractors must not import any tree-sitter grammar. + * - Per-language plugins own their grammar import, their query sources, + * and the mapping from capture → meta. + * - This module provides the plumbing: compile queries once per plugin, + * parse a file with a given grammar, run all patterns, and return the + * captured `string_literal`-style nodes together with the plugin's meta. + */ + +/** + * One pattern owned by a language plugin. Each pattern owns a tree-sitter + * S-expression query. Plugins can freely choose which capture names to + * use — the scanner exposes every capture in the returned `captures` + * map and does not privilege any particular name. + * + * `TMeta` is the plugin-specific payload the orchestrator receives back + * when this pattern matches — e.g. for topic extraction it carries the + * broker name, role, confidence, symbol name. + */ +export interface PatternSpec { + /** Tree-sitter S-expression. */ + query: string; + /** Plugin-specific payload returned on every match. */ + meta: TMeta; +} + +/** + * A set of patterns owned by one language plugin, bound to a specific + * tree-sitter grammar. + * + * `language` is typed as `unknown` because tree-sitter's TypeScript + * declarations use `any` for the grammar object, and the grammar modules + * export different shapes (plain grammar vs. namespace with `typescript` + * / `tsx` members). Callers pass the concrete grammar object; this + * module forwards it to `parser.setLanguage` / `new Parser.Query`. + */ +export interface LanguagePatterns { + /** Human-readable plugin name for diagnostics. */ + name: string; + /** tree-sitter grammar object. */ + language: unknown; + /** Patterns authored against `language`. */ + patterns: PatternSpec[]; +} + +/** + * Compiled form of a `LanguagePatterns` bundle. Queries are compiled + * eagerly at module load time so a broken grammar/query pair fails + * loudly the first time the plugin is imported, instead of silently + * at scan time when no contract is produced. + */ +export interface CompiledPatterns { + name: string; + language: unknown; + patterns: CompiledPattern[]; +} + +export interface CompiledPattern { + query: Parser.Query; + meta: TMeta; +} + +/** + * Map from capture name → syntax node. Every named capture the query + * binds is exposed as an entry. If a query captures the same name more + * than once (unusual), the first occurrence wins — plugins that need + * all occurrences should use distinct capture names or fall back to + * `match.captures` array directly by iterating `query.matches()` + * themselves. + */ +export type CaptureMap = Record; + +/** + * One match returned by `scanFile` / `runCompiledPatterns`. The caller + * receives the full capture map plus the plugin meta, and is + * responsible for turning it into a domain object. + */ +export interface ScanMatch { + meta: TMeta; + captures: CaptureMap; +} + +/** + * Compile a LanguagePatterns bundle. Call this once per plugin, at + * module load time, and export the result. Throws if any pattern + * fails to compile against the grammar — that's a bug in the plugin + * author's query, not a runtime condition. + */ +export function compilePatterns(bundle: LanguagePatterns): CompiledPatterns { + const compiled: CompiledPattern[] = []; + for (const spec of bundle.patterns) { + try { + const query = new Parser.Query(bundle.language, spec.query); + compiled.push({ query, meta: spec.meta }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error( + `[tree-sitter-scanner] Failed to compile pattern in ${bundle.name}: ${message}\n` + + `Query source:\n${spec.query}`, + ); + } + } + return { name: bundle.name, language: bundle.language, patterns: compiled }; +} + +/** + * Run every compiled pattern in `plugin` against an already-parsed + * tree. Use this when a plugin needs multiple query bundles against + * the same file (e.g. one query for class-level prefixes and another + * for method-level annotations) and wants to avoid re-parsing. + */ +export function runCompiledPatterns( + plugin: CompiledPatterns, + tree: Parser.Tree, +): ScanMatch[] { + const out: ScanMatch[] = []; + for (const compiled of plugin.patterns) { + let matches: Parser.QueryMatch[]; + try { + matches = compiled.query.matches(tree.rootNode); + } catch { + continue; + } + for (const match of matches) { + const captures: CaptureMap = {}; + for (const cap of match.captures) { + if (!(cap.name in captures)) captures[cap.name] = cap.node; + } + out.push({ meta: compiled.meta, captures }); + } + } + return out; +} + +/** + * Parse `content` with the plugin's grammar and run every compiled + * pattern against the AST. Returns one `ScanMatch` per matched query + * occurrence, carrying the plugin's meta payload. + * + * Errors are swallowed at the file level (malformed file must not abort + * the whole extract). Individual pattern failures are swallowed too so + * a single unusable query doesn't block the rest of the plugin. + */ +export function scanFile( + parser: Parser, + plugin: CompiledPatterns, + content: string, +): ScanMatch[] { + let tree: Parser.Tree; + try { + parser.setLanguage(plugin.language); + tree = parser.parse(content); + } catch { + return []; + } + return runCompiledPatterns(plugin, tree); +} + +/** + * Strip enclosing quotes from a tree-sitter string literal node's text. + * Handles single / double / template quotes, Python triple-quoted strings, + * and Go raw string literals (backticks). + * + * Returns null for empty/nullish input so callers can uniformly skip + * captures whose value is missing. + */ +export function unquoteLiteral(raw: string): string | null { + if (!raw) return null; + + // Python triple-quoted + if ( + (raw.startsWith('"""') && raw.endsWith('"""')) || + (raw.startsWith("'''") && raw.endsWith("'''")) + ) { + return raw.slice(3, -3); + } + + const first = raw[0]; + const last = raw[raw.length - 1]; + if ((first === '"' || first === "'" || first === '`') && last === first && raw.length >= 2) { + return raw.slice(1, -1); + } + + // Some grammars expose the string content without quotes already (e.g. + // Python `string_content` child). Return as-is. + return raw; +} diff --git a/gitnexus/test/unit/group/grpc-extractor.test.ts b/gitnexus/test/unit/group/grpc-extractor.test.ts index b4fc63b5c..82d79cbd6 100644 --- a/gitnexus/test/unit/group/grpc-extractor.test.ts +++ b/gitnexus/test/unit/group/grpc-extractor.test.ts @@ -1,17 +1,23 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; +import fsp from 'node:fs/promises'; import * as path from 'node:path'; import * as os from 'node:os'; -import { GrpcExtractor } from '../../../src/core/group/extractors/grpc-extractor.js'; +import { + GrpcExtractor, + buildProtoMap, + resolveProtoConflict, + serviceContractId, +} from '../../../src/core/group/extractors/grpc-extractor.js'; +import type { ProtoServiceInfo } from '../../../src/core/group/extractors/grpc-extractor.js'; import type { RepoHandle } from '../../../src/core/group/types.js'; describe('GrpcExtractor', () => { let tmpDir: string; let extractor: GrpcExtractor; - beforeEach(() => { - tmpDir = path.join(os.tmpdir(), `gitnexus-grpc-${Date.now()}`); - fs.mkdirSync(tmpDir, { recursive: true }); + beforeEach(async () => { + tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'gitnexus-grpc-')); extractor = new GrpcExtractor(); }); @@ -205,6 +211,66 @@ service IncompleteService { // The old regex would find partial match; the new parser should skip it expect(providers).toHaveLength(0); }); + + it('test_extract_proto_ignores_braces_inside_string_literals', async () => { + // Regression for a known parser limitation: braces inside string + // literals used to be counted as real service-body braces, which + // would terminate the service early and drop methods after the + // offending string. + writeFile( + 'api/strings.proto', + `syntax = "proto3"; +package strings; + +service TrickyService { + rpc First (Req) returns (Res) { + option (google.api.http).additional_bindings = { + post: "/v1/first"; + }; + } + // Previously the "{" inside this literal would close the service body. + option deprecated_reason = "use NewService { instead"; + rpc Second (Req) returns (Res); + rpc Third (Req) returns (Res); +} +`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const protoProviders = contracts.filter( + (c) => c.role === 'provider' && c.symbolRef.filePath === 'api/strings.proto', + ); + // All three methods must be extracted even though a string literal + // contains an unbalanced "{". + expect(protoProviders.map((c) => c.symbolName).sort()).toEqual([ + 'TrickyService.First', + 'TrickyService.Second', + 'TrickyService.Third', + ]); + }); + + it('test_extract_proto_ignores_braces_inside_comments', async () => { + writeFile( + 'api/commented.proto', + `syntax = "proto3"; +package commented; + +service Svc { + // TODO: move { or } from this comment — parser used to count them + /* A block comment with { unbalanced braces } */ + rpc Alpha (Req) returns (Res); + // }} end of the method block (in comment) + rpc Beta (Req) returns (Res); +} +`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const protoProviders = contracts.filter( + (c) => c.role === 'provider' && c.symbolRef.filePath === 'api/commented.proto', + ); + expect(protoProviders.map((c) => c.symbolName).sort()).toEqual(['Svc.Alpha', 'Svc.Beta']); + }); }); describe('Go server detection', () => { @@ -228,7 +294,7 @@ func main() { expect(providers.length).toBeGreaterThanOrEqual(1); expect(providers[0].contractId).toContain('grpc::'); expect(providers[0].contractId).toContain('AuthService'); - expect(providers[0].confidence).toBe(0.8); + expect(providers[0].confidence).toBe(0.65); }); it('test_extract_go_unimplemented_server_returns_provider', async () => { @@ -267,7 +333,7 @@ func NewAuthClient(conn *grpc.ClientConn) pb.AuthServiceClient { expect(consumers.length).toBeGreaterThanOrEqual(1); expect(consumers[0].contractId).toContain('AuthService'); - expect(consumers[0].confidence).toBe(0.7); + expect(consumers[0].confidence).toBe(0.55); }); }); @@ -287,7 +353,7 @@ public class AuthGrpcService extends AuthServiceGrpc.AuthServiceImplBase { expect(providers.length).toBeGreaterThanOrEqual(1); expect(providers[0].contractId).toContain('AuthService'); - expect(providers[0].confidence).toBe(0.8); + expect(providers[0].confidence).toBe(0.65); }); it('test_extract_java_blocking_stub_returns_consumer', async () => { @@ -306,7 +372,7 @@ public class AuthGrpcService extends AuthServiceGrpc.AuthServiceImplBase { expect(consumers.length).toBeGreaterThanOrEqual(1); expect(consumers[0].contractId).toContain('AuthService'); - expect(consumers[0].confidence).toBe(0.7); + expect(consumers[0].confidence).toBe(0.55); }); }); @@ -328,7 +394,7 @@ def serve(): expect(providers.length).toBeGreaterThanOrEqual(1); expect(providers[0].contractId).toContain('AuthService'); - expect(providers[0].confidence).toBe(0.8); + expect(providers[0].confidence).toBe(0.65); }); it('test_extract_python_stub_returns_consumer', async () => { @@ -346,7 +412,7 @@ stub = auth_pb2_grpc.AuthServiceStub(channel)`, expect(consumers.length).toBeGreaterThanOrEqual(1); expect(consumers[0].contractId).toContain('AuthService'); - expect(consumers[0].confidence).toBe(0.7); + expect(consumers[0].confidence).toBe(0.55); }); }); @@ -372,6 +438,165 @@ export class AuthController { expect(providers[0].contractId).toContain('Login'); expect(providers[0].confidence).toBe(0.8); }); + + it('test_extract_ts_grpc_client_decorator_returns_consumer', async () => { + writeFile( + 'proto/auth.proto', + `syntax = "proto3"; +package auth.v1; +service AuthService { + rpc Login (LoginRequest) returns (LoginResponse); +}`, + ); + writeFile( + 'src/auth.client.ts', + `import { GrpcClient } from '@nestjs/microservices'; +import type { AuthServiceClient } from './generated/auth'; + +export class AuthGateway { + @GrpcClient({ package: 'auth.v1', protoPath: 'proto/auth.proto' }) + private readonly authClient!: AuthServiceClient; +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(1); + expect(consumers[0].contractId).toBe('grpc::auth.v1.AuthService/*'); + }); + + it('test_extract_ts_getService_without_decorator_returns_consumer', async () => { + writeFile( + 'proto/auth.proto', + `syntax = "proto3"; +package auth.v1; +service AuthService { + rpc Login (LoginRequest) returns (LoginResponse); +}`, + ); + writeFile( + 'src/auth.client.ts', + `import type { ClientGrpc } from '@nestjs/microservices'; + +export function createAuthClient(client: ClientGrpc) { + return client.getService('AuthService'); +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(1); + expect(consumers[0].contractId).toBe('grpc::auth.v1.AuthService/*'); + }); + + it('test_extract_ts_generated_client_constructor_returns_consumer', async () => { + writeFile( + 'proto/auth.proto', + `syntax = "proto3"; +package auth.v1; +service AuthService { + rpc Login (LoginRequest) returns (LoginResponse); +}`, + ); + writeFile( + 'src/auth.client.ts', + `import { credentials } from '@grpc/grpc-js'; +import { AuthServiceClient } from './generated/auth'; + +export const authClient = new AuthServiceClient('localhost:50051', credentials.createInsecure());`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(1); + expect(consumers[0].contractId).toBe('grpc::auth.v1.AuthService/*'); + }); + + it('test_extract_ts_non_service_client_constructor_is_ignored', async () => { + writeFile( + 'proto/auth.proto', + `syntax = "proto3"; +package auth.v1; +service AuthService { + rpc Login (LoginRequest) returns (LoginResponse); +}`, + ); + writeFile( + 'src/auth.client.ts', + `import { AuthClient } from './generated/auth'; + +export const authClient = new AuthClient('localhost:50051');`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(0); + }); + + it('test_extract_ts_loadPackageDefinition_constructor_returns_consumer', async () => { + writeFile( + 'proto/auth.proto', + `syntax = "proto3"; +package auth.v1; +service AuthService { + rpc Login (LoginRequest) returns (LoginResponse); +}`, + ); + writeFile( + 'src/auth.client.ts', + `import * as grpc from '@grpc/grpc-js'; +import * as protoLoader from '@grpc/proto-loader'; + +const definition = protoLoader.loadSync('proto/auth.proto'); +const authProto = grpc.loadPackageDefinition(definition) as any; +export const authClient = new authProto.auth.v1.AuthService( + 'localhost:50051', + grpc.credentials.createInsecure(), +);`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(1); + expect(consumers[0].contractId).toBe('grpc::auth.v1.AuthService/*'); + }); + + it('test_extract_ts_duplicate_consumer_patterns_in_one_file_dedupes_deterministically', async () => { + writeFile( + 'proto/auth.proto', + `syntax = "proto3"; +package auth.v1; +service AuthService { + rpc Login (LoginRequest) returns (LoginResponse); +}`, + ); + writeFile( + 'src/auth.client.ts', + `import * as grpc from '@grpc/grpc-js'; +import type { ClientGrpc } from '@nestjs/microservices'; +import { AuthServiceClient } from './generated/auth'; + +export class AuthGateway { + constructor(private readonly client: ClientGrpc) {} + + connect() { + this.client.getService('AuthService'); + return new AuthServiceClient('localhost:50051', grpc.credentials.createInsecure()); + } +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(1); + expect(consumers[0].contractId).toBe('grpc::auth.v1.AuthService/*'); + }); }); describe('edge cases', () => { @@ -389,3 +614,297 @@ export class AuthController { }); }); }); + +describe('buildProtoMap', () => { + let tmpDir: string; + beforeEach(async () => { + tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'proto-test-')); + }); + afterEach(async () => { + await fsp.rm(tmpDir, { recursive: true, force: true }); + }); + + it('test_buildProtoMap_single_proto_parses_package_service_methods', async () => { + const protoContent = ` +syntax = "proto3"; +package com.example; + +service UserService { + rpc GetUser (GetUserRequest) returns (GetUserResponse); + rpc ListUsers (ListUsersRequest) returns (ListUsersResponse); +}`; + await fsp.mkdir(path.join(tmpDir, 'proto'), { recursive: true }); + await fsp.writeFile(path.join(tmpDir, 'proto', 'user.proto'), protoContent); + + const map = await buildProtoMap(tmpDir); + expect(map.has('UserService')).toBe(true); + const entries = map.get('UserService')!; + expect(entries).toHaveLength(1); + expect(entries[0].package).toBe('com.example'); + expect(entries[0].serviceName).toBe('UserService'); + expect(entries[0].methods).toEqual(['GetUser', 'ListUsers']); + expect(entries[0].protoPath).toBe('proto/user.proto'); + }); + + it('test_buildProtoMap_no_package_declaration', async () => { + const protoContent = ` +syntax = "proto3"; +service Foo { rpc Bar (Req) returns (Res); }`; + await fsp.writeFile(path.join(tmpDir, 'foo.proto'), protoContent); + + const map = await buildProtoMap(tmpDir); + const entries = map.get('Foo')!; + expect(entries[0].package).toBe(''); + }); + + it('test_buildProtoMap_no_protos_returns_empty', async () => { + const map = await buildProtoMap(tmpDir); + expect(map.size).toBe(0); + }); + + it('test_buildProtoMap_conflicting_names', async () => { + await fsp.mkdir(path.join(tmpDir, 'a'), { recursive: true }); + await fsp.mkdir(path.join(tmpDir, 'b'), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, 'a', 'svc.proto'), + 'package pkg.a;\nservice Svc { rpc Do (R) returns (R); }', + ); + await fsp.writeFile( + path.join(tmpDir, 'b', 'svc.proto'), + 'package pkg.b;\nservice Svc { rpc Do (R) returns (R); }', + ); + + const map = await buildProtoMap(tmpDir); + expect(map.get('Svc')).toHaveLength(2); + }); + + it('test_buildProtoMap_imported_package_is_inherited_for_split_service_definition', async () => { + await fsp.mkdir(path.join(tmpDir, 'proto', 'shared'), { recursive: true }); + await fsp.mkdir(path.join(tmpDir, 'proto', 'services'), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, 'proto', 'shared', 'package.proto'), + 'package auth.v1;\nmessage LoginRequest {}', + ); + await fsp.writeFile( + path.join(tmpDir, 'proto', 'services', 'auth.proto'), + 'import "../shared/package.proto";\nservice AuthService { rpc Login (LoginRequest) returns (LoginRequest); }', + ); + + const map = await buildProtoMap(tmpDir); + const entries = map.get('AuthService')!; + + expect(entries).toHaveLength(1); + expect(entries[0].package).toBe('auth.v1'); + }); +}); + +describe('resolveProtoConflict', () => { + const makeInfo = (pkg: string, protoPath: string): ProtoServiceInfo => ({ + package: pkg, + serviceName: 'Svc', + methods: ['Do'], + protoPath, + }); + + it('test_single_candidate_returns_it', () => { + const result = resolveProtoConflict('Svc', 'src/main.go', [makeInfo('pkg', 'proto/svc.proto')]); + expect(result?.package).toBe('pkg'); + }); + + it('test_multiple_candidates_picks_closest_directory', () => { + const candidates = [ + makeInfo('far', 'other/dir/svc.proto'), + makeInfo('close', 'src/proto/svc.proto'), + ]; + const result = resolveProtoConflict('Svc', 'src/server.go', candidates); + expect(result?.package).toBe('close'); + }); + + it('test_centralized_proto_layout_prefers_shared_path_segments_over_prefix_only', () => { + const candidates = [ + makeInfo('billing', 'proto/services/billing/svc.proto'), + makeInfo('auth', 'proto/services/auth/svc.proto'), + ]; + const result = resolveProtoConflict('Svc', 'services/auth/src/server.ts', candidates); + expect(result?.package).toBe('auth'); + }); + + it('test_no_candidates_returns_null', () => { + expect(resolveProtoConflict('Svc', 'src/main.go', [])).toBeNull(); + }); +}); + +describe('serviceContractId', () => { + it('test_with_package', () => { + expect(serviceContractId('com.example', 'UserService')).toBe('grpc::com.example.UserService/*'); + }); + + it('test_without_package', () => { + expect(serviceContractId('', 'UserService')).toBe('grpc::UserService/*'); + }); +}); + +describe('proto-aware source scanners', () => { + let tmpDir: string; + let extractor: GrpcExtractor; + + beforeEach(async () => { + tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'scanner-test-')); + extractor = new GrpcExtractor(); + }); + afterEach(async () => { + await fsp.rm(tmpDir, { recursive: true, force: true }); + }); + + const makeRepo = (repoPath: string): RepoHandle => ({ + id: 'test-repo', + path: '', + repoPath, + storagePath: '', + }); + + it('test_go_provider_with_proto_uses_canonical_service_id', async () => { + await fsp.mkdir(path.join(tmpDir, 'proto'), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, 'proto', 'user.proto'), + 'package com.example;\nservice UserService { rpc GetUser (R) returns (R); }', + ); + await fsp.mkdir(path.join(tmpDir, 'src'), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, 'src', 'server.go'), + 'package main\nfunc init() { pb.RegisterUserServiceServer(srv, &impl{}) }', + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + + const goProvider = contracts.find((c) => c.meta.source === 'go_register'); + expect(goProvider).toBeDefined(); + expect(goProvider!.contractId).toBe('grpc::com.example.UserService/*'); + expect(goProvider!.confidence).toBe(0.8); + }); + + it('test_go_provider_without_proto_reduced_confidence', async () => { + await fsp.mkdir(path.join(tmpDir, 'src'), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, 'src', 'server.go'), + 'package main\nfunc init() { pb.RegisterFooServer(srv, &impl{}) }', + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + + const goProvider = contracts.find((c) => c.meta.source === 'go_register'); + expect(goProvider).toBeDefined(); + expect(goProvider!.contractId).toBe('grpc::Foo/*'); + expect(goProvider!.confidence).toBe(0.65); + }); + + it('test_go_consumer_with_proto_uses_canonical_service_id', async () => { + await fsp.mkdir(path.join(tmpDir, 'proto'), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, 'proto', 'user.proto'), + 'package com.example;\nservice UserService { rpc GetUser (R) returns (R); }', + ); + await fsp.mkdir(path.join(tmpDir, 'src'), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, 'src', 'client.go'), + 'package main\nfunc init() { client := pb.NewUserServiceClient(conn) }', + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + + const goConsumer = contracts.find((c) => c.meta.source === 'go_client'); + expect(goConsumer).toBeDefined(); + expect(goConsumer!.contractId).toBe('grpc::com.example.UserService/*'); + expect(goConsumer!.confidence).toBe(0.75); + }); + + it('test_java_provider_with_proto_uses_canonical_service_id', async () => { + await fsp.mkdir(path.join(tmpDir, 'proto'), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, 'proto', 'user.proto'), + 'package com.example;\nservice UserService { rpc GetUser (R) returns (R); }', + ); + await fsp.mkdir(path.join(tmpDir, 'src', 'main', 'java'), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, 'src', 'main', 'java', 'UserGrpcService.java'), + `@GrpcService +public class UserGrpcService extends UserServiceGrpc.UserServiceImplBase { + @Override + public void getUser(GetUserRequest req, StreamObserver obs) {} +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + + const javaProvider = contracts.find((c) => c.meta.source === 'java_grpc_service'); + expect(javaProvider).toBeDefined(); + expect(javaProvider!.contractId).toBe('grpc::com.example.UserService/*'); + expect(javaProvider!.confidence).toBe(0.8); + }); + + it('test_python_consumer_with_proto_uses_canonical_service_id', async () => { + await fsp.mkdir(path.join(tmpDir, 'proto'), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, 'proto', 'user.proto'), + 'package com.example;\nservice UserService { rpc GetUser (R) returns (R); }', + ); + await fsp.writeFile( + path.join(tmpDir, 'client.py'), + `import grpc +channel = grpc.insecure_channel('localhost:50051') +stub = UserServiceStub(channel)`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + + const pyConsumer = contracts.find((c) => c.meta.source === 'python_stub'); + expect(pyConsumer).toBeDefined(); + expect(pyConsumer!.contractId).toBe('grpc::com.example.UserService/*'); + expect(pyConsumer!.confidence).toBe(0.75); + }); + + it('test_ts_provider_with_proto_adds_package', async () => { + await fsp.mkdir(path.join(tmpDir, 'proto'), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, 'proto', 'user.proto'), + 'package com.example;\nservice UserService { rpc GetUser (R) returns (R); }', + ); + await fsp.mkdir(path.join(tmpDir, 'src'), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, 'src', 'controller.ts'), + "@GrpcMethod('UserService', 'GetUser')\nasync getUser() {}", + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + + const tsProvider = contracts.find((c) => c.meta.source === 'ts_grpc_method'); + expect(tsProvider).toBeDefined(); + expect(tsProvider!.contractId).toBe('grpc::com.example.UserService/GetUser'); + expect(tsProvider!.confidence).toBe(0.8); + }); + + it('test_proto_provider_inherits_package_from_imported_definition', async () => { + await fsp.mkdir(path.join(tmpDir, 'proto', 'shared'), { recursive: true }); + await fsp.mkdir(path.join(tmpDir, 'proto', 'services'), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, 'proto', 'shared', 'package.proto'), + 'package auth.v1;\nmessage LoginRequest {}', + ); + await fsp.writeFile( + path.join(tmpDir, 'proto', 'services', 'auth.proto'), + `syntax = "proto3"; +import "../shared/package.proto"; +service AuthService { + rpc Login (LoginRequest) returns (LoginRequest); +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + + const protoProvider = contracts.find( + (c) => c.symbolRef.filePath === 'proto/services/auth.proto', + ); + expect(protoProvider).toBeDefined(); + expect(protoProvider!.contractId).toBe('grpc::auth.v1.AuthService/Login'); + }); +}); diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index d4c0db3eb..653b4952c 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -157,6 +157,89 @@ export default router; providers.find((c) => c.contractId === 'http::DELETE::/api/users/{param}'), ).toBeDefined(); }); + + it('extracts Go Gin and Echo route registrations', async () => { + const dir = path.join(tmpDir, 'go-frameworks'); + fs.mkdirSync(path.join(dir, 'cmd'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'cmd', 'server.go'), + ` +package main + +func createOrder(c *gin.Context) {} +func listOrders(c echo.Context) error { return nil } + +func main() { + r := gin.Default() + r.POST("/api/orders/:id", createOrder) + + e := echo.New() + e.GET("/api/orders", listOrders) +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + const ginRoute = providers.find((c) => c.contractId === 'http::POST::/api/orders/{param}'); + expect(ginRoute).toBeDefined(); + expect(ginRoute?.symbolName).toBe('createOrder'); + + const echoRoute = providers.find((c) => c.contractId === 'http::GET::/api/orders'); + expect(echoRoute).toBeDefined(); + expect(echoRoute?.symbolName).toBe('listOrders'); + }); + + it('extracts stdlib HandleFunc providers', async () => { + const dir = path.join(tmpDir, 'go-stdlib-provider'); + fs.mkdirSync(path.join(dir, 'cmd'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'cmd', 'server.go'), + ` +package main + +func healthHandler(w http.ResponseWriter, r *http.Request) {} + +func main() { + http.HandleFunc("/api/health", healthHandler) +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + const healthRoute = providers.find((c) => c.contractId === 'http::GET::/api/health'); + expect(healthRoute).toBeDefined(); + expect(healthRoute?.symbolName).toBe('healthHandler'); + }); + + it('extracts NestJS controller decorators', async () => { + const dir = path.join(tmpDir, 'nestjs'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'orders.controller.ts'), + ` +import { Controller, Patch } from '@nestjs/common'; + +@Controller('orders') +export class OrdersController { + @Patch(':id') + updateOrder() { + return {}; + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + const patchRoute = providers.find((c) => c.contractId === 'http::PATCH::/orders/{param}'); + expect(patchRoute).toBeDefined(); + expect(patchRoute?.symbolName).toBe('updateOrder'); + }); }); describe('consumer extraction — fetch patterns', () => { @@ -206,6 +289,91 @@ export const deleteUser = (id: string) => axios.delete(\`/api/users/\${id}\`); consumers.find((c) => c.contractId === 'http::DELETE::/api/users/{param}'), ).toBeDefined(); }); + + it('extracts Python requests calls', async () => { + const dir = path.join(tmpDir, 'python-consumer'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'client.py'), + ` +import requests + +def create_order(): + return requests.post("https://svc.local/api/orders/42", json={"id": 42}) +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect( + consumers.find((c) => c.contractId === 'http::POST::/api/orders/{param}'), + ).toBeDefined(); + }); + + it('extracts Java RestTemplate, WebClient and OkHttp calls', async () => { + const dir = path.join(tmpDir, 'java-consumer'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'ApiClient.java'), + ` +import org.springframework.http.HttpMethod; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.reactive.function.client.WebClient; +import okhttp3.Request; + +class ApiClient { + void run(RestTemplate restTemplate, WebClient webClient) { + restTemplate.getForObject("/api/users/{id}", String.class, 42); + webClient.method(HttpMethod.PATCH, "/api/users/42"); + new Request.Builder().url("/api/orders/42").build(); + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers.find((c) => c.contractId === 'http::GET::/api/users/{param}')).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::PATCH::/api/users/{param}'), + ).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::GET::/api/orders/{param}'), + ).toBeDefined(); + }); + + it('extracts Go stdlib and resty calls', async () => { + const dir = path.join(tmpDir, 'go-consumer'); + fs.mkdirSync(path.join(dir, 'cmd'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'cmd', 'client.go'), + ` +package main + +import ( + "net/http" + + "github.com/go-resty/resty/v2" +) + +func main() { + http.Get("/api/health") + client := resty.New() + client.R().Delete("/api/orders/42") +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers.find((c) => c.contractId === 'http::GET::/api/health')).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::DELETE::/api/orders/{param}'), + ).toBeDefined(); + }); }); describe('provider extraction — Laravel', () => { @@ -326,78 +494,6 @@ async def create_user(user: UserCreate): }); }); - describe('interface regex anchoring', () => { - it('skips Feign client interfaces (no @Controller)', async () => { - const dir = path.join(tmpDir, 'feign-skip'); - fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); - fs.writeFileSync( - path.join(dir, 'src/UserClient.java'), - ` -package com.example; -@FeignClient(name = "user-service") -public interface UserClient { - @GetMapping("/users") - List getUsers(); -} -`, - ); - const contracts = await extractor.extract(null, dir, makeRepo(dir)); - expect(contracts.filter((c) => c.role === 'provider')).toHaveLength(0); - }); - - it('does NOT skip when @RestController is present', async () => { - const dir = path.join(tmpDir, 'ctrl-iface'); - fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); - fs.writeFileSync( - path.join(dir, 'src/UserController.java'), - ` -@RestController -@RequestMapping("/api") -public class UserController { - @GetMapping("/users") - public List list() { return null; } -} -`, - ); - const contracts = await extractor.extract(null, dir, makeRepo(dir)); - expect(contracts.filter((c) => c.role === 'provider').length).toBeGreaterThanOrEqual(1); - }); - - it('does NOT false-positive on interface in comments', async () => { - const dir = path.join(tmpDir, 'iface-comment'); - fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); - fs.writeFileSync( - path.join(dir, 'src/Api.java'), - ` -// implements the interface UserApi -public class Api { - @GetMapping("/health") - public String health() { return "ok"; } -} -`, - ); - const contracts = await extractor.extract(null, dir, makeRepo(dir)); - expect(contracts.filter((c) => c.role === 'provider').length).toBeGreaterThanOrEqual(1); - }); - - it('does NOT false-positive on interface in a string', async () => { - const dir = path.join(tmpDir, 'iface-str'); - fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); - fs.writeFileSync( - path.join(dir, 'src/Svc.java'), - ` -public class Svc { - String desc = "implements interface Foo"; - @GetMapping("/status") - public String status() { return desc; } -} -`, - ); - const contracts = await extractor.extract(null, dir, makeRepo(dir)); - expect(contracts.filter((c) => c.role === 'provider').length).toBeGreaterThanOrEqual(1); - }); - }); - describe('path normalization', () => { it('strips trailing slash', async () => { const dir = path.join(tmpDir, 'trailing'); diff --git a/gitnexus/test/unit/group/manifest-extractor.test.ts b/gitnexus/test/unit/group/manifest-extractor.test.ts new file mode 100644 index 000000000..c2c67a33a --- /dev/null +++ b/gitnexus/test/unit/group/manifest-extractor.test.ts @@ -0,0 +1,308 @@ +import { describe, it, expect } from 'vitest'; +import { ManifestExtractor } from '../../../src/core/group/extractors/manifest-extractor.js'; +import type { GroupManifestLink } from '../../../src/core/group/types.js'; + +describe('ManifestExtractor', () => { + const extractor = new ManifestExtractor(); + + it('creates provider + consumer contracts and a cross-link for each manifest link', async () => { + const links: GroupManifestLink[] = [ + { + from: 'hr/payroll/backend', + to: 'hr/hiring/backend', + type: 'topic', + contract: 'employee.hired', + role: 'provider', + }, + ]; + + const result = await extractor.extractFromManifest(links); + + expect(result.contracts).toHaveLength(2); + + const provider = result.contracts.find((c) => c.role === 'provider'); + expect(provider).toBeDefined(); + expect(provider!.contractId).toBe('topic::employee.hired'); + expect(provider!.type).toBe('topic'); + expect(provider!.confidence).toBe(1.0); + + const consumer = result.contracts.find((c) => c.role === 'consumer'); + expect(consumer).toBeDefined(); + expect(consumer!.contractId).toBe('topic::employee.hired'); + + expect(result.crossLinks).toHaveLength(1); + expect(result.crossLinks[0].matchType).toBe('manifest'); + expect(result.crossLinks[0].confidence).toBe(1.0); + expect(result.crossLinks[0].from.repo).toBe('hr/hiring/backend'); + expect(result.crossLinks[0].to.repo).toBe('hr/payroll/backend'); + }); + + it('handles role: consumer (from-repo is consumer)', async () => { + const links: GroupManifestLink[] = [ + { + from: 'sales/admin/bff', + to: 'sales/crm/backend', + type: 'http', + contract: '/api/v2/leads/*', + role: 'consumer', + }, + ]; + + const result = await extractor.extractFromManifest(links); + + const provider = result.contracts.find((c) => c.role === 'provider'); + const consumer = result.contracts.find((c) => c.role === 'consumer'); + + expect(consumer!.contractId).toBe('http::*::/api/v2/leads/*'); + expect(provider!.contractId).toBe('http::*::/api/v2/leads/*'); + + expect(result.crossLinks[0].from.repo).toBe('sales/admin/bff'); + expect(result.crossLinks[0].to.repo).toBe('sales/crm/backend'); + }); + + it('resolves grpc manifest provider by exact method name (no .proto fallback)', async () => { + const links: GroupManifestLink[] = [ + { + from: 'platform/orders', + to: 'platform/auth', + type: 'grpc', + contract: 'auth.AuthService/Login', + role: 'consumer', + }, + ]; + + const dbExecutors = new Map< + string, + (cypher: string, params?: Record) => Promise[]> + >([ + [ + 'platform/auth', + async (_cypher, params) => { + // Exact match on method name. + if (params?.methodName === 'Login') { + return [ + { + uid: 'uid-auth-login', + name: 'Login', + filePath: 'src/auth.proto', + }, + ]; + } + return []; + }, + ], + [ + 'platform/orders', + async (_cypher, params) => { + // No symbol with the exact method name — resolve returns null and + // the consumer contract gets an empty symbolUid, falling back to + // name-based hint at cross-impact time. + if (params?.methodName === 'Login') return []; + return []; + }, + ], + ]); + + const result = await extractor.extractFromManifest(links, dbExecutors); + + const provider = result.contracts.find((c) => c.role === 'provider'); + const consumer = result.contracts.find((c) => c.role === 'consumer'); + + // Provider resolved to the concrete proto symbol. + expect(provider?.symbolUid).toBe('uid-auth-login'); + expect(provider?.symbolRef.filePath).toBe('src/auth.proto'); + + // Consumer falls back to a deterministic synthetic uid + name-based ref. + // The synthetic uid lets the bridge cross-impact query anchor on it + // even when the indexer doesn't expose a matching symbol. + expect(consumer?.symbolUid).toBe('manifest::platform/orders::grpc::auth.AuthService/Login'); + expect(consumer?.symbolRef.name).toBe('auth.AuthService/Login'); + + expect(result.crossLinks[0].to.symbolRef.filePath).toBe('src/auth.proto'); + expect(result.crossLinks[0].from.symbolUid).toBe( + 'manifest::platform/orders::grpc::auth.AuthService/Login', + ); + }); + + it('does NOT resolve grpc manifest to an arbitrary .proto file', async () => { + // Regression test for a previous bug: the extractor had an unconditional + // `OR n.filePath ENDS WITH '.proto'` fallback that returned the first + // proto symbol in the repo, regardless of whether it matched the contract. + const links: GroupManifestLink[] = [ + { + from: 'platform/orders', + to: 'platform/auth', + type: 'grpc', + contract: 'auth.AuthService/Login', + role: 'consumer', + }, + ]; + + const dbExecutors = new Map< + string, + (cypher: string, params?: Record) => Promise[]> + >([ + [ + 'platform/auth', + // Executor returns matches for ANY query (simulates the old buggy + // fallback that returned a random .proto file). The new code must + // only accept a hit when the method/service name matches exactly. + async (_cypher, params) => { + if (params?.methodName === 'Login' || params?.serviceName === 'auth.AuthService') { + return [ + { + uid: 'uid-correct-login', + name: 'Login', + filePath: 'src/auth.proto', + }, + ]; + } + return []; + }, + ], + ['platform/orders', async () => []], + ]); + + const result = await extractor.extractFromManifest(links, dbExecutors); + const provider = result.contracts.find((c) => c.role === 'provider'); + // Must resolve to the correct symbol (not a random proto one). + expect(provider?.symbolUid).toBe('uid-correct-login'); + }); + + it('resolves lib manifest links by exact name only', async () => { + const links: GroupManifestLink[] = [ + { + from: 'platform/web', + to: 'platform/shared-lib', + type: 'lib', + contract: '@platform/contracts', + role: 'consumer', + }, + ]; + + const dbExecutors = new Map< + string, + (cypher: string, params?: Record) => Promise[]> + >([ + [ + 'platform/shared-lib', + async (_cypher, params) => { + if (params?.contract !== '@platform/contracts') return []; + return [ + { + uid: 'uid-lib', + name: '@platform/contracts', + filePath: 'src/index.ts', + }, + ]; + }, + ], + [ + 'platform/web', + async (_cypher, params) => { + if (params?.contract !== '@platform/contracts') return []; + return []; + }, + ], + ]); + + const result = await extractor.extractFromManifest(links, dbExecutors); + + const provider = result.contracts.find((c) => c.role === 'provider'); + const consumer = result.contracts.find((c) => c.role === 'consumer'); + + expect(provider?.symbolUid).toBe('uid-lib'); + // Consumer doesn't have a symbol named exactly '@platform/contracts' — + // exact matching returns null, falling back to the synthetic manifest uid. + expect(consumer?.symbolUid).toBe('manifest::platform/web::lib::@platform/contracts'); + }); + + it('does NOT resolve lib manifest via CONTAINS on name', async () => { + // Regression test: previous CONTAINS fallback would match "react" to + // "react-native" or "@types/react". Exact matching must reject both. + const links: GroupManifestLink[] = [ + { + from: 'web', + to: 'packages/ui', + type: 'lib', + contract: 'react', + role: 'consumer', + }, + ]; + + const dbExecutors = new Map< + string, + (cypher: string, params?: Record) => Promise[]> + >([ + [ + 'packages/ui', + async (_cypher, params) => { + // Executor is called with contract='react'. Only exact matches + // should come back; return only wrong candidates to verify the + // Cypher uses `=` not `CONTAINS`. + if (params?.contract === 'react') { + // Simulated DB returns nothing because it has only "react-native" + // and "@types/react" — neither is an exact match for "react". + return []; + } + return []; + }, + ], + ['web', async () => []], + ]); + + const result = await extractor.extractFromManifest(links, dbExecutors); + const provider = result.contracts.find((c) => c.role === 'provider'); + // No exact match → synthetic manifest uid, not a wrong real one. + expect(provider?.symbolUid).toBe('manifest::packages/ui::lib::react'); + }); + + it('normalizes http contract path for exact Route.name match', async () => { + // Manifest may be written as "/api/orders/" or "api/orders"; both should + // match the canonical "/api/orders" stored in the graph. + const variants = ['/api/orders', '/api/orders/', 'api/orders', '//api//orders']; + for (const raw of variants) { + const links: GroupManifestLink[] = [ + { + from: 'gateway', + to: 'orders-svc', + type: 'http', + contract: raw, + role: 'consumer', + }, + ]; + + let seenParam: string | undefined; + const dbExecutors = new Map< + string, + (cypher: string, params?: Record) => Promise[]> + >([ + [ + 'orders-svc', + async (_cypher, params) => { + seenParam = params?.normalized as string; + return [ + { + uid: 'uid-orders-list', + name: 'listOrders', + filePath: 'src/orders.ts', + }, + ]; + }, + ], + ['gateway', async () => []], + ]); + + const result = await extractor.extractFromManifest(links, dbExecutors); + expect(seenParam).toBe('/api/orders'); + const provider = result.contracts.find((c) => c.role === 'provider'); + expect(provider?.symbolUid).toBe('uid-orders-list'); + } + }); + + it('returns empty for no links', async () => { + const result = await extractor.extractFromManifest([]); + expect(result.contracts).toHaveLength(0); + expect(result.crossLinks).toHaveLength(0); + }); +}); diff --git a/gitnexus/test/unit/group/topic-extractor.test.ts b/gitnexus/test/unit/group/topic-extractor.test.ts index c6a1161a0..bf821de63 100644 --- a/gitnexus/test/unit/group/topic-extractor.test.ts +++ b/gitnexus/test/unit/group/topic-extractor.test.ts @@ -75,8 +75,7 @@ public void handleUserCreated(ConsumerRecord record) { it('test_extract_kafkajs_subscribe_returns_consumer', async () => { writeFile( 'src/consumer.ts', - `await consumer.subscribe({ topic: 'order.placed', fromBeginning: true }); -await consumer.run({ eachMessage: async ({ message }) => {} });`, + `await consumer.subscribe({ topic: 'order.placed', fromBeginning: true });`, ); const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); @@ -101,6 +100,23 @@ await consumer.run({ eachMessage: async ({ message }) => {} });`, }); }); + describe('KafkaJS consumer run', () => { + it('test_extract_kafkajs_consumer_run_eachmessage_returns_consumer', async () => { + writeFile( + 'src/consumer.ts', + `await consumer.subscribe({ topic: 'user.logged-in' }); +await consumer.run({ eachMessage: async () => {} });`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(1); + expect(consumers[0].contractId).toBe('topic::user.logged-in'); + expect(consumers[0].meta.broker).toBe('kafka'); + }); + }); + describe('RabbitMQ — Java', () => { it('test_extract_rabbit_listener_returns_consumer', async () => { writeFile( @@ -174,6 +190,62 @@ public void processOrder(OrderMessage msg) {}`, }); }); + describe('JetStream', () => { + it('test_extract_jetstream_publish_returns_provider', async () => { + writeFile('src/stream.go', `js.Publish("orders.created", payload)`); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const producers = contracts.filter((c) => c.role === 'provider'); + + expect(producers).toHaveLength(1); + expect(producers[0].contractId).toBe('topic::orders.created'); + expect(producers[0].meta.broker).toBe('nats'); + }); + + it('test_extract_jetstream_subscribe_returns_consumer', async () => { + writeFile('src/stream.go', `js.Subscribe("orders.created", handler)`); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(1); + expect(consumers[0].contractId).toBe('topic::orders.created'); + expect(consumers[0].meta.broker).toBe('nats'); + }); + }); + + describe('Python NATS', () => { + it('test_extract_python_nats_subscribe_returns_consumer', async () => { + writeFile( + 'src/subscriber.py', + `nc = await nats.connect() +await nc.subscribe("orders.created", cb=handler)`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(1); + expect(consumers[0].contractId).toBe('topic::orders.created'); + expect(consumers[0].meta.broker).toBe('nats'); + }); + + it('test_extract_python_nats_publish_returns_provider', async () => { + writeFile( + 'src/publisher.py', + `nc = await nats.connect() +await nc.publish("orders.created", payload)`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const producers = contracts.filter((c) => c.role === 'provider'); + + expect(producers).toHaveLength(1); + expect(producers[0].contractId).toBe('topic::orders.created'); + expect(producers[0].meta.broker).toBe('nats'); + }); + }); + describe('NATS', () => { it('test_extract_nats_subscribe_go_returns_consumer', async () => { writeFile( @@ -248,6 +320,96 @@ partConsumer, _ := consumer.ConsumePartition("inventory.update", 0, sarama.Offse expect(consumers[0].contractId).toBe('topic::inventory.update'); expect(consumers[0].meta.broker).toBe('kafka'); }); + + it('test_extract_sarama_sync_producer_returns_provider', async () => { + writeFile( + 'internal/publisher.go', + `package publisher +producer, _ := sarama.NewSyncProducer(brokers, cfg) +producer.SendMessage(&sarama.ProducerMessage{Topic: "inventory.update"})`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const producers = contracts.filter((c) => c.role === 'provider'); + + expect(producers).toHaveLength(1); + expect(producers[0].contractId).toBe('topic::inventory.update'); + expect(producers[0].meta.broker).toBe('kafka'); + }); + + it('test_extract_sarama_async_producer_returns_provider', async () => { + writeFile( + 'internal/publisher.go', + `package publisher +producer, _ := sarama.NewAsyncProducer(brokers, cfg) +producer.Input() <- &sarama.ProducerMessage{Topic: "inventory.update"}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const producers = contracts.filter((c) => c.role === 'provider'); + + expect(producers).toHaveLength(1); + expect(producers[0].contractId).toBe('topic::inventory.update'); + expect(producers[0].meta.broker).toBe('kafka'); + }); + + it('test_extract_sarama_producer_in_loop_captures_all_topics', async () => { + // Regression: a for loop that constructs multiple ProducerMessage + // literals inside a single NewSyncProducer scope. The previous + // regex anchored on NewSyncProducer and captured only the first + // Topic within 300 chars, silently dropping the rest. + writeFile( + 'internal/multi-publisher.go', + `package publisher + +func publishAll(producer sarama.SyncProducer, items []Item) error { + _, _ = sarama.NewSyncProducer(brokers, cfg) + for _, item := range items { + msg1 := &sarama.ProducerMessage{Topic: "order.created"} + msg2 := &sarama.ProducerMessage{Topic: "order.shipped"} + _ = msg1 + _ = msg2 + } + return nil +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const producers = contracts.filter((c) => c.role === 'provider'); + const topics = producers.map((c) => c.contractId).sort(); + // Both topics must appear (exact set match to catch any duplicates). + expect(topics).toEqual(['topic::order.created', 'topic::order.shipped']); + }); + + it('test_extract_kafka_go_writer_returns_provider', async () => { + writeFile( + 'internal/writer.go', + `package publisher +writer := &kafka.Writer{Topic: "inventory.update"}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const producers = contracts.filter((c) => c.role === 'provider'); + + expect(producers).toHaveLength(1); + expect(producers[0].contractId).toBe('topic::inventory.update'); + expect(producers[0].meta.broker).toBe('kafka'); + }); + + it('test_extract_kafka_go_reader_returns_consumer', async () => { + writeFile( + 'internal/reader.go', + `package consumer +reader := kafka.NewReader(kafka.ReaderConfig{Topic: "inventory.update"})`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(1); + expect(consumers[0].contractId).toBe('topic::inventory.update'); + expect(consumers[0].meta.broker).toBe('kafka'); + }); }); describe('Kafka — Python', () => { @@ -309,5 +471,16 @@ await consumer.subscribe({ topic: 'order.placed' });`, expect(producers).toHaveLength(2); expect(consumers).toHaveLength(1); }); + + it('test_extract_ignores_go_test_files', async () => { + writeFile( + 'src/orders_test.go', + `consumer.ConsumePartition("fake-topic", 0, sarama.OffsetNewest)`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + + expect(contracts).toEqual([]); + }); }); }); diff --git a/gitnexus/vendor/tree-sitter-proto/.gitignore b/gitnexus/vendor/tree-sitter-proto/.gitignore new file mode 100644 index 000000000..009351ab8 --- /dev/null +++ b/gitnexus/vendor/tree-sitter-proto/.gitignore @@ -0,0 +1,3 @@ +build/ +node_modules/ +package-lock.json diff --git a/gitnexus/vendor/tree-sitter-proto/binding.gyp b/gitnexus/vendor/tree-sitter-proto/binding.gyp new file mode 100644 index 000000000..53ec4feb8 --- /dev/null +++ b/gitnexus/vendor/tree-sitter-proto/binding.gyp @@ -0,0 +1,30 @@ +{ + "targets": [ + { + "target_name": "tree_sitter_proto_binding", + "dependencies": [ + " + +typedef struct TSLanguage TSLanguage; + +extern "C" TSLanguage *tree_sitter_proto(); + +// "tree-sitter", "language" hashed with BLAKE2 +const napi_type_tag LANGUAGE_TYPE_TAG = { + 0x8AF2E5212AD58ABF, 0xD5006CAD83ABBA16 +}; + +Napi::Object Init(Napi::Env env, Napi::Object exports) { + exports["name"] = Napi::String::New(env, "proto"); + auto language = Napi::External::New(env, tree_sitter_proto()); + language.TypeTag(&LANGUAGE_TYPE_TAG); + exports["language"] = language; + return exports; +} + +NODE_API_MODULE(tree_sitter_proto_binding, Init) diff --git a/gitnexus/vendor/tree-sitter-proto/bindings/node/index.d.ts b/gitnexus/vendor/tree-sitter-proto/bindings/node/index.d.ts new file mode 100644 index 000000000..efe259eed --- /dev/null +++ b/gitnexus/vendor/tree-sitter-proto/bindings/node/index.d.ts @@ -0,0 +1,28 @@ +type BaseNode = { + type: string; + named: boolean; +}; + +type ChildNode = { + multiple: boolean; + required: boolean; + types: BaseNode[]; +}; + +type NodeInfo = + | (BaseNode & { + subtypes: BaseNode[]; + }) + | (BaseNode & { + fields: { [name: string]: ChildNode }; + children: ChildNode[]; + }); + +type Language = { + name: string; + language: unknown; + nodeTypeInfo: NodeInfo[]; +}; + +declare const language: Language; +export = language; diff --git a/gitnexus/vendor/tree-sitter-proto/bindings/node/index.js b/gitnexus/vendor/tree-sitter-proto/bindings/node/index.js new file mode 100644 index 000000000..6657bcf42 --- /dev/null +++ b/gitnexus/vendor/tree-sitter-proto/bindings/node/index.js @@ -0,0 +1,7 @@ +const root = require("path").join(__dirname, "..", ".."); + +module.exports = require("node-gyp-build")(root); + +try { + module.exports.nodeTypeInfo = require("../../src/node-types.json"); +} catch (_) {} diff --git a/gitnexus/vendor/tree-sitter-proto/package.json b/gitnexus/vendor/tree-sitter-proto/package.json new file mode 100644 index 000000000..387f3d9bb --- /dev/null +++ b/gitnexus/vendor/tree-sitter-proto/package.json @@ -0,0 +1,18 @@ +{ + "name": "tree-sitter-proto", + "version": "0.4.1", + "description": "tree-sitter grammar for protobuf — ABI 14 build from coder3101/tree-sitter-proto latest grammar.js, compatible with tree-sitter 0.25", + "repository": "https://github.com/coder3101/tree-sitter-proto", + "license": "MIT", + "main": "bindings/node", + "scripts": { + "install": "node-gyp-build" + }, + "peerDependencies": { + "tree-sitter": ">=0.21.0" + }, + "dependencies": { + "node-addon-api": "^8.0.0", + "node-gyp-build": "^4.8.0" + } +} diff --git a/gitnexus/vendor/tree-sitter-proto/src/node-types.json b/gitnexus/vendor/tree-sitter-proto/src/node-types.json new file mode 100644 index 000000000..63f8942a6 --- /dev/null +++ b/gitnexus/vendor/tree-sitter-proto/src/node-types.json @@ -0,0 +1,1145 @@ +[ + { + "type": "block_lit", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": false, + "types": [ + { + "type": "constant", + "named": true + }, + { + "type": "full_ident", + "named": true + }, + { + "type": "identifier", + "named": true + } + ] + } + }, + { + "type": "bool", + "named": true, + "fields": {}, + "children": { + "multiple": false, + "required": true, + "types": [ + { + "type": "false", + "named": true + }, + { + "type": "true", + "named": true + } + ] + } + }, + { + "type": "constant", + "named": true, + "fields": {}, + "children": { + "multiple": false, + "required": true, + "types": [ + { + "type": "block_lit", + "named": true + }, + { + "type": "bool", + "named": true + }, + { + "type": "float_lit", + "named": true + }, + { + "type": "full_ident", + "named": true + }, + { + "type": "int_lit", + "named": true + }, + { + "type": "string", + "named": true + } + ] + } + }, + { + "type": "edition", + "named": true, + "fields": { + "year": { + "multiple": false, + "required": true, + "types": [ + { + "type": "string", + "named": true + } + ] + } + } + }, + { + "type": "empty_statement", + "named": true, + "fields": {} + }, + { + "type": "enum", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "enum_body", + "named": true + }, + { + "type": "enum_name", + "named": true + } + ] + } + }, + { + "type": "enum_body", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": false, + "types": [ + { + "type": "empty_statement", + "named": true + }, + { + "type": "enum_field", + "named": true + }, + { + "type": "option", + "named": true + }, + { + "type": "reserved", + "named": true + } + ] + } + }, + { + "type": "enum_field", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "enum_value_option", + "named": true + }, + { + "type": "identifier", + "named": true + }, + { + "type": "int_lit", + "named": true + } + ] + } + }, + { + "type": "enum_name", + "named": true, + "fields": {}, + "children": { + "multiple": false, + "required": true, + "types": [ + { + "type": "identifier", + "named": true + } + ] + } + }, + { + "type": "enum_value_option", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "constant", + "named": true + }, + { + "type": "full_ident", + "named": true + }, + { + "type": "identifier", + "named": true + } + ] + } + }, + { + "type": "extend", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "full_ident", + "named": true + }, + { + "type": "message_body", + "named": true + } + ] + } + }, + { + "type": "extensions", + "named": true, + "fields": {}, + "children": { + "multiple": false, + "required": true, + "types": [ + { + "type": "ranges", + "named": true + } + ] + } + }, + { + "type": "field", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "field_number", + "named": true + }, + { + "type": "field_options", + "named": true + }, + { + "type": "identifier", + "named": true + }, + { + "type": "type", + "named": true + } + ] + } + }, + { + "type": "field_number", + "named": true, + "fields": {}, + "children": { + "multiple": false, + "required": true, + "types": [ + { + "type": "int_lit", + "named": true + } + ] + } + }, + { + "type": "field_option", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "constant", + "named": true + }, + { + "type": "full_ident", + "named": true + }, + { + "type": "identifier", + "named": true + } + ] + } + }, + { + "type": "field_options", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "field_option", + "named": true + } + ] + } + }, + { + "type": "full_ident", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "identifier", + "named": true + } + ] + } + }, + { + "type": "group", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "field_number", + "named": true + }, + { + "type": "message_body", + "named": true + }, + { + "type": "message_name", + "named": true + } + ] + } + }, + { + "type": "import", + "named": true, + "fields": { + "path": { + "multiple": false, + "required": true, + "types": [ + { + "type": "string", + "named": true + } + ] + } + } + }, + { + "type": "int_lit", + "named": true, + "fields": {}, + "children": { + "multiple": false, + "required": true, + "types": [ + { + "type": "decimal_lit", + "named": true + }, + { + "type": "hex_lit", + "named": true + }, + { + "type": "octal_lit", + "named": true + } + ] + } + }, + { + "type": "key_type", + "named": true, + "fields": {} + }, + { + "type": "map_field", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "field_number", + "named": true + }, + { + "type": "field_options", + "named": true + }, + { + "type": "identifier", + "named": true + }, + { + "type": "key_type", + "named": true + }, + { + "type": "type", + "named": true + } + ] + } + }, + { + "type": "message", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "message_body", + "named": true + }, + { + "type": "message_name", + "named": true + } + ] + } + }, + { + "type": "message_body", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": false, + "types": [ + { + "type": "empty_statement", + "named": true + }, + { + "type": "enum", + "named": true + }, + { + "type": "extend", + "named": true + }, + { + "type": "extensions", + "named": true + }, + { + "type": "field", + "named": true + }, + { + "type": "group", + "named": true + }, + { + "type": "map_field", + "named": true + }, + { + "type": "message", + "named": true + }, + { + "type": "oneof", + "named": true + }, + { + "type": "option", + "named": true + }, + { + "type": "reserved", + "named": true + } + ] + } + }, + { + "type": "message_name", + "named": true, + "fields": {}, + "children": { + "multiple": false, + "required": true, + "types": [ + { + "type": "identifier", + "named": true + } + ] + } + }, + { + "type": "message_or_enum_type", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "identifier", + "named": true + } + ] + } + }, + { + "type": "oneof", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "empty_statement", + "named": true + }, + { + "type": "identifier", + "named": true + }, + { + "type": "oneof_field", + "named": true + }, + { + "type": "option", + "named": true + } + ] + } + }, + { + "type": "oneof_field", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "field_number", + "named": true + }, + { + "type": "field_options", + "named": true + }, + { + "type": "identifier", + "named": true + }, + { + "type": "type", + "named": true + } + ] + } + }, + { + "type": "option", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "constant", + "named": true + }, + { + "type": "full_ident", + "named": true + }, + { + "type": "identifier", + "named": true + } + ] + } + }, + { + "type": "package", + "named": true, + "fields": {}, + "children": { + "multiple": false, + "required": true, + "types": [ + { + "type": "full_ident", + "named": true + } + ] + } + }, + { + "type": "range", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "int_lit", + "named": true + } + ] + } + }, + { + "type": "ranges", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "range", + "named": true + } + ] + } + }, + { + "type": "reserved", + "named": true, + "fields": {}, + "children": { + "multiple": false, + "required": true, + "types": [ + { + "type": "ranges", + "named": true + }, + { + "type": "reserved_field_names", + "named": true + } + ] + } + }, + { + "type": "reserved_field_names", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "reserved_identifier", + "named": true + } + ] + } + }, + { + "type": "rpc", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "empty_statement", + "named": true + }, + { + "type": "message_or_enum_type", + "named": true + }, + { + "type": "option", + "named": true + }, + { + "type": "rpc_name", + "named": true + } + ] + } + }, + { + "type": "rpc_name", + "named": true, + "fields": {}, + "children": { + "multiple": false, + "required": true, + "types": [ + { + "type": "identifier", + "named": true + } + ] + } + }, + { + "type": "service", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": true, + "types": [ + { + "type": "empty_statement", + "named": true + }, + { + "type": "option", + "named": true + }, + { + "type": "rpc", + "named": true + }, + { + "type": "service_name", + "named": true + } + ] + } + }, + { + "type": "service_name", + "named": true, + "fields": {}, + "children": { + "multiple": false, + "required": true, + "types": [ + { + "type": "identifier", + "named": true + } + ] + } + }, + { + "type": "source_file", + "named": true, + "root": true, + "fields": {}, + "children": { + "multiple": true, + "required": false, + "types": [ + { + "type": "edition", + "named": true + }, + { + "type": "empty_statement", + "named": true + }, + { + "type": "enum", + "named": true + }, + { + "type": "extend", + "named": true + }, + { + "type": "import", + "named": true + }, + { + "type": "message", + "named": true + }, + { + "type": "option", + "named": true + }, + { + "type": "package", + "named": true + }, + { + "type": "service", + "named": true + }, + { + "type": "syntax", + "named": true + } + ] + } + }, + { + "type": "string", + "named": true, + "fields": {}, + "children": { + "multiple": true, + "required": false, + "types": [ + { + "type": "escape_sequence", + "named": true + } + ] + } + }, + { + "type": "syntax", + "named": true, + "fields": {} + }, + { + "type": "type", + "named": true, + "fields": {}, + "children": { + "multiple": false, + "required": false, + "types": [ + { + "type": "message_or_enum_type", + "named": true + } + ] + } + }, + { + "type": "\"", + "named": false + }, + { + "type": "\"proto2\"", + "named": false + }, + { + "type": "\"proto3\"", + "named": false + }, + { + "type": "'", + "named": false + }, + { + "type": "(", + "named": false + }, + { + "type": ")", + "named": false + }, + { + "type": "+", + "named": false + }, + { + "type": ",", + "named": false + }, + { + "type": "-", + "named": false + }, + { + "type": ".", + "named": false + }, + { + "type": ":", + "named": false + }, + { + "type": ";", + "named": false + }, + { + "type": "<", + "named": false + }, + { + "type": "=", + "named": false + }, + { + "type": ">", + "named": false + }, + { + "type": "[", + "named": false + }, + { + "type": "]", + "named": false + }, + { + "type": "bool", + "named": false + }, + { + "type": "bytes", + "named": false + }, + { + "type": "comment", + "named": true + }, + { + "type": "decimal_lit", + "named": true + }, + { + "type": "double", + "named": false + }, + { + "type": "edition", + "named": false + }, + { + "type": "enum", + "named": false + }, + { + "type": "escape_sequence", + "named": true + }, + { + "type": "export", + "named": false + }, + { + "type": "extend", + "named": false + }, + { + "type": "extensions", + "named": false + }, + { + "type": "false", + "named": true + }, + { + "type": "fixed32", + "named": false + }, + { + "type": "fixed64", + "named": false + }, + { + "type": "float", + "named": false + }, + { + "type": "float_lit", + "named": true + }, + { + "type": "group", + "named": false + }, + { + "type": "hex_lit", + "named": true + }, + { + "type": "identifier", + "named": true + }, + { + "type": "import", + "named": false + }, + { + "type": "int32", + "named": false + }, + { + "type": "int64", + "named": false + }, + { + "type": "local", + "named": false + }, + { + "type": "map", + "named": false + }, + { + "type": "max", + "named": false + }, + { + "type": "message", + "named": false + }, + { + "type": "octal_lit", + "named": true + }, + { + "type": "oneof", + "named": false + }, + { + "type": "option", + "named": false + }, + { + "type": "optional", + "named": false + }, + { + "type": "package", + "named": false + }, + { + "type": "public", + "named": false + }, + { + "type": "repeated", + "named": false + }, + { + "type": "required", + "named": false + }, + { + "type": "reserved", + "named": false + }, + { + "type": "reserved_identifier", + "named": true + }, + { + "type": "returns", + "named": false + }, + { + "type": "rpc", + "named": false + }, + { + "type": "service", + "named": false + }, + { + "type": "sfixed32", + "named": false + }, + { + "type": "sfixed64", + "named": false + }, + { + "type": "sint32", + "named": false + }, + { + "type": "sint64", + "named": false + }, + { + "type": "stream", + "named": false + }, + { + "type": "string", + "named": false + }, + { + "type": "syntax", + "named": false + }, + { + "type": "to", + "named": false + }, + { + "type": "true", + "named": true + }, + { + "type": "uint32", + "named": false + }, + { + "type": "uint64", + "named": false + }, + { + "type": "weak", + "named": false + }, + { + "type": "{", + "named": false + }, + { + "type": "}", + "named": false + } +] \ No newline at end of file diff --git a/gitnexus/vendor/tree-sitter-proto/src/parser.c b/gitnexus/vendor/tree-sitter-proto/src/parser.c new file mode 100644 index 000000000..96b661b8e --- /dev/null +++ b/gitnexus/vendor/tree-sitter-proto/src/parser.c @@ -0,0 +1,10149 @@ +#include "tree_sitter/parser.h" + +#if defined(__GNUC__) || defined(__clang__) +#pragma GCC diagnostic ignored "-Wmissing-field-initializers" +#endif + +#ifdef _MSC_VER +#pragma optimize("", off) +#elif defined(__clang__) +#pragma clang optimize off +#elif defined(__GNUC__) +#pragma GCC optimize ("O0") +#endif + +#define LANGUAGE_VERSION 14 +#define STATE_COUNT 345 +#define LARGE_STATE_COUNT 2 +#define SYMBOL_COUNT 133 +#define ALIAS_COUNT 0 +#define TOKEN_COUNT 73 +#define EXTERNAL_TOKEN_COUNT 0 +#define FIELD_COUNT 2 +#define MAX_ALIAS_SEQUENCE_LENGTH 14 +#define PRODUCTION_ID_COUNT 4 + +enum ts_symbol_identifiers { + anon_sym_SEMI = 1, + anon_sym_edition = 2, + anon_sym_EQ = 3, + anon_sym_syntax = 4, + anon_sym_DQUOTEproto3_DQUOTE = 5, + anon_sym_DQUOTEproto2_DQUOTE = 6, + anon_sym_import = 7, + anon_sym_weak = 8, + anon_sym_public = 9, + anon_sym_option = 10, + anon_sym_package = 11, + anon_sym_LPAREN = 12, + anon_sym_RPAREN = 13, + anon_sym_DOT = 14, + anon_sym_export = 15, + anon_sym_local = 16, + anon_sym_enum = 17, + anon_sym_LBRACE = 18, + anon_sym_RBRACE = 19, + anon_sym_DASH = 20, + anon_sym_LBRACK = 21, + anon_sym_COMMA = 22, + anon_sym_RBRACK = 23, + anon_sym_message = 24, + anon_sym_extend = 25, + anon_sym_optional = 26, + anon_sym_required = 27, + anon_sym_repeated = 28, + anon_sym_group = 29, + anon_sym_oneof = 30, + anon_sym_map = 31, + anon_sym_LT = 32, + anon_sym_GT = 33, + anon_sym_int32 = 34, + anon_sym_int64 = 35, + anon_sym_uint32 = 36, + anon_sym_uint64 = 37, + anon_sym_sint32 = 38, + anon_sym_sint64 = 39, + anon_sym_fixed32 = 40, + anon_sym_fixed64 = 41, + anon_sym_sfixed32 = 42, + anon_sym_sfixed64 = 43, + anon_sym_bool = 44, + anon_sym_string = 45, + anon_sym_double = 46, + anon_sym_float = 47, + anon_sym_bytes = 48, + anon_sym_reserved = 49, + anon_sym_extensions = 50, + anon_sym_to = 51, + anon_sym_max = 52, + anon_sym_service = 53, + anon_sym_rpc = 54, + anon_sym_stream = 55, + anon_sym_returns = 56, + anon_sym_PLUS = 57, + anon_sym_COLON = 58, + sym_identifier = 59, + sym_reserved_identifier = 60, + sym_true = 61, + sym_false = 62, + sym_decimal_lit = 63, + sym_octal_lit = 64, + sym_hex_lit = 65, + sym_float_lit = 66, + anon_sym_DQUOTE = 67, + aux_sym_string_token1 = 68, + anon_sym_SQUOTE = 69, + aux_sym_string_token2 = 70, + sym_escape_sequence = 71, + sym_comment = 72, + sym_source_file = 73, + sym_empty_statement = 74, + sym_edition = 75, + sym_syntax = 76, + sym_import = 77, + sym_package = 78, + sym_option = 79, + sym__option_name = 80, + sym_enum = 81, + sym_enum_name = 82, + sym_enum_body = 83, + sym_enum_field = 84, + sym_enum_value_option = 85, + sym_message = 86, + sym_message_body = 87, + sym_message_name = 88, + sym_extend = 89, + sym_group = 90, + sym_field = 91, + sym_field_options = 92, + sym_field_option = 93, + sym_oneof = 94, + sym_oneof_field = 95, + sym_map_field = 96, + sym_key_type = 97, + sym_type = 98, + sym_reserved = 99, + sym_extensions = 100, + sym_ranges = 101, + sym_range = 102, + sym_reserved_field_names = 103, + sym_message_or_enum_type = 104, + sym_field_number = 105, + sym_service = 106, + sym_service_name = 107, + sym_rpc = 108, + sym_rpc_name = 109, + sym_constant = 110, + sym_block_lit = 111, + sym_full_ident = 112, + sym_bool = 113, + sym_int_lit = 114, + sym_string = 115, + aux_sym_source_file_repeat1 = 116, + aux_sym__option_name_repeat1 = 117, + aux_sym_enum_body_repeat1 = 118, + aux_sym_enum_field_repeat1 = 119, + aux_sym_message_body_repeat1 = 120, + aux_sym_field_options_repeat1 = 121, + aux_sym_oneof_repeat1 = 122, + aux_sym_ranges_repeat1 = 123, + aux_sym_reserved_field_names_repeat1 = 124, + aux_sym_message_or_enum_type_repeat1 = 125, + aux_sym_service_repeat1 = 126, + aux_sym_rpc_repeat1 = 127, + aux_sym_block_lit_repeat1 = 128, + aux_sym_block_lit_repeat2 = 129, + aux_sym_string_repeat1 = 130, + aux_sym_string_repeat2 = 131, + aux_sym_string_repeat3 = 132, +}; + +static const char * const ts_symbol_names[] = { + [ts_builtin_sym_end] = "end", + [anon_sym_SEMI] = ";", + [anon_sym_edition] = "edition", + [anon_sym_EQ] = "=", + [anon_sym_syntax] = "syntax", + [anon_sym_DQUOTEproto3_DQUOTE] = "\"proto3\"", + [anon_sym_DQUOTEproto2_DQUOTE] = "\"proto2\"", + [anon_sym_import] = "import", + [anon_sym_weak] = "weak", + [anon_sym_public] = "public", + [anon_sym_option] = "option", + [anon_sym_package] = "package", + [anon_sym_LPAREN] = "(", + [anon_sym_RPAREN] = ")", + [anon_sym_DOT] = ".", + [anon_sym_export] = "export", + [anon_sym_local] = "local", + [anon_sym_enum] = "enum", + [anon_sym_LBRACE] = "{", + [anon_sym_RBRACE] = "}", + [anon_sym_DASH] = "-", + [anon_sym_LBRACK] = "[", + [anon_sym_COMMA] = ",", + [anon_sym_RBRACK] = "]", + [anon_sym_message] = "message", + [anon_sym_extend] = "extend", + [anon_sym_optional] = "optional", + [anon_sym_required] = "required", + [anon_sym_repeated] = "repeated", + [anon_sym_group] = "group", + [anon_sym_oneof] = "oneof", + [anon_sym_map] = "map", + [anon_sym_LT] = "<", + [anon_sym_GT] = ">", + [anon_sym_int32] = "int32", + [anon_sym_int64] = "int64", + [anon_sym_uint32] = "uint32", + [anon_sym_uint64] = "uint64", + [anon_sym_sint32] = "sint32", + [anon_sym_sint64] = "sint64", + [anon_sym_fixed32] = "fixed32", + [anon_sym_fixed64] = "fixed64", + [anon_sym_sfixed32] = "sfixed32", + [anon_sym_sfixed64] = "sfixed64", + [anon_sym_bool] = "bool", + [anon_sym_string] = "string", + [anon_sym_double] = "double", + [anon_sym_float] = "float", + [anon_sym_bytes] = "bytes", + [anon_sym_reserved] = "reserved", + [anon_sym_extensions] = "extensions", + [anon_sym_to] = "to", + [anon_sym_max] = "max", + [anon_sym_service] = "service", + [anon_sym_rpc] = "rpc", + [anon_sym_stream] = "stream", + [anon_sym_returns] = "returns", + [anon_sym_PLUS] = "+", + [anon_sym_COLON] = ":", + [sym_identifier] = "identifier", + [sym_reserved_identifier] = "reserved_identifier", + [sym_true] = "true", + [sym_false] = "false", + [sym_decimal_lit] = "decimal_lit", + [sym_octal_lit] = "octal_lit", + [sym_hex_lit] = "hex_lit", + [sym_float_lit] = "float_lit", + [anon_sym_DQUOTE] = "\"", + [aux_sym_string_token1] = "string_token1", + [anon_sym_SQUOTE] = "'", + [aux_sym_string_token2] = "string_token2", + [sym_escape_sequence] = "escape_sequence", + [sym_comment] = "comment", + [sym_source_file] = "source_file", + [sym_empty_statement] = "empty_statement", + [sym_edition] = "edition", + [sym_syntax] = "syntax", + [sym_import] = "import", + [sym_package] = "package", + [sym_option] = "option", + [sym__option_name] = "_option_name", + [sym_enum] = "enum", + [sym_enum_name] = "enum_name", + [sym_enum_body] = "enum_body", + [sym_enum_field] = "enum_field", + [sym_enum_value_option] = "enum_value_option", + [sym_message] = "message", + [sym_message_body] = "message_body", + [sym_message_name] = "message_name", + [sym_extend] = "extend", + [sym_group] = "group", + [sym_field] = "field", + [sym_field_options] = "field_options", + [sym_field_option] = "field_option", + [sym_oneof] = "oneof", + [sym_oneof_field] = "oneof_field", + [sym_map_field] = "map_field", + [sym_key_type] = "key_type", + [sym_type] = "type", + [sym_reserved] = "reserved", + [sym_extensions] = "extensions", + [sym_ranges] = "ranges", + [sym_range] = "range", + [sym_reserved_field_names] = "reserved_field_names", + [sym_message_or_enum_type] = "message_or_enum_type", + [sym_field_number] = "field_number", + [sym_service] = "service", + [sym_service_name] = "service_name", + [sym_rpc] = "rpc", + [sym_rpc_name] = "rpc_name", + [sym_constant] = "constant", + [sym_block_lit] = "block_lit", + [sym_full_ident] = "full_ident", + [sym_bool] = "bool", + [sym_int_lit] = "int_lit", + [sym_string] = "string", + [aux_sym_source_file_repeat1] = "source_file_repeat1", + [aux_sym__option_name_repeat1] = "_option_name_repeat1", + [aux_sym_enum_body_repeat1] = "enum_body_repeat1", + [aux_sym_enum_field_repeat1] = "enum_field_repeat1", + [aux_sym_message_body_repeat1] = "message_body_repeat1", + [aux_sym_field_options_repeat1] = "field_options_repeat1", + [aux_sym_oneof_repeat1] = "oneof_repeat1", + [aux_sym_ranges_repeat1] = "ranges_repeat1", + [aux_sym_reserved_field_names_repeat1] = "reserved_field_names_repeat1", + [aux_sym_message_or_enum_type_repeat1] = "message_or_enum_type_repeat1", + [aux_sym_service_repeat1] = "service_repeat1", + [aux_sym_rpc_repeat1] = "rpc_repeat1", + [aux_sym_block_lit_repeat1] = "block_lit_repeat1", + [aux_sym_block_lit_repeat2] = "block_lit_repeat2", + [aux_sym_string_repeat1] = "string_repeat1", + [aux_sym_string_repeat2] = "string_repeat2", + [aux_sym_string_repeat3] = "string_repeat3", +}; + +static const TSSymbol ts_symbol_map[] = { + [ts_builtin_sym_end] = ts_builtin_sym_end, + [anon_sym_SEMI] = anon_sym_SEMI, + [anon_sym_edition] = anon_sym_edition, + [anon_sym_EQ] = anon_sym_EQ, + [anon_sym_syntax] = anon_sym_syntax, + [anon_sym_DQUOTEproto3_DQUOTE] = anon_sym_DQUOTEproto3_DQUOTE, + [anon_sym_DQUOTEproto2_DQUOTE] = anon_sym_DQUOTEproto2_DQUOTE, + [anon_sym_import] = anon_sym_import, + [anon_sym_weak] = anon_sym_weak, + [anon_sym_public] = anon_sym_public, + [anon_sym_option] = anon_sym_option, + [anon_sym_package] = anon_sym_package, + [anon_sym_LPAREN] = anon_sym_LPAREN, + [anon_sym_RPAREN] = anon_sym_RPAREN, + [anon_sym_DOT] = anon_sym_DOT, + [anon_sym_export] = anon_sym_export, + [anon_sym_local] = anon_sym_local, + [anon_sym_enum] = anon_sym_enum, + [anon_sym_LBRACE] = anon_sym_LBRACE, + [anon_sym_RBRACE] = anon_sym_RBRACE, + [anon_sym_DASH] = anon_sym_DASH, + [anon_sym_LBRACK] = anon_sym_LBRACK, + [anon_sym_COMMA] = anon_sym_COMMA, + [anon_sym_RBRACK] = anon_sym_RBRACK, + [anon_sym_message] = anon_sym_message, + [anon_sym_extend] = anon_sym_extend, + [anon_sym_optional] = anon_sym_optional, + [anon_sym_required] = anon_sym_required, + [anon_sym_repeated] = anon_sym_repeated, + [anon_sym_group] = anon_sym_group, + [anon_sym_oneof] = anon_sym_oneof, + [anon_sym_map] = anon_sym_map, + [anon_sym_LT] = anon_sym_LT, + [anon_sym_GT] = anon_sym_GT, + [anon_sym_int32] = anon_sym_int32, + [anon_sym_int64] = anon_sym_int64, + [anon_sym_uint32] = anon_sym_uint32, + [anon_sym_uint64] = anon_sym_uint64, + [anon_sym_sint32] = anon_sym_sint32, + [anon_sym_sint64] = anon_sym_sint64, + [anon_sym_fixed32] = anon_sym_fixed32, + [anon_sym_fixed64] = anon_sym_fixed64, + [anon_sym_sfixed32] = anon_sym_sfixed32, + [anon_sym_sfixed64] = anon_sym_sfixed64, + [anon_sym_bool] = anon_sym_bool, + [anon_sym_string] = anon_sym_string, + [anon_sym_double] = anon_sym_double, + [anon_sym_float] = anon_sym_float, + [anon_sym_bytes] = anon_sym_bytes, + [anon_sym_reserved] = anon_sym_reserved, + [anon_sym_extensions] = anon_sym_extensions, + [anon_sym_to] = anon_sym_to, + [anon_sym_max] = anon_sym_max, + [anon_sym_service] = anon_sym_service, + [anon_sym_rpc] = anon_sym_rpc, + [anon_sym_stream] = anon_sym_stream, + [anon_sym_returns] = anon_sym_returns, + [anon_sym_PLUS] = anon_sym_PLUS, + [anon_sym_COLON] = anon_sym_COLON, + [sym_identifier] = sym_identifier, + [sym_reserved_identifier] = sym_reserved_identifier, + [sym_true] = sym_true, + [sym_false] = sym_false, + [sym_decimal_lit] = sym_decimal_lit, + [sym_octal_lit] = sym_octal_lit, + [sym_hex_lit] = sym_hex_lit, + [sym_float_lit] = sym_float_lit, + [anon_sym_DQUOTE] = anon_sym_DQUOTE, + [aux_sym_string_token1] = aux_sym_string_token1, + [anon_sym_SQUOTE] = anon_sym_SQUOTE, + [aux_sym_string_token2] = aux_sym_string_token2, + [sym_escape_sequence] = sym_escape_sequence, + [sym_comment] = sym_comment, + [sym_source_file] = sym_source_file, + [sym_empty_statement] = sym_empty_statement, + [sym_edition] = sym_edition, + [sym_syntax] = sym_syntax, + [sym_import] = sym_import, + [sym_package] = sym_package, + [sym_option] = sym_option, + [sym__option_name] = sym__option_name, + [sym_enum] = sym_enum, + [sym_enum_name] = sym_enum_name, + [sym_enum_body] = sym_enum_body, + [sym_enum_field] = sym_enum_field, + [sym_enum_value_option] = sym_enum_value_option, + [sym_message] = sym_message, + [sym_message_body] = sym_message_body, + [sym_message_name] = sym_message_name, + [sym_extend] = sym_extend, + [sym_group] = sym_group, + [sym_field] = sym_field, + [sym_field_options] = sym_field_options, + [sym_field_option] = sym_field_option, + [sym_oneof] = sym_oneof, + [sym_oneof_field] = sym_oneof_field, + [sym_map_field] = sym_map_field, + [sym_key_type] = sym_key_type, + [sym_type] = sym_type, + [sym_reserved] = sym_reserved, + [sym_extensions] = sym_extensions, + [sym_ranges] = sym_ranges, + [sym_range] = sym_range, + [sym_reserved_field_names] = sym_reserved_field_names, + [sym_message_or_enum_type] = sym_message_or_enum_type, + [sym_field_number] = sym_field_number, + [sym_service] = sym_service, + [sym_service_name] = sym_service_name, + [sym_rpc] = sym_rpc, + [sym_rpc_name] = sym_rpc_name, + [sym_constant] = sym_constant, + [sym_block_lit] = sym_block_lit, + [sym_full_ident] = sym_full_ident, + [sym_bool] = sym_bool, + [sym_int_lit] = sym_int_lit, + [sym_string] = sym_string, + [aux_sym_source_file_repeat1] = aux_sym_source_file_repeat1, + [aux_sym__option_name_repeat1] = aux_sym__option_name_repeat1, + [aux_sym_enum_body_repeat1] = aux_sym_enum_body_repeat1, + [aux_sym_enum_field_repeat1] = aux_sym_enum_field_repeat1, + [aux_sym_message_body_repeat1] = aux_sym_message_body_repeat1, + [aux_sym_field_options_repeat1] = aux_sym_field_options_repeat1, + [aux_sym_oneof_repeat1] = aux_sym_oneof_repeat1, + [aux_sym_ranges_repeat1] = aux_sym_ranges_repeat1, + [aux_sym_reserved_field_names_repeat1] = aux_sym_reserved_field_names_repeat1, + [aux_sym_message_or_enum_type_repeat1] = aux_sym_message_or_enum_type_repeat1, + [aux_sym_service_repeat1] = aux_sym_service_repeat1, + [aux_sym_rpc_repeat1] = aux_sym_rpc_repeat1, + [aux_sym_block_lit_repeat1] = aux_sym_block_lit_repeat1, + [aux_sym_block_lit_repeat2] = aux_sym_block_lit_repeat2, + [aux_sym_string_repeat1] = aux_sym_string_repeat1, + [aux_sym_string_repeat2] = aux_sym_string_repeat2, + [aux_sym_string_repeat3] = aux_sym_string_repeat3, +}; + +static const TSSymbolMetadata ts_symbol_metadata[] = { + [ts_builtin_sym_end] = { + .visible = false, + .named = true, + }, + [anon_sym_SEMI] = { + .visible = true, + .named = false, + }, + [anon_sym_edition] = { + .visible = true, + .named = false, + }, + [anon_sym_EQ] = { + .visible = true, + .named = false, + }, + [anon_sym_syntax] = { + .visible = true, + .named = false, + }, + [anon_sym_DQUOTEproto3_DQUOTE] = { + .visible = true, + .named = false, + }, + [anon_sym_DQUOTEproto2_DQUOTE] = { + .visible = true, + .named = false, + }, + [anon_sym_import] = { + .visible = true, + .named = false, + }, + [anon_sym_weak] = { + .visible = true, + .named = false, + }, + [anon_sym_public] = { + .visible = true, + .named = false, + }, + [anon_sym_option] = { + .visible = true, + .named = false, + }, + [anon_sym_package] = { + .visible = true, + .named = false, + }, + [anon_sym_LPAREN] = { + .visible = true, + .named = false, + }, + [anon_sym_RPAREN] = { + .visible = true, + .named = false, + }, + [anon_sym_DOT] = { + .visible = true, + .named = false, + }, + [anon_sym_export] = { + .visible = true, + .named = false, + }, + [anon_sym_local] = { + .visible = true, + .named = false, + }, + [anon_sym_enum] = { + .visible = true, + .named = false, + }, + [anon_sym_LBRACE] = { + .visible = true, + .named = false, + }, + [anon_sym_RBRACE] = { + .visible = true, + .named = false, + }, + [anon_sym_DASH] = { + .visible = true, + .named = false, + }, + [anon_sym_LBRACK] = { + .visible = true, + .named = false, + }, + [anon_sym_COMMA] = { + .visible = true, + .named = false, + }, + [anon_sym_RBRACK] = { + .visible = true, + .named = false, + }, + [anon_sym_message] = { + .visible = true, + .named = false, + }, + [anon_sym_extend] = { + .visible = true, + .named = false, + }, + [anon_sym_optional] = { + .visible = true, + .named = false, + }, + [anon_sym_required] = { + .visible = true, + .named = false, + }, + [anon_sym_repeated] = { + .visible = true, + .named = false, + }, + [anon_sym_group] = { + .visible = true, + .named = false, + }, + [anon_sym_oneof] = { + .visible = true, + .named = false, + }, + [anon_sym_map] = { + .visible = true, + .named = false, + }, + [anon_sym_LT] = { + .visible = true, + .named = false, + }, + [anon_sym_GT] = { + .visible = true, + .named = false, + }, + [anon_sym_int32] = { + .visible = true, + .named = false, + }, + [anon_sym_int64] = { + .visible = true, + .named = false, + }, + [anon_sym_uint32] = { + .visible = true, + .named = false, + }, + [anon_sym_uint64] = { + .visible = true, + .named = false, + }, + [anon_sym_sint32] = { + .visible = true, + .named = false, + }, + [anon_sym_sint64] = { + .visible = true, + .named = false, + }, + [anon_sym_fixed32] = { + .visible = true, + .named = false, + }, + [anon_sym_fixed64] = { + .visible = true, + .named = false, + }, + [anon_sym_sfixed32] = { + .visible = true, + .named = false, + }, + [anon_sym_sfixed64] = { + .visible = true, + .named = false, + }, + [anon_sym_bool] = { + .visible = true, + .named = false, + }, + [anon_sym_string] = { + .visible = true, + .named = false, + }, + [anon_sym_double] = { + .visible = true, + .named = false, + }, + [anon_sym_float] = { + .visible = true, + .named = false, + }, + [anon_sym_bytes] = { + .visible = true, + .named = false, + }, + [anon_sym_reserved] = { + .visible = true, + .named = false, + }, + [anon_sym_extensions] = { + .visible = true, + .named = false, + }, + [anon_sym_to] = { + .visible = true, + .named = false, + }, + [anon_sym_max] = { + .visible = true, + .named = false, + }, + [anon_sym_service] = { + .visible = true, + .named = false, + }, + [anon_sym_rpc] = { + .visible = true, + .named = false, + }, + [anon_sym_stream] = { + .visible = true, + .named = false, + }, + [anon_sym_returns] = { + .visible = true, + .named = false, + }, + [anon_sym_PLUS] = { + .visible = true, + .named = false, + }, + [anon_sym_COLON] = { + .visible = true, + .named = false, + }, + [sym_identifier] = { + .visible = true, + .named = true, + }, + [sym_reserved_identifier] = { + .visible = true, + .named = true, + }, + [sym_true] = { + .visible = true, + .named = true, + }, + [sym_false] = { + .visible = true, + .named = true, + }, + [sym_decimal_lit] = { + .visible = true, + .named = true, + }, + [sym_octal_lit] = { + .visible = true, + .named = true, + }, + [sym_hex_lit] = { + .visible = true, + .named = true, + }, + [sym_float_lit] = { + .visible = true, + .named = true, + }, + [anon_sym_DQUOTE] = { + .visible = true, + .named = false, + }, + [aux_sym_string_token1] = { + .visible = false, + .named = false, + }, + [anon_sym_SQUOTE] = { + .visible = true, + .named = false, + }, + [aux_sym_string_token2] = { + .visible = false, + .named = false, + }, + [sym_escape_sequence] = { + .visible = true, + .named = true, + }, + [sym_comment] = { + .visible = true, + .named = true, + }, + [sym_source_file] = { + .visible = true, + .named = true, + }, + [sym_empty_statement] = { + .visible = true, + .named = true, + }, + [sym_edition] = { + .visible = true, + .named = true, + }, + [sym_syntax] = { + .visible = true, + .named = true, + }, + [sym_import] = { + .visible = true, + .named = true, + }, + [sym_package] = { + .visible = true, + .named = true, + }, + [sym_option] = { + .visible = true, + .named = true, + }, + [sym__option_name] = { + .visible = false, + .named = true, + }, + [sym_enum] = { + .visible = true, + .named = true, + }, + [sym_enum_name] = { + .visible = true, + .named = true, + }, + [sym_enum_body] = { + .visible = true, + .named = true, + }, + [sym_enum_field] = { + .visible = true, + .named = true, + }, + [sym_enum_value_option] = { + .visible = true, + .named = true, + }, + [sym_message] = { + .visible = true, + .named = true, + }, + [sym_message_body] = { + .visible = true, + .named = true, + }, + [sym_message_name] = { + .visible = true, + .named = true, + }, + [sym_extend] = { + .visible = true, + .named = true, + }, + [sym_group] = { + .visible = true, + .named = true, + }, + [sym_field] = { + .visible = true, + .named = true, + }, + [sym_field_options] = { + .visible = true, + .named = true, + }, + [sym_field_option] = { + .visible = true, + .named = true, + }, + [sym_oneof] = { + .visible = true, + .named = true, + }, + [sym_oneof_field] = { + .visible = true, + .named = true, + }, + [sym_map_field] = { + .visible = true, + .named = true, + }, + [sym_key_type] = { + .visible = true, + .named = true, + }, + [sym_type] = { + .visible = true, + .named = true, + }, + [sym_reserved] = { + .visible = true, + .named = true, + }, + [sym_extensions] = { + .visible = true, + .named = true, + }, + [sym_ranges] = { + .visible = true, + .named = true, + }, + [sym_range] = { + .visible = true, + .named = true, + }, + [sym_reserved_field_names] = { + .visible = true, + .named = true, + }, + [sym_message_or_enum_type] = { + .visible = true, + .named = true, + }, + [sym_field_number] = { + .visible = true, + .named = true, + }, + [sym_service] = { + .visible = true, + .named = true, + }, + [sym_service_name] = { + .visible = true, + .named = true, + }, + [sym_rpc] = { + .visible = true, + .named = true, + }, + [sym_rpc_name] = { + .visible = true, + .named = true, + }, + [sym_constant] = { + .visible = true, + .named = true, + }, + [sym_block_lit] = { + .visible = true, + .named = true, + }, + [sym_full_ident] = { + .visible = true, + .named = true, + }, + [sym_bool] = { + .visible = true, + .named = true, + }, + [sym_int_lit] = { + .visible = true, + .named = true, + }, + [sym_string] = { + .visible = true, + .named = true, + }, + [aux_sym_source_file_repeat1] = { + .visible = false, + .named = false, + }, + [aux_sym__option_name_repeat1] = { + .visible = false, + .named = false, + }, + [aux_sym_enum_body_repeat1] = { + .visible = false, + .named = false, + }, + [aux_sym_enum_field_repeat1] = { + .visible = false, + .named = false, + }, + [aux_sym_message_body_repeat1] = { + .visible = false, + .named = false, + }, + [aux_sym_field_options_repeat1] = { + .visible = false, + .named = false, + }, + [aux_sym_oneof_repeat1] = { + .visible = false, + .named = false, + }, + [aux_sym_ranges_repeat1] = { + .visible = false, + .named = false, + }, + [aux_sym_reserved_field_names_repeat1] = { + .visible = false, + .named = false, + }, + [aux_sym_message_or_enum_type_repeat1] = { + .visible = false, + .named = false, + }, + [aux_sym_service_repeat1] = { + .visible = false, + .named = false, + }, + [aux_sym_rpc_repeat1] = { + .visible = false, + .named = false, + }, + [aux_sym_block_lit_repeat1] = { + .visible = false, + .named = false, + }, + [aux_sym_block_lit_repeat2] = { + .visible = false, + .named = false, + }, + [aux_sym_string_repeat1] = { + .visible = false, + .named = false, + }, + [aux_sym_string_repeat2] = { + .visible = false, + .named = false, + }, + [aux_sym_string_repeat3] = { + .visible = false, + .named = false, + }, +}; + +enum ts_field_identifiers { + field_path = 1, + field_year = 2, +}; + +static const char * const ts_field_names[] = { + [0] = NULL, + [field_path] = "path", + [field_year] = "year", +}; + +static const TSFieldMapSlice ts_field_map_slices[PRODUCTION_ID_COUNT] = { + [1] = {.index = 0, .length = 1}, + [2] = {.index = 1, .length = 1}, + [3] = {.index = 2, .length = 1}, +}; + +static const TSFieldMapEntry ts_field_map_entries[] = { + [0] = + {field_path, 1}, + [1] = + {field_year, 2}, + [2] = + {field_path, 2}, +}; + +static const TSSymbol ts_alias_sequences[PRODUCTION_ID_COUNT][MAX_ALIAS_SEQUENCE_LENGTH] = { + [0] = {0}, +}; + +static const uint16_t ts_non_terminal_alias_map[] = { + 0, +}; + +static const TSStateId ts_primary_state_ids[STATE_COUNT] = { + [0] = 0, + [1] = 1, + [2] = 2, + [3] = 3, + [4] = 4, + [5] = 3, + [6] = 2, + [7] = 7, + [8] = 8, + [9] = 9, + [10] = 10, + [11] = 11, + [12] = 12, + [13] = 13, + [14] = 14, + [15] = 15, + [16] = 16, + [17] = 17, + [18] = 18, + [19] = 19, + [20] = 20, + [21] = 21, + [22] = 22, + [23] = 23, + [24] = 24, + [25] = 25, + [26] = 26, + [27] = 27, + [28] = 28, + [29] = 29, + [30] = 30, + [31] = 31, + [32] = 32, + [33] = 33, + [34] = 34, + [35] = 35, + [36] = 36, + [37] = 37, + [38] = 38, + [39] = 39, + [40] = 40, + [41] = 41, + [42] = 42, + [43] = 43, + [44] = 44, + [45] = 45, + [46] = 46, + [47] = 47, + [48] = 48, + [49] = 49, + [50] = 50, + [51] = 51, + [52] = 52, + [53] = 53, + [54] = 54, + [55] = 30, + [56] = 8, + [57] = 57, + [58] = 57, + [59] = 59, + [60] = 60, + [61] = 61, + [62] = 57, + [63] = 57, + [64] = 8, + [65] = 65, + [66] = 30, + [67] = 67, + [68] = 68, + [69] = 28, + [70] = 22, + [71] = 71, + [72] = 7, + [73] = 73, + [74] = 21, + [75] = 23, + [76] = 76, + [77] = 77, + [78] = 78, + [79] = 24, + [80] = 25, + [81] = 26, + [82] = 27, + [83] = 83, + [84] = 84, + [85] = 85, + [86] = 86, + [87] = 87, + [88] = 86, + [89] = 89, + [90] = 90, + [91] = 87, + [92] = 92, + [93] = 93, + [94] = 94, + [95] = 95, + [96] = 96, + [97] = 97, + [98] = 95, + [99] = 99, + [100] = 100, + [101] = 101, + [102] = 102, + [103] = 103, + [104] = 104, + [105] = 105, + [106] = 106, + [107] = 107, + [108] = 108, + [109] = 39, + [110] = 110, + [111] = 111, + [112] = 112, + [113] = 113, + [114] = 114, + [115] = 115, + [116] = 116, + [117] = 117, + [118] = 118, + [119] = 119, + [120] = 120, + [121] = 121, + [122] = 122, + [123] = 123, + [124] = 30, + [125] = 125, + [126] = 126, + [127] = 127, + [128] = 39, + [129] = 129, + [130] = 130, + [131] = 131, + [132] = 132, + [133] = 8, + [134] = 134, + [135] = 135, + [136] = 136, + [137] = 137, + [138] = 138, + [139] = 139, + [140] = 140, + [141] = 141, + [142] = 142, + [143] = 143, + [144] = 116, + [145] = 145, + [146] = 146, + [147] = 147, + [148] = 29, + [149] = 149, + [150] = 150, + [151] = 151, + [152] = 152, + [153] = 153, + [154] = 154, + [155] = 155, + [156] = 156, + [157] = 157, + [158] = 158, + [159] = 159, + [160] = 160, + [161] = 161, + [162] = 162, + [163] = 163, + [164] = 164, + [165] = 165, + [166] = 166, + [167] = 167, + [168] = 168, + [169] = 169, + [170] = 170, + [171] = 171, + [172] = 172, + [173] = 173, + [174] = 174, + [175] = 175, + [176] = 176, + [177] = 177, + [178] = 178, + [179] = 179, + [180] = 180, + [181] = 181, + [182] = 182, + [183] = 183, + [184] = 184, + [185] = 185, + [186] = 186, + [187] = 187, + [188] = 188, + [189] = 189, + [190] = 190, + [191] = 191, + [192] = 192, + [193] = 193, + [194] = 194, + [195] = 195, + [196] = 196, + [197] = 197, + [198] = 198, + [199] = 199, + [200] = 200, + [201] = 201, + [202] = 202, + [203] = 203, + [204] = 204, + [205] = 188, + [206] = 206, + [207] = 207, + [208] = 208, + [209] = 209, + [210] = 210, + [211] = 211, + [212] = 212, + [213] = 213, + [214] = 214, + [215] = 188, + [216] = 188, + [217] = 217, + [218] = 218, + [219] = 219, + [220] = 220, + [221] = 221, + [222] = 222, + [223] = 223, + [224] = 224, + [225] = 225, + [226] = 226, + [227] = 227, + [228] = 228, + [229] = 229, + [230] = 230, + [231] = 231, + [232] = 232, + [233] = 233, + [234] = 234, + [235] = 235, + [236] = 236, + [237] = 237, + [238] = 238, + [239] = 239, + [240] = 240, + [241] = 241, + [242] = 242, + [243] = 243, + [244] = 244, + [245] = 245, + [246] = 246, + [247] = 247, + [248] = 248, + [249] = 249, + [250] = 250, + [251] = 251, + [252] = 252, + [253] = 253, + [254] = 254, + [255] = 255, + [256] = 256, + [257] = 247, + [258] = 258, + [259] = 259, + [260] = 243, + [261] = 244, + [262] = 258, + [263] = 221, + [264] = 228, + [265] = 255, + [266] = 227, + [267] = 248, + [268] = 259, + [269] = 269, + [270] = 254, + [271] = 271, + [272] = 272, + [273] = 273, + [274] = 274, + [275] = 275, + [276] = 276, + [277] = 277, + [278] = 278, + [279] = 279, + [280] = 280, + [281] = 281, + [282] = 282, + [283] = 283, + [284] = 284, + [285] = 285, + [286] = 286, + [287] = 287, + [288] = 288, + [289] = 289, + [290] = 290, + [291] = 291, + [292] = 292, + [293] = 293, + [294] = 294, + [295] = 295, + [296] = 296, + [297] = 297, + [298] = 298, + [299] = 299, + [300] = 300, + [301] = 301, + [302] = 302, + [303] = 303, + [304] = 304, + [305] = 305, + [306] = 306, + [307] = 307, + [308] = 308, + [309] = 309, + [310] = 310, + [311] = 311, + [312] = 312, + [313] = 313, + [314] = 314, + [315] = 315, + [316] = 316, + [317] = 317, + [318] = 318, + [319] = 319, + [320] = 320, + [321] = 321, + [322] = 308, + [323] = 323, + [324] = 324, + [325] = 303, + [326] = 308, + [327] = 308, + [328] = 328, + [329] = 329, + [330] = 330, + [331] = 331, + [332] = 332, + [333] = 333, + [334] = 334, + [335] = 335, + [336] = 336, + [337] = 337, + [338] = 338, + [339] = 338, + [340] = 338, + [341] = 341, + [342] = 342, + [343] = 343, + [344] = 338, +}; + +static bool ts_lex(TSLexer *lexer, TSStateId state) { + START_LEXER(); + eof = lexer->eof(lexer); + switch (state) { + case 0: + if (eof) ADVANCE(212); + ADVANCE_MAP( + '"', 450, + '\'', 457, + '(', 227, + ')', 228, + '+', 302, + ',', 241, + '-', 239, + '.', 230, + '/', 11, + '0', 442, + ':', 303, + ';', 213, + '<', 259, + '=', 215, + '>', 260, + '[', 240, + '\\', 38, + ']', 242, + 'b', 138, + 'd', 132, + 'e', 64, + 'f', 39, + 'g', 165, + 'i', 115, + 'l', 133, + 'm', 40, + 'n', 41, + 'o', 123, + 'p', 44, + 'r', 68, + 's', 76, + 't', 134, + 'u', 105, + 'w', 78, + '{', 237, + '}', 238, + ); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') SKIP(210); + if (('1' <= lookahead && lookahead <= '9')) ADVANCE(440); + END_STATE(); + case 1: + ADVANCE_MAP( + '"', 450, + '\'', 457, + '(', 227, + ')', 228, + ',', 241, + '.', 229, + '/', 11, + ';', 213, + '=', 215, + '>', 260, + '[', 240, + ']', 242, + '{', 237, + '}', 238, + ); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') SKIP(1); + if (('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 2: + ADVANCE_MAP( + '"', 450, + '\'', 457, + '+', 302, + '-', 239, + '.', 197, + '/', 11, + '0', 442, + ':', 303, + '[', 240, + ']', 242, + 'f', 324, + 'i', 380, + 'n', 325, + 't', 405, + '{', 237, + ); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') SKIP(2); + if (('1' <= lookahead && lookahead <= '9')) ADVANCE(440); + if (('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 3: + if (lookahead == '"') ADVANCE(450); + if (lookahead == '/') ADVANCE(452); + if (lookahead == '\\') ADVANCE(38); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') ADVANCE(455); + if (lookahead != 0) ADVANCE(456); + END_STATE(); + case 4: + if (lookahead == '"') ADVANCE(218); + END_STATE(); + case 5: + if (lookahead == '"') ADVANCE(217); + END_STATE(); + case 6: + if (lookahead == '"') ADVANCE(208); + if (lookahead == '\'') ADVANCE(209); + if (lookahead == '/') ADVANCE(11); + if (lookahead == '0') ADVANCE(444); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') SKIP(6); + if (('1' <= lookahead && lookahead <= '9')) ADVANCE(441); + if (('A' <= lookahead && lookahead <= 'Z') || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(435); + END_STATE(); + case 7: + if (lookahead == '"') ADVANCE(434); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(7); + END_STATE(); + case 8: + ADVANCE_MAP( + '"', 153, + '.', 229, + '/', 11, + ';', 213, + 'b', 390, + 'd', 385, + 'e', 379, + 'f', 358, + 'g', 408, + 'i', 378, + 'l', 386, + 'm', 319, + 'o', 377, + 'r', 335, + 's', 355, + 'u', 364, + '}', 238, + ); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') SKIP(8); + if (('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 9: + if (lookahead == '\'') ADVANCE(457); + if (lookahead == '/') ADVANCE(459); + if (lookahead == '\\') ADVANCE(38); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') ADVANCE(462); + if (lookahead != 0) ADVANCE(463); + END_STATE(); + case 10: + if (lookahead == '\'') ADVANCE(434); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(10); + END_STATE(); + case 11: + if (lookahead == '*') ADVANCE(13); + if (lookahead == '/') ADVANCE(468); + END_STATE(); + case 12: + if (lookahead == '*') ADVANCE(12); + if (lookahead == '/') ADVANCE(467); + if (lookahead != 0) ADVANCE(13); + END_STATE(); + case 13: + if (lookahead == '*') ADVANCE(12); + if (lookahead != 0) ADVANCE(13); + END_STATE(); + case 14: + if (lookahead == '.') ADVANCE(448); + if (lookahead == 'E' || + lookahead == 'e') ADVANCE(196); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(14); + END_STATE(); + case 15: + ADVANCE_MAP( + '.', 229, + '/', 11, + ';', 213, + '[', 240, + 'b', 390, + 'd', 385, + 'f', 358, + 'i', 378, + 'o', 401, + 's', 355, + 'u', 364, + '{', 237, + '}', 238, + ); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') SKIP(15); + if (('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 16: + ADVANCE_MAP( + '.', 229, + '/', 11, + 'b', 390, + 'd', 385, + 'f', 358, + 'g', 408, + 'i', 378, + 'r', 346, + 's', 355, + 'u', 364, + ); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') SKIP(16); + if (('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 17: + ADVANCE_MAP( + '.', 229, + '/', 11, + 'b', 390, + 'd', 385, + 'f', 358, + 'g', 408, + 'i', 378, + 's', 355, + 'u', 364, + ); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') SKIP(17); + if (('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 18: + ADVANCE_MAP( + '.', 229, + '/', 11, + 'b', 390, + 'd', 385, + 'f', 358, + 'i', 378, + 's', 355, + 'u', 364, + ); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') SKIP(18); + if (('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 19: + if (lookahead == '.') ADVANCE(229); + if (lookahead == '/') ADVANCE(11); + if (lookahead == 's') ADVANCE(422); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') SKIP(19); + if (('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 20: + if (lookahead == '.') ADVANCE(197); + if (lookahead == '/') ADVANCE(11); + if (lookahead == '0') ADVANCE(442); + if (lookahead == 'i') ADVANCE(124); + if (lookahead == 'n') ADVANCE(41); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') SKIP(20); + if (('1' <= lookahead && lookahead <= '9')) ADVANCE(440); + END_STATE(); + case 21: + if (lookahead == '/') ADVANCE(11); + if (lookahead == ';') ADVANCE(213); + if (lookahead == 'o') ADVANCE(401); + if (lookahead == 'r') ADVANCE(351); + if (lookahead == '}') ADVANCE(238); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') SKIP(21); + if (('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 22: + if (lookahead == '2') ADVANCE(261); + END_STATE(); + case 23: + if (lookahead == '2') ADVANCE(269); + END_STATE(); + case 24: + if (lookahead == '2') ADVANCE(265); + END_STATE(); + case 25: + if (lookahead == '2') ADVANCE(273); + END_STATE(); + case 26: + if (lookahead == '2') ADVANCE(277); + END_STATE(); + case 27: + if (lookahead == '2') ADVANCE(4); + if (lookahead == '3') ADVANCE(5); + END_STATE(); + case 28: + if (lookahead == '3') ADVANCE(22); + if (lookahead == '6') ADVANCE(33); + END_STATE(); + case 29: + if (lookahead == '3') ADVANCE(23); + if (lookahead == '6') ADVANCE(34); + END_STATE(); + case 30: + if (lookahead == '3') ADVANCE(24); + if (lookahead == '6') ADVANCE(35); + END_STATE(); + case 31: + if (lookahead == '3') ADVANCE(25); + if (lookahead == '6') ADVANCE(36); + END_STATE(); + case 32: + if (lookahead == '3') ADVANCE(26); + if (lookahead == '6') ADVANCE(37); + END_STATE(); + case 33: + if (lookahead == '4') ADVANCE(263); + END_STATE(); + case 34: + if (lookahead == '4') ADVANCE(271); + END_STATE(); + case 35: + if (lookahead == '4') ADVANCE(267); + END_STATE(); + case 36: + if (lookahead == '4') ADVANCE(275); + END_STATE(); + case 37: + if (lookahead == '4') ADVANCE(279); + END_STATE(); + case 38: + if (lookahead == 'U') ADVANCE(207); + if (lookahead == 'u') ADVANCE(203); + if (lookahead == 'x') ADVANCE(201); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(466); + if (lookahead != 0) ADVANCE(464); + END_STATE(); + case 39: + if (lookahead == 'a') ADVANCE(111); + if (lookahead == 'i') ADVANCE(194); + if (lookahead == 'l') ADVANCE(139); + END_STATE(); + case 40: + if (lookahead == 'a') ADVANCE(148); + if (lookahead == 'e') ADVANCE(169); + END_STATE(); + case 41: + if (lookahead == 'a') ADVANCE(118); + END_STATE(); + case 42: + if (lookahead == 'a') ADVANCE(94); + END_STATE(); + case 43: + if (lookahead == 'a') ADVANCE(193); + END_STATE(); + case 44: + if (lookahead == 'a') ADVANCE(54); + if (lookahead == 'u') ADVANCE(52); + END_STATE(); + case 45: + if (lookahead == 'a') ADVANCE(117); + END_STATE(); + case 46: + if (lookahead == 'a') ADVANCE(106); + END_STATE(); + case 47: + if (lookahead == 'a') ADVANCE(192); + if (lookahead == 'e') ADVANCE(169); + END_STATE(); + case 48: + if (lookahead == 'a') ADVANCE(172); + END_STATE(); + case 49: + if (lookahead == 'a') ADVANCE(109); + END_STATE(); + case 50: + if (lookahead == 'a') ADVANCE(179); + END_STATE(); + case 51: + if (lookahead == 'a') ADVANCE(95); + END_STATE(); + case 52: + if (lookahead == 'b') ADVANCE(112); + END_STATE(); + case 53: + if (lookahead == 'b') ADVANCE(113); + END_STATE(); + case 54: + if (lookahead == 'c') ADVANCE(107); + END_STATE(); + case 55: + if (lookahead == 'c') ADVANCE(298); + END_STATE(); + case 56: + if (lookahead == 'c') ADVANCE(221); + END_STATE(); + case 57: + if (lookahead == 'c') ADVANCE(49); + END_STATE(); + case 58: + if (lookahead == 'c') ADVANCE(75); + END_STATE(); + case 59: + if (lookahead == 'd') ADVANCE(245); + END_STATE(); + case 60: + if (lookahead == 'd') ADVANCE(245); + if (lookahead == 's') ADVANCE(101); + END_STATE(); + case 61: + if (lookahead == 'd') ADVANCE(251); + END_STATE(); + case 62: + if (lookahead == 'd') ADVANCE(249); + END_STATE(); + case 63: + if (lookahead == 'd') ADVANCE(291); + END_STATE(); + case 64: + if (lookahead == 'd') ADVANCE(103); + if (lookahead == 'n') ADVANCE(184); + if (lookahead == 'x') ADVANCE(151); + END_STATE(); + case 65: + if (lookahead == 'd') ADVANCE(103); + if (lookahead == 'n') ADVANCE(184); + if (lookahead == 'x') ADVANCE(152); + END_STATE(); + case 66: + if (lookahead == 'd') ADVANCE(31); + END_STATE(); + case 67: + if (lookahead == 'd') ADVANCE(32); + END_STATE(); + case 68: + if (lookahead == 'e') ADVANCE(154); + if (lookahead == 'p') ADVANCE(55); + END_STATE(); + case 69: + if (lookahead == 'e') ADVANCE(66); + END_STATE(); + case 70: + if (lookahead == 'e') ADVANCE(436); + END_STATE(); + case 71: + if (lookahead == 'e') ADVANCE(438); + END_STATE(); + case 72: + if (lookahead == 'e') ADVANCE(285); + END_STATE(); + case 73: + if (lookahead == 'e') ADVANCE(243); + END_STATE(); + case 74: + if (lookahead == 'e') ADVANCE(226); + END_STATE(); + case 75: + if (lookahead == 'e') ADVANCE(297); + END_STATE(); + case 76: + if (lookahead == 'e') ADVANCE(157); + if (lookahead == 'f') ADVANCE(104); + if (lookahead == 'i') ADVANCE(126); + if (lookahead == 't') ADVANCE(158); + if (lookahead == 'y') ADVANCE(127); + END_STATE(); + case 77: + if (lookahead == 'e') ADVANCE(157); + if (lookahead == 'y') ADVANCE(127); + END_STATE(); + case 78: + if (lookahead == 'e') ADVANCE(46); + END_STATE(); + case 79: + if (lookahead == 'e') ADVANCE(61); + END_STATE(); + case 80: + if (lookahead == 'e') ADVANCE(119); + END_STATE(); + case 81: + if (lookahead == 'e') ADVANCE(62); + END_STATE(); + case 82: + if (lookahead == 'e') ADVANCE(166); + END_STATE(); + case 83: + if (lookahead == 'e') ADVANCE(63); + END_STATE(); + case 84: + if (lookahead == 'e') ADVANCE(159); + END_STATE(); + case 85: + if (lookahead == 'e') ADVANCE(135); + END_STATE(); + case 86: + if (lookahead == 'e') ADVANCE(50); + END_STATE(); + case 87: + if (lookahead == 'e') ADVANCE(45); + if (lookahead == 'i') ADVANCE(125); + END_STATE(); + case 88: + if (lookahead == 'e') ADVANCE(129); + END_STATE(); + case 89: + if (lookahead == 'e') ADVANCE(67); + END_STATE(); + case 90: + if (lookahead == 'f') ADVANCE(447); + END_STATE(); + case 91: + if (lookahead == 'f') ADVANCE(447); + if (lookahead == 't') ADVANCE(28); + END_STATE(); + case 92: + if (lookahead == 'f') ADVANCE(255); + END_STATE(); + case 93: + if (lookahead == 'g') ADVANCE(283); + END_STATE(); + case 94: + if (lookahead == 'g') ADVANCE(73); + END_STATE(); + case 95: + if (lookahead == 'g') ADVANCE(74); + END_STATE(); + case 96: + if (lookahead == 'i') ADVANCE(56); + END_STATE(); + case 97: + if (lookahead == 'i') ADVANCE(58); + END_STATE(); + case 98: + if (lookahead == 'i') ADVANCE(142); + END_STATE(); + case 99: + if (lookahead == 'i') ADVANCE(164); + END_STATE(); + case 100: + if (lookahead == 'i') ADVANCE(143); + END_STATE(); + case 101: + if (lookahead == 'i') ADVANCE(145); + END_STATE(); + case 102: + if (lookahead == 'i') ADVANCE(146); + END_STATE(); + case 103: + if (lookahead == 'i') ADVANCE(181); + END_STATE(); + case 104: + if (lookahead == 'i') ADVANCE(195); + END_STATE(); + case 105: + if (lookahead == 'i') ADVANCE(131); + END_STATE(); + case 106: + if (lookahead == 'k') ADVANCE(220); + END_STATE(); + case 107: + if (lookahead == 'k') ADVANCE(51); + END_STATE(); + case 108: + if (lookahead == 'l') ADVANCE(281); + END_STATE(); + case 109: + if (lookahead == 'l') ADVANCE(233); + END_STATE(); + case 110: + if (lookahead == 'l') ADVANCE(247); + END_STATE(); + case 111: + if (lookahead == 'l') ADVANCE(171); + END_STATE(); + case 112: + if (lookahead == 'l') ADVANCE(96); + END_STATE(); + case 113: + if (lookahead == 'l') ADVANCE(72); + END_STATE(); + case 114: + if (lookahead == 'm') ADVANCE(155); + END_STATE(); + case 115: + if (lookahead == 'm') ADVANCE(155); + if (lookahead == 'n') ADVANCE(91); + END_STATE(); + case 116: + if (lookahead == 'm') ADVANCE(235); + END_STATE(); + case 117: + if (lookahead == 'm') ADVANCE(299); + END_STATE(); + case 118: + if (lookahead == 'n') ADVANCE(447); + END_STATE(); + case 119: + if (lookahead == 'n') ADVANCE(60); + END_STATE(); + case 120: + if (lookahead == 'n') ADVANCE(224); + END_STATE(); + case 121: + if (lookahead == 'n') ADVANCE(214); + END_STATE(); + case 122: + if (lookahead == 'n') ADVANCE(222); + END_STATE(); + case 123: + if (lookahead == 'n') ADVANCE(85); + if (lookahead == 'p') ADVANCE(176); + END_STATE(); + case 124: + if (lookahead == 'n') ADVANCE(90); + END_STATE(); + case 125: + if (lookahead == 'n') ADVANCE(93); + END_STATE(); + case 126: + if (lookahead == 'n') ADVANCE(180); + END_STATE(); + case 127: + if (lookahead == 'n') ADVANCE(177); + END_STATE(); + case 128: + if (lookahead == 'n') ADVANCE(167); + END_STATE(); + case 129: + if (lookahead == 'n') ADVANCE(59); + END_STATE(); + case 130: + if (lookahead == 'n') ADVANCE(168); + END_STATE(); + case 131: + if (lookahead == 'n') ADVANCE(182); + END_STATE(); + case 132: + if (lookahead == 'o') ADVANCE(189); + END_STATE(); + case 133: + if (lookahead == 'o') ADVANCE(57); + END_STATE(); + case 134: + if (lookahead == 'o') ADVANCE(295); + if (lookahead == 'r') ADVANCE(188); + END_STATE(); + case 135: + if (lookahead == 'o') ADVANCE(92); + END_STATE(); + case 136: + if (lookahead == 'o') ADVANCE(27); + END_STATE(); + case 137: + if (lookahead == 'o') ADVANCE(108); + END_STATE(); + case 138: + if (lookahead == 'o') ADVANCE(137); + if (lookahead == 'y') ADVANCE(175); + END_STATE(); + case 139: + if (lookahead == 'o') ADVANCE(48); + END_STATE(); + case 140: + if (lookahead == 'o') ADVANCE(160); + END_STATE(); + case 141: + if (lookahead == 'o') ADVANCE(185); + END_STATE(); + case 142: + if (lookahead == 'o') ADVANCE(120); + END_STATE(); + case 143: + if (lookahead == 'o') ADVANCE(121); + END_STATE(); + case 144: + if (lookahead == 'o') ADVANCE(178); + END_STATE(); + case 145: + if (lookahead == 'o') ADVANCE(130); + END_STATE(); + case 146: + if (lookahead == 'o') ADVANCE(122); + END_STATE(); + case 147: + if (lookahead == 'o') ADVANCE(162); + END_STATE(); + case 148: + if (lookahead == 'p') ADVANCE(257); + if (lookahead == 'x') ADVANCE(296); + END_STATE(); + case 149: + if (lookahead == 'p') ADVANCE(253); + END_STATE(); + case 150: + if (lookahead == 'p') ADVANCE(55); + END_STATE(); + case 151: + if (lookahead == 'p') ADVANCE(140); + if (lookahead == 't') ADVANCE(80); + END_STATE(); + case 152: + if (lookahead == 'p') ADVANCE(140); + if (lookahead == 't') ADVANCE(88); + END_STATE(); + case 153: + if (lookahead == 'p') ADVANCE(163); + END_STATE(); + case 154: + if (lookahead == 'p') ADVANCE(86); + if (lookahead == 'q') ADVANCE(187); + if (lookahead == 's') ADVANCE(84); + if (lookahead == 't') ADVANCE(186); + END_STATE(); + case 155: + if (lookahead == 'p') ADVANCE(147); + END_STATE(); + case 156: + if (lookahead == 'p') ADVANCE(183); + END_STATE(); + case 157: + if (lookahead == 'r') ADVANCE(190); + END_STATE(); + case 158: + if (lookahead == 'r') ADVANCE(87); + END_STATE(); + case 159: + if (lookahead == 'r') ADVANCE(191); + END_STATE(); + case 160: + if (lookahead == 'r') ADVANCE(173); + END_STATE(); + case 161: + if (lookahead == 'r') ADVANCE(128); + END_STATE(); + case 162: + if (lookahead == 'r') ADVANCE(174); + END_STATE(); + case 163: + if (lookahead == 'r') ADVANCE(144); + END_STATE(); + case 164: + if (lookahead == 'r') ADVANCE(81); + END_STATE(); + case 165: + if (lookahead == 'r') ADVANCE(141); + END_STATE(); + case 166: + if (lookahead == 's') ADVANCE(289); + END_STATE(); + case 167: + if (lookahead == 's') ADVANCE(301); + END_STATE(); + case 168: + if (lookahead == 's') ADVANCE(293); + END_STATE(); + case 169: + if (lookahead == 's') ADVANCE(170); + END_STATE(); + case 170: + if (lookahead == 's') ADVANCE(42); + END_STATE(); + case 171: + if (lookahead == 's') ADVANCE(71); + END_STATE(); + case 172: + if (lookahead == 't') ADVANCE(287); + END_STATE(); + case 173: + if (lookahead == 't') ADVANCE(231); + END_STATE(); + case 174: + if (lookahead == 't') ADVANCE(219); + END_STATE(); + case 175: + if (lookahead == 't') ADVANCE(82); + END_STATE(); + case 176: + if (lookahead == 't') ADVANCE(98); + END_STATE(); + case 177: + if (lookahead == 't') ADVANCE(43); + END_STATE(); + case 178: + if (lookahead == 't') ADVANCE(136); + END_STATE(); + case 179: + if (lookahead == 't') ADVANCE(79); + END_STATE(); + case 180: + if (lookahead == 't') ADVANCE(29); + END_STATE(); + case 181: + if (lookahead == 't') ADVANCE(100); + END_STATE(); + case 182: + if (lookahead == 't') ADVANCE(30); + END_STATE(); + case 183: + if (lookahead == 't') ADVANCE(102); + END_STATE(); + case 184: + if (lookahead == 'u') ADVANCE(116); + END_STATE(); + case 185: + if (lookahead == 'u') ADVANCE(149); + END_STATE(); + case 186: + if (lookahead == 'u') ADVANCE(161); + END_STATE(); + case 187: + if (lookahead == 'u') ADVANCE(99); + END_STATE(); + case 188: + if (lookahead == 'u') ADVANCE(70); + END_STATE(); + case 189: + if (lookahead == 'u') ADVANCE(53); + END_STATE(); + case 190: + if (lookahead == 'v') ADVANCE(97); + END_STATE(); + case 191: + if (lookahead == 'v') ADVANCE(83); + END_STATE(); + case 192: + if (lookahead == 'x') ADVANCE(296); + END_STATE(); + case 193: + if (lookahead == 'x') ADVANCE(216); + END_STATE(); + case 194: + if (lookahead == 'x') ADVANCE(69); + END_STATE(); + case 195: + if (lookahead == 'x') ADVANCE(89); + END_STATE(); + case 196: + if (lookahead == '+' || + lookahead == '-') ADVANCE(198); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(449); + END_STATE(); + case 197: + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(448); + END_STATE(); + case 198: + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(449); + END_STATE(); + case 199: + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'F') || + ('a' <= lookahead && lookahead <= 'f')) ADVANCE(464); + END_STATE(); + case 200: + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'F') || + ('a' <= lookahead && lookahead <= 'f')) ADVANCE(446); + END_STATE(); + case 201: + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'F') || + ('a' <= lookahead && lookahead <= 'f')) ADVANCE(199); + END_STATE(); + case 202: + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'F') || + ('a' <= lookahead && lookahead <= 'f')) ADVANCE(201); + END_STATE(); + case 203: + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'F') || + ('a' <= lookahead && lookahead <= 'f')) ADVANCE(202); + END_STATE(); + case 204: + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'F') || + ('a' <= lookahead && lookahead <= 'f')) ADVANCE(203); + END_STATE(); + case 205: + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'F') || + ('a' <= lookahead && lookahead <= 'f')) ADVANCE(204); + END_STATE(); + case 206: + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'F') || + ('a' <= lookahead && lookahead <= 'f')) ADVANCE(205); + END_STATE(); + case 207: + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'F') || + ('a' <= lookahead && lookahead <= 'f')) ADVANCE(206); + END_STATE(); + case 208: + if (('A' <= lookahead && lookahead <= 'Z') || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(7); + END_STATE(); + case 209: + if (('A' <= lookahead && lookahead <= 'Z') || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(10); + END_STATE(); + case 210: + if (eof) ADVANCE(212); + ADVANCE_MAP( + '"', 450, + '\'', 457, + '(', 227, + ')', 228, + '+', 302, + ',', 241, + '-', 239, + '.', 230, + '/', 11, + '0', 442, + ':', 303, + ';', 213, + '<', 259, + '=', 215, + '>', 260, + '[', 240, + ']', 242, + 'b', 138, + 'd', 132, + 'e', 64, + 'f', 39, + 'g', 165, + 'i', 115, + 'l', 133, + 'm', 40, + 'n', 41, + 'o', 123, + 'p', 44, + 'r', 68, + 's', 76, + 't', 134, + 'u', 105, + 'w', 78, + '{', 237, + '}', 238, + ); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') SKIP(210); + if (('1' <= lookahead && lookahead <= '9')) ADVANCE(440); + END_STATE(); + case 211: + if (eof) ADVANCE(212); + ADVANCE_MAP( + '"', 450, + '\'', 457, + '-', 239, + '.', 229, + '/', 11, + '0', 444, + ';', 213, + '=', 215, + 'e', 65, + 'i', 114, + 'l', 133, + 'm', 47, + 'o', 156, + 'p', 44, + 'r', 150, + 's', 77, + 'w', 78, + '}', 238, + ); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') SKIP(211); + if (('1' <= lookahead && lookahead <= '9')) ADVANCE(441); + END_STATE(); + case 212: + ACCEPT_TOKEN(ts_builtin_sym_end); + END_STATE(); + case 213: + ACCEPT_TOKEN(anon_sym_SEMI); + END_STATE(); + case 214: + ACCEPT_TOKEN(anon_sym_edition); + END_STATE(); + case 215: + ACCEPT_TOKEN(anon_sym_EQ); + END_STATE(); + case 216: + ACCEPT_TOKEN(anon_sym_syntax); + END_STATE(); + case 217: + ACCEPT_TOKEN(anon_sym_DQUOTEproto3_DQUOTE); + END_STATE(); + case 218: + ACCEPT_TOKEN(anon_sym_DQUOTEproto2_DQUOTE); + END_STATE(); + case 219: + ACCEPT_TOKEN(anon_sym_import); + END_STATE(); + case 220: + ACCEPT_TOKEN(anon_sym_weak); + END_STATE(); + case 221: + ACCEPT_TOKEN(anon_sym_public); + END_STATE(); + case 222: + ACCEPT_TOKEN(anon_sym_option); + END_STATE(); + case 223: + ACCEPT_TOKEN(anon_sym_option); + if (lookahead == 'a') ADVANCE(368); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('b' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 224: + ACCEPT_TOKEN(anon_sym_option); + if (lookahead == 'a') ADVANCE(110); + END_STATE(); + case 225: + ACCEPT_TOKEN(anon_sym_option); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 226: + ACCEPT_TOKEN(anon_sym_package); + END_STATE(); + case 227: + ACCEPT_TOKEN(anon_sym_LPAREN); + END_STATE(); + case 228: + ACCEPT_TOKEN(anon_sym_RPAREN); + END_STATE(); + case 229: + ACCEPT_TOKEN(anon_sym_DOT); + END_STATE(); + case 230: + ACCEPT_TOKEN(anon_sym_DOT); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(448); + END_STATE(); + case 231: + ACCEPT_TOKEN(anon_sym_export); + END_STATE(); + case 232: + ACCEPT_TOKEN(anon_sym_export); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 233: + ACCEPT_TOKEN(anon_sym_local); + END_STATE(); + case 234: + ACCEPT_TOKEN(anon_sym_local); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 235: + ACCEPT_TOKEN(anon_sym_enum); + END_STATE(); + case 236: + ACCEPT_TOKEN(anon_sym_enum); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 237: + ACCEPT_TOKEN(anon_sym_LBRACE); + END_STATE(); + case 238: + ACCEPT_TOKEN(anon_sym_RBRACE); + END_STATE(); + case 239: + ACCEPT_TOKEN(anon_sym_DASH); + END_STATE(); + case 240: + ACCEPT_TOKEN(anon_sym_LBRACK); + END_STATE(); + case 241: + ACCEPT_TOKEN(anon_sym_COMMA); + END_STATE(); + case 242: + ACCEPT_TOKEN(anon_sym_RBRACK); + END_STATE(); + case 243: + ACCEPT_TOKEN(anon_sym_message); + END_STATE(); + case 244: + ACCEPT_TOKEN(anon_sym_message); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 245: + ACCEPT_TOKEN(anon_sym_extend); + END_STATE(); + case 246: + ACCEPT_TOKEN(anon_sym_extend); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 247: + ACCEPT_TOKEN(anon_sym_optional); + END_STATE(); + case 248: + ACCEPT_TOKEN(anon_sym_optional); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 249: + ACCEPT_TOKEN(anon_sym_required); + END_STATE(); + case 250: + ACCEPT_TOKEN(anon_sym_required); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 251: + ACCEPT_TOKEN(anon_sym_repeated); + END_STATE(); + case 252: + ACCEPT_TOKEN(anon_sym_repeated); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 253: + ACCEPT_TOKEN(anon_sym_group); + END_STATE(); + case 254: + ACCEPT_TOKEN(anon_sym_group); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 255: + ACCEPT_TOKEN(anon_sym_oneof); + END_STATE(); + case 256: + ACCEPT_TOKEN(anon_sym_oneof); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 257: + ACCEPT_TOKEN(anon_sym_map); + END_STATE(); + case 258: + ACCEPT_TOKEN(anon_sym_map); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 259: + ACCEPT_TOKEN(anon_sym_LT); + END_STATE(); + case 260: + ACCEPT_TOKEN(anon_sym_GT); + END_STATE(); + case 261: + ACCEPT_TOKEN(anon_sym_int32); + END_STATE(); + case 262: + ACCEPT_TOKEN(anon_sym_int32); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 263: + ACCEPT_TOKEN(anon_sym_int64); + END_STATE(); + case 264: + ACCEPT_TOKEN(anon_sym_int64); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 265: + ACCEPT_TOKEN(anon_sym_uint32); + END_STATE(); + case 266: + ACCEPT_TOKEN(anon_sym_uint32); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 267: + ACCEPT_TOKEN(anon_sym_uint64); + END_STATE(); + case 268: + ACCEPT_TOKEN(anon_sym_uint64); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 269: + ACCEPT_TOKEN(anon_sym_sint32); + END_STATE(); + case 270: + ACCEPT_TOKEN(anon_sym_sint32); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 271: + ACCEPT_TOKEN(anon_sym_sint64); + END_STATE(); + case 272: + ACCEPT_TOKEN(anon_sym_sint64); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 273: + ACCEPT_TOKEN(anon_sym_fixed32); + END_STATE(); + case 274: + ACCEPT_TOKEN(anon_sym_fixed32); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 275: + ACCEPT_TOKEN(anon_sym_fixed64); + END_STATE(); + case 276: + ACCEPT_TOKEN(anon_sym_fixed64); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 277: + ACCEPT_TOKEN(anon_sym_sfixed32); + END_STATE(); + case 278: + ACCEPT_TOKEN(anon_sym_sfixed32); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 279: + ACCEPT_TOKEN(anon_sym_sfixed64); + END_STATE(); + case 280: + ACCEPT_TOKEN(anon_sym_sfixed64); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 281: + ACCEPT_TOKEN(anon_sym_bool); + END_STATE(); + case 282: + ACCEPT_TOKEN(anon_sym_bool); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 283: + ACCEPT_TOKEN(anon_sym_string); + END_STATE(); + case 284: + ACCEPT_TOKEN(anon_sym_string); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 285: + ACCEPT_TOKEN(anon_sym_double); + END_STATE(); + case 286: + ACCEPT_TOKEN(anon_sym_double); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 287: + ACCEPT_TOKEN(anon_sym_float); + END_STATE(); + case 288: + ACCEPT_TOKEN(anon_sym_float); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 289: + ACCEPT_TOKEN(anon_sym_bytes); + END_STATE(); + case 290: + ACCEPT_TOKEN(anon_sym_bytes); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 291: + ACCEPT_TOKEN(anon_sym_reserved); + END_STATE(); + case 292: + ACCEPT_TOKEN(anon_sym_reserved); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 293: + ACCEPT_TOKEN(anon_sym_extensions); + END_STATE(); + case 294: + ACCEPT_TOKEN(anon_sym_extensions); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 295: + ACCEPT_TOKEN(anon_sym_to); + END_STATE(); + case 296: + ACCEPT_TOKEN(anon_sym_max); + END_STATE(); + case 297: + ACCEPT_TOKEN(anon_sym_service); + END_STATE(); + case 298: + ACCEPT_TOKEN(anon_sym_rpc); + END_STATE(); + case 299: + ACCEPT_TOKEN(anon_sym_stream); + END_STATE(); + case 300: + ACCEPT_TOKEN(anon_sym_stream); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 301: + ACCEPT_TOKEN(anon_sym_returns); + END_STATE(); + case 302: + ACCEPT_TOKEN(anon_sym_PLUS); + END_STATE(); + case 303: + ACCEPT_TOKEN(anon_sym_COLON); + END_STATE(); + case 304: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == '2') ADVANCE(262); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 305: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == '2') ADVANCE(270); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 306: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == '2') ADVANCE(266); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 307: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == '2') ADVANCE(274); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 308: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == '2') ADVANCE(278); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 309: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == '3') ADVANCE(304); + if (lookahead == '6') ADVANCE(314); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 310: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == '3') ADVANCE(305); + if (lookahead == '6') ADVANCE(315); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 311: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == '3') ADVANCE(306); + if (lookahead == '6') ADVANCE(316); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 312: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == '3') ADVANCE(307); + if (lookahead == '6') ADVANCE(317); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 313: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == '3') ADVANCE(308); + if (lookahead == '6') ADVANCE(318); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 314: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == '4') ADVANCE(264); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 315: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == '4') ADVANCE(272); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 316: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == '4') ADVANCE(268); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 317: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == '4') ADVANCE(276); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 318: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == '4') ADVANCE(280); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 319: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'a') ADVANCE(396); + if (lookahead == 'e') ADVANCE(411); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('b' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 320: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'a') ADVANCE(357); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('b' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 321: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'a') ADVANCE(372); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('b' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 322: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'a') ADVANCE(367); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('b' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 323: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'a') ADVANCE(416); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('b' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 324: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'a') ADVANCE(369); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('b' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 325: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'a') ADVANCE(373); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('b' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 326: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'a') ADVANCE(420); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('b' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 327: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'b') ADVANCE(370); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 328: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'c') ADVANCE(322); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 329: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'd') ADVANCE(246); + if (lookahead == 's') ADVANCE(362); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 330: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'd') ADVANCE(252); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 331: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'd') ADVANCE(250); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 332: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'd') ADVANCE(292); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 333: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'd') ADVANCE(312); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 334: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'd') ADVANCE(313); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 335: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'e') ADVANCE(399); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 336: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'e') ADVANCE(333); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 337: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'e') ADVANCE(286); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 338: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'e') ADVANCE(244); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 339: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'e') ADVANCE(437); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 340: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'e') ADVANCE(439); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 341: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'e') ADVANCE(374); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 342: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'e') ADVANCE(330); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 343: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'e') ADVANCE(409); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 344: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'e') ADVANCE(331); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 345: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'e') ADVANCE(402); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 346: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'e') ADVANCE(400); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 347: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'e') ADVANCE(326); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 348: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'e') ADVANCE(332); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 349: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'e') ADVANCE(389); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 350: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'e') ADVANCE(321); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 351: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'e') ADVANCE(413); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 352: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'e') ADVANCE(334); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 353: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'f') ADVANCE(433); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 354: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'f') ADVANCE(256); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 355: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'f') ADVANCE(365); + if (lookahead == 'i') ADVANCE(383); + if (lookahead == 't') ADVANCE(403); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 356: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'g') ADVANCE(284); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 357: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'g') ADVANCE(338); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 358: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'i') ADVANCE(431); + if (lookahead == 'l') ADVANCE(388); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 359: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'i') ADVANCE(381); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 360: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'i') ADVANCE(406); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 361: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'i') ADVANCE(393); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 362: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'i') ADVANCE(394); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 363: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'i') ADVANCE(395); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 364: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'i') ADVANCE(384); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 365: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'i') ADVANCE(432); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 366: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'l') ADVANCE(282); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 367: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'l') ADVANCE(234); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 368: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'l') ADVANCE(248); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 369: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'l') ADVANCE(414); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 370: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'l') ADVANCE(337); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 371: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'm') ADVANCE(236); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 372: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'm') ADVANCE(300); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 373: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'n') ADVANCE(433); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 374: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'n') ADVANCE(329); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 375: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'n') ADVANCE(223); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 376: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'n') ADVANCE(225); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 377: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'n') ADVANCE(349); + if (lookahead == 'p') ADVANCE(419); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 378: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'n') ADVANCE(415); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 379: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'n') ADVANCE(426); + if (lookahead == 'x') ADVANCE(398); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 380: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'n') ADVANCE(353); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 381: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'n') ADVANCE(356); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 382: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'n') ADVANCE(410); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 383: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'n') ADVANCE(421); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 384: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'n') ADVANCE(423); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 385: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'o') ADVANCE(425); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 386: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'o') ADVANCE(328); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 387: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'o') ADVANCE(366); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 388: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'o') ADVANCE(323); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 389: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'o') ADVANCE(354); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 390: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'o') ADVANCE(387); + if (lookahead == 'y') ADVANCE(418); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 391: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'o') ADVANCE(404); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 392: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'o') ADVANCE(427); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 393: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'o') ADVANCE(375); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 394: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'o') ADVANCE(382); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 395: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'o') ADVANCE(376); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 396: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'p') ADVANCE(258); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 397: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'p') ADVANCE(254); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 398: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'p') ADVANCE(391); + if (lookahead == 't') ADVANCE(341); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 399: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'p') ADVANCE(347); + if (lookahead == 'q') ADVANCE(428); + if (lookahead == 's') ADVANCE(345); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 400: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'p') ADVANCE(347); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 401: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'p') ADVANCE(424); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 402: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'r') ADVANCE(430); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 403: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'r') ADVANCE(359); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 404: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'r') ADVANCE(417); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 405: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'r') ADVANCE(429); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 406: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'r') ADVANCE(344); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 407: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'r') ADVANCE(350); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 408: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'r') ADVANCE(392); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 409: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 's') ADVANCE(290); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 410: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 's') ADVANCE(294); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 411: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 's') ADVANCE(412); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 412: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 's') ADVANCE(320); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 413: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 's') ADVANCE(345); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 414: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 's') ADVANCE(340); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 415: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 't') ADVANCE(309); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 416: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 't') ADVANCE(288); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 417: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 't') ADVANCE(232); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 418: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 't') ADVANCE(343); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 419: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 't') ADVANCE(361); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 420: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 't') ADVANCE(342); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 421: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 't') ADVANCE(310); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 422: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 't') ADVANCE(407); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 423: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 't') ADVANCE(311); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 424: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 't') ADVANCE(363); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 425: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'u') ADVANCE(327); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 426: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'u') ADVANCE(371); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 427: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'u') ADVANCE(397); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 428: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'u') ADVANCE(360); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 429: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'u') ADVANCE(339); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 430: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'v') ADVANCE(348); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 431: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'x') ADVANCE(336); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 432: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == 'x') ADVANCE(352); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 433: + ACCEPT_TOKEN(sym_identifier); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 434: + ACCEPT_TOKEN(sym_reserved_identifier); + END_STATE(); + case 435: + ACCEPT_TOKEN(sym_reserved_identifier); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(435); + END_STATE(); + case 436: + ACCEPT_TOKEN(sym_true); + END_STATE(); + case 437: + ACCEPT_TOKEN(sym_true); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 438: + ACCEPT_TOKEN(sym_false); + END_STATE(); + case 439: + ACCEPT_TOKEN(sym_false); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(433); + END_STATE(); + case 440: + ACCEPT_TOKEN(sym_decimal_lit); + if (lookahead == '.') ADVANCE(448); + if (lookahead == 'E' || + lookahead == 'e') ADVANCE(196); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(440); + END_STATE(); + case 441: + ACCEPT_TOKEN(sym_decimal_lit); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(441); + END_STATE(); + case 442: + ACCEPT_TOKEN(sym_octal_lit); + if (lookahead == '.') ADVANCE(448); + if (lookahead == 'E' || + lookahead == 'e') ADVANCE(196); + if (lookahead == 'X' || + lookahead == 'x') ADVANCE(200); + if (lookahead == '8' || + lookahead == '9') ADVANCE(14); + if (('0' <= lookahead && lookahead <= '7')) ADVANCE(443); + END_STATE(); + case 443: + ACCEPT_TOKEN(sym_octal_lit); + if (lookahead == '.') ADVANCE(448); + if (lookahead == 'E' || + lookahead == 'e') ADVANCE(196); + if (lookahead == '8' || + lookahead == '9') ADVANCE(14); + if (('0' <= lookahead && lookahead <= '7')) ADVANCE(443); + END_STATE(); + case 444: + ACCEPT_TOKEN(sym_octal_lit); + if (lookahead == 'X' || + lookahead == 'x') ADVANCE(200); + if (('0' <= lookahead && lookahead <= '7')) ADVANCE(445); + END_STATE(); + case 445: + ACCEPT_TOKEN(sym_octal_lit); + if (('0' <= lookahead && lookahead <= '7')) ADVANCE(445); + END_STATE(); + case 446: + ACCEPT_TOKEN(sym_hex_lit); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'F') || + ('a' <= lookahead && lookahead <= 'f')) ADVANCE(446); + END_STATE(); + case 447: + ACCEPT_TOKEN(sym_float_lit); + END_STATE(); + case 448: + ACCEPT_TOKEN(sym_float_lit); + if (lookahead == 'E' || + lookahead == 'e') ADVANCE(196); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(448); + END_STATE(); + case 449: + ACCEPT_TOKEN(sym_float_lit); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(449); + END_STATE(); + case 450: + ACCEPT_TOKEN(anon_sym_DQUOTE); + END_STATE(); + case 451: + ACCEPT_TOKEN(aux_sym_string_token1); + if (lookahead == '\n') ADVANCE(456); + if (lookahead != 0 && + lookahead != '"' && + lookahead != '\\') ADVANCE(451); + END_STATE(); + case 452: + ACCEPT_TOKEN(aux_sym_string_token1); + if (lookahead == '*') ADVANCE(454); + if (lookahead == '/') ADVANCE(451); + if (lookahead != 0 && + lookahead != '"' && + lookahead != '\\') ADVANCE(456); + END_STATE(); + case 453: + ACCEPT_TOKEN(aux_sym_string_token1); + if (lookahead == '*') ADVANCE(453); + if (lookahead == '/') ADVANCE(456); + if (lookahead != 0 && + lookahead != '"' && + lookahead != '\\') ADVANCE(454); + END_STATE(); + case 454: + ACCEPT_TOKEN(aux_sym_string_token1); + if (lookahead == '*') ADVANCE(453); + if (lookahead != 0 && + lookahead != '"' && + lookahead != '\\') ADVANCE(454); + END_STATE(); + case 455: + ACCEPT_TOKEN(aux_sym_string_token1); + if (lookahead == '/') ADVANCE(452); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') ADVANCE(455); + if (lookahead != 0 && + lookahead != '"' && + lookahead != '\\') ADVANCE(456); + END_STATE(); + case 456: + ACCEPT_TOKEN(aux_sym_string_token1); + if (lookahead != 0 && + lookahead != '"' && + lookahead != '\\') ADVANCE(456); + END_STATE(); + case 457: + ACCEPT_TOKEN(anon_sym_SQUOTE); + END_STATE(); + case 458: + ACCEPT_TOKEN(aux_sym_string_token2); + if (lookahead == '\n') ADVANCE(463); + if (lookahead != 0 && + lookahead != '\'' && + lookahead != '\\') ADVANCE(458); + END_STATE(); + case 459: + ACCEPT_TOKEN(aux_sym_string_token2); + if (lookahead == '*') ADVANCE(461); + if (lookahead == '/') ADVANCE(458); + if (lookahead != 0 && + lookahead != '\'' && + lookahead != '\\') ADVANCE(463); + END_STATE(); + case 460: + ACCEPT_TOKEN(aux_sym_string_token2); + if (lookahead == '*') ADVANCE(460); + if (lookahead == '/') ADVANCE(463); + if (lookahead != 0 && + lookahead != '\'' && + lookahead != '\\') ADVANCE(461); + END_STATE(); + case 461: + ACCEPT_TOKEN(aux_sym_string_token2); + if (lookahead == '*') ADVANCE(460); + if (lookahead != 0 && + lookahead != '\'' && + lookahead != '\\') ADVANCE(461); + END_STATE(); + case 462: + ACCEPT_TOKEN(aux_sym_string_token2); + if (lookahead == '/') ADVANCE(459); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') ADVANCE(462); + if (lookahead != 0 && + lookahead != '\'' && + lookahead != '\\') ADVANCE(463); + END_STATE(); + case 463: + ACCEPT_TOKEN(aux_sym_string_token2); + if (lookahead != 0 && + lookahead != '\'' && + lookahead != '\\') ADVANCE(463); + END_STATE(); + case 464: + ACCEPT_TOKEN(sym_escape_sequence); + END_STATE(); + case 465: + ACCEPT_TOKEN(sym_escape_sequence); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(464); + END_STATE(); + case 466: + ACCEPT_TOKEN(sym_escape_sequence); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(465); + END_STATE(); + case 467: + ACCEPT_TOKEN(sym_comment); + END_STATE(); + case 468: + ACCEPT_TOKEN(sym_comment); + if (lookahead != 0 && + lookahead != '\n') ADVANCE(468); + END_STATE(); + default: + return false; + } +} + +static const TSLexMode ts_lex_modes[STATE_COUNT] = { + [0] = {.lex_state = 0}, + [1] = {.lex_state = 211}, + [2] = {.lex_state = 8}, + [3] = {.lex_state = 8}, + [4] = {.lex_state = 8}, + [5] = {.lex_state = 8}, + [6] = {.lex_state = 8}, + [7] = {.lex_state = 8}, + [8] = {.lex_state = 8}, + [9] = {.lex_state = 8}, + [10] = {.lex_state = 8}, + [11] = {.lex_state = 8}, + [12] = {.lex_state = 8}, + [13] = {.lex_state = 8}, + [14] = {.lex_state = 8}, + [15] = {.lex_state = 8}, + [16] = {.lex_state = 8}, + [17] = {.lex_state = 8}, + [18] = {.lex_state = 8}, + [19] = {.lex_state = 8}, + [20] = {.lex_state = 8}, + [21] = {.lex_state = 8}, + [22] = {.lex_state = 8}, + [23] = {.lex_state = 8}, + [24] = {.lex_state = 8}, + [25] = {.lex_state = 8}, + [26] = {.lex_state = 8}, + [27] = {.lex_state = 8}, + [28] = {.lex_state = 8}, + [29] = {.lex_state = 8}, + [30] = {.lex_state = 8}, + [31] = {.lex_state = 8}, + [32] = {.lex_state = 15}, + [33] = {.lex_state = 15}, + [34] = {.lex_state = 15}, + [35] = {.lex_state = 16}, + [36] = {.lex_state = 15}, + [37] = {.lex_state = 15}, + [38] = {.lex_state = 17}, + [39] = {.lex_state = 15}, + [40] = {.lex_state = 2}, + [41] = {.lex_state = 2}, + [42] = {.lex_state = 211}, + [43] = {.lex_state = 18}, + [44] = {.lex_state = 211}, + [45] = {.lex_state = 211}, + [46] = {.lex_state = 2}, + [47] = {.lex_state = 2}, + [48] = {.lex_state = 2}, + [49] = {.lex_state = 18}, + [50] = {.lex_state = 2}, + [51] = {.lex_state = 2}, + [52] = {.lex_state = 15}, + [53] = {.lex_state = 211}, + [54] = {.lex_state = 2}, + [55] = {.lex_state = 15}, + [56] = {.lex_state = 15}, + [57] = {.lex_state = 2}, + [58] = {.lex_state = 2}, + [59] = {.lex_state = 2}, + [60] = {.lex_state = 2}, + [61] = {.lex_state = 2}, + [62] = {.lex_state = 2}, + [63] = {.lex_state = 2}, + [64] = {.lex_state = 211}, + [65] = {.lex_state = 0}, + [66] = {.lex_state = 211}, + [67] = {.lex_state = 211}, + [68] = {.lex_state = 211}, + [69] = {.lex_state = 211}, + [70] = {.lex_state = 211}, + [71] = {.lex_state = 211}, + [72] = {.lex_state = 211}, + [73] = {.lex_state = 211}, + [74] = {.lex_state = 211}, + [75] = {.lex_state = 211}, + [76] = {.lex_state = 211}, + [77] = {.lex_state = 211}, + [78] = {.lex_state = 1}, + [79] = {.lex_state = 211}, + [80] = {.lex_state = 211}, + [81] = {.lex_state = 211}, + [82] = {.lex_state = 211}, + [83] = {.lex_state = 211}, + [84] = {.lex_state = 1}, + [85] = {.lex_state = 21}, + [86] = {.lex_state = 21}, + [87] = {.lex_state = 21}, + [88] = {.lex_state = 21}, + [89] = {.lex_state = 1}, + [90] = {.lex_state = 1}, + [91] = {.lex_state = 21}, + [92] = {.lex_state = 1}, + [93] = {.lex_state = 1}, + [94] = {.lex_state = 1}, + [95] = {.lex_state = 6}, + [96] = {.lex_state = 211}, + [97] = {.lex_state = 1}, + [98] = {.lex_state = 6}, + [99] = {.lex_state = 211}, + [100] = {.lex_state = 211}, + [101] = {.lex_state = 211}, + [102] = {.lex_state = 211}, + [103] = {.lex_state = 211}, + [104] = {.lex_state = 211}, + [105] = {.lex_state = 211}, + [106] = {.lex_state = 211}, + [107] = {.lex_state = 211}, + [108] = {.lex_state = 211}, + [109] = {.lex_state = 0}, + [110] = {.lex_state = 1}, + [111] = {.lex_state = 1}, + [112] = {.lex_state = 1}, + [113] = {.lex_state = 211}, + [114] = {.lex_state = 1}, + [115] = {.lex_state = 1}, + [116] = {.lex_state = 20}, + [117] = {.lex_state = 1}, + [118] = {.lex_state = 1}, + [119] = {.lex_state = 211}, + [120] = {.lex_state = 19}, + [121] = {.lex_state = 1}, + [122] = {.lex_state = 1}, + [123] = {.lex_state = 21}, + [124] = {.lex_state = 21}, + [125] = {.lex_state = 1}, + [126] = {.lex_state = 211}, + [127] = {.lex_state = 1}, + [128] = {.lex_state = 1}, + [129] = {.lex_state = 211}, + [130] = {.lex_state = 211}, + [131] = {.lex_state = 21}, + [132] = {.lex_state = 211}, + [133] = {.lex_state = 21}, + [134] = {.lex_state = 1}, + [135] = {.lex_state = 211}, + [136] = {.lex_state = 21}, + [137] = {.lex_state = 1}, + [138] = {.lex_state = 21}, + [139] = {.lex_state = 1}, + [140] = {.lex_state = 211}, + [141] = {.lex_state = 1}, + [142] = {.lex_state = 1}, + [143] = {.lex_state = 211}, + [144] = {.lex_state = 20}, + [145] = {.lex_state = 211}, + [146] = {.lex_state = 21}, + [147] = {.lex_state = 1}, + [148] = {.lex_state = 21}, + [149] = {.lex_state = 211}, + [150] = {.lex_state = 19}, + [151] = {.lex_state = 19}, + [152] = {.lex_state = 9}, + [153] = {.lex_state = 1}, + [154] = {.lex_state = 1}, + [155] = {.lex_state = 1}, + [156] = {.lex_state = 1}, + [157] = {.lex_state = 1}, + [158] = {.lex_state = 1}, + [159] = {.lex_state = 3}, + [160] = {.lex_state = 9}, + [161] = {.lex_state = 1}, + [162] = {.lex_state = 1}, + [163] = {.lex_state = 1}, + [164] = {.lex_state = 1}, + [165] = {.lex_state = 1}, + [166] = {.lex_state = 0}, + [167] = {.lex_state = 211}, + [168] = {.lex_state = 211}, + [169] = {.lex_state = 211}, + [170] = {.lex_state = 211}, + [171] = {.lex_state = 211}, + [172] = {.lex_state = 0}, + [173] = {.lex_state = 3}, + [174] = {.lex_state = 1}, + [175] = {.lex_state = 211}, + [176] = {.lex_state = 1}, + [177] = {.lex_state = 3}, + [178] = {.lex_state = 9}, + [179] = {.lex_state = 0}, + [180] = {.lex_state = 0}, + [181] = {.lex_state = 0}, + [182] = {.lex_state = 0}, + [183] = {.lex_state = 0}, + [184] = {.lex_state = 0}, + [185] = {.lex_state = 1}, + [186] = {.lex_state = 0}, + [187] = {.lex_state = 0}, + [188] = {.lex_state = 1}, + [189] = {.lex_state = 0}, + [190] = {.lex_state = 211}, + [191] = {.lex_state = 0}, + [192] = {.lex_state = 0}, + [193] = {.lex_state = 1}, + [194] = {.lex_state = 0}, + [195] = {.lex_state = 0}, + [196] = {.lex_state = 1}, + [197] = {.lex_state = 0}, + [198] = {.lex_state = 1}, + [199] = {.lex_state = 0}, + [200] = {.lex_state = 0}, + [201] = {.lex_state = 0}, + [202] = {.lex_state = 0}, + [203] = {.lex_state = 0}, + [204] = {.lex_state = 1}, + [205] = {.lex_state = 1}, + [206] = {.lex_state = 1}, + [207] = {.lex_state = 0}, + [208] = {.lex_state = 211}, + [209] = {.lex_state = 0}, + [210] = {.lex_state = 0}, + [211] = {.lex_state = 1}, + [212] = {.lex_state = 211}, + [213] = {.lex_state = 0}, + [214] = {.lex_state = 211}, + [215] = {.lex_state = 1}, + [216] = {.lex_state = 1}, + [217] = {.lex_state = 0}, + [218] = {.lex_state = 0}, + [219] = {.lex_state = 0}, + [220] = {.lex_state = 0}, + [221] = {.lex_state = 1}, + [222] = {.lex_state = 0}, + [223] = {.lex_state = 0}, + [224] = {.lex_state = 1}, + [225] = {.lex_state = 1}, + [226] = {.lex_state = 1}, + [227] = {.lex_state = 1}, + [228] = {.lex_state = 1}, + [229] = {.lex_state = 0}, + [230] = {.lex_state = 0}, + [231] = {.lex_state = 0}, + [232] = {.lex_state = 0}, + [233] = {.lex_state = 0}, + [234] = {.lex_state = 0}, + [235] = {.lex_state = 0}, + [236] = {.lex_state = 1}, + [237] = {.lex_state = 1}, + [238] = {.lex_state = 0}, + [239] = {.lex_state = 1}, + [240] = {.lex_state = 0}, + [241] = {.lex_state = 1}, + [242] = {.lex_state = 1}, + [243] = {.lex_state = 0}, + [244] = {.lex_state = 0}, + [245] = {.lex_state = 8}, + [246] = {.lex_state = 1}, + [247] = {.lex_state = 0}, + [248] = {.lex_state = 1}, + [249] = {.lex_state = 1}, + [250] = {.lex_state = 1}, + [251] = {.lex_state = 0}, + [252] = {.lex_state = 0}, + [253] = {.lex_state = 0}, + [254] = {.lex_state = 0}, + [255] = {.lex_state = 1}, + [256] = {.lex_state = 0}, + [257] = {.lex_state = 0}, + [258] = {.lex_state = 0}, + [259] = {.lex_state = 0}, + [260] = {.lex_state = 0}, + [261] = {.lex_state = 0}, + [262] = {.lex_state = 0}, + [263] = {.lex_state = 1}, + [264] = {.lex_state = 1}, + [265] = {.lex_state = 1}, + [266] = {.lex_state = 1}, + [267] = {.lex_state = 1}, + [268] = {.lex_state = 0}, + [269] = {.lex_state = 0}, + [270] = {.lex_state = 0}, + [271] = {.lex_state = 1}, + [272] = {.lex_state = 0}, + [273] = {.lex_state = 0}, + [274] = {.lex_state = 0}, + [275] = {.lex_state = 0}, + [276] = {.lex_state = 1}, + [277] = {.lex_state = 0}, + [278] = {.lex_state = 0}, + [279] = {.lex_state = 0}, + [280] = {.lex_state = 0}, + [281] = {.lex_state = 0}, + [282] = {.lex_state = 211}, + [283] = {.lex_state = 0}, + [284] = {.lex_state = 0}, + [285] = {.lex_state = 0}, + [286] = {.lex_state = 0}, + [287] = {.lex_state = 0}, + [288] = {.lex_state = 0}, + [289] = {.lex_state = 1}, + [290] = {.lex_state = 0}, + [291] = {.lex_state = 0}, + [292] = {.lex_state = 1}, + [293] = {.lex_state = 0}, + [294] = {.lex_state = 0}, + [295] = {.lex_state = 0}, + [296] = {.lex_state = 0}, + [297] = {.lex_state = 0}, + [298] = {.lex_state = 0}, + [299] = {.lex_state = 1}, + [300] = {.lex_state = 0}, + [301] = {.lex_state = 0}, + [302] = {.lex_state = 0}, + [303] = {.lex_state = 0}, + [304] = {.lex_state = 0}, + [305] = {.lex_state = 0}, + [306] = {.lex_state = 6}, + [307] = {.lex_state = 0}, + [308] = {.lex_state = 0}, + [309] = {.lex_state = 0}, + [310] = {.lex_state = 0}, + [311] = {.lex_state = 0}, + [312] = {.lex_state = 0}, + [313] = {.lex_state = 0}, + [314] = {.lex_state = 0}, + [315] = {.lex_state = 1}, + [316] = {.lex_state = 0}, + [317] = {.lex_state = 0}, + [318] = {.lex_state = 0}, + [319] = {.lex_state = 0}, + [320] = {.lex_state = 1}, + [321] = {.lex_state = 0}, + [322] = {.lex_state = 0}, + [323] = {.lex_state = 0}, + [324] = {.lex_state = 0}, + [325] = {.lex_state = 0}, + [326] = {.lex_state = 0}, + [327] = {.lex_state = 0}, + [328] = {.lex_state = 0}, + [329] = {.lex_state = 0}, + [330] = {.lex_state = 0}, + [331] = {.lex_state = 0}, + [332] = {.lex_state = 0}, + [333] = {.lex_state = 0}, + [334] = {.lex_state = 0}, + [335] = {.lex_state = 0}, + [336] = {.lex_state = 1}, + [337] = {.lex_state = 0}, + [338] = {.lex_state = 0}, + [339] = {.lex_state = 0}, + [340] = {.lex_state = 0}, + [341] = {.lex_state = 0}, + [342] = {.lex_state = 1}, + [343] = {.lex_state = 0}, + [344] = {.lex_state = 0}, +}; + +static const uint16_t ts_parse_table[LARGE_STATE_COUNT][SYMBOL_COUNT] = { + [0] = { + [ts_builtin_sym_end] = ACTIONS(1), + [anon_sym_SEMI] = ACTIONS(1), + [anon_sym_edition] = ACTIONS(1), + [anon_sym_EQ] = ACTIONS(1), + [anon_sym_syntax] = ACTIONS(1), + [anon_sym_import] = ACTIONS(1), + [anon_sym_weak] = ACTIONS(1), + [anon_sym_public] = ACTIONS(1), + [anon_sym_option] = ACTIONS(1), + [anon_sym_package] = ACTIONS(1), + [anon_sym_LPAREN] = ACTIONS(1), + [anon_sym_RPAREN] = ACTIONS(1), + [anon_sym_DOT] = ACTIONS(1), + [anon_sym_export] = ACTIONS(1), + [anon_sym_local] = ACTIONS(1), + [anon_sym_enum] = ACTIONS(1), + [anon_sym_LBRACE] = ACTIONS(1), + [anon_sym_RBRACE] = ACTIONS(1), + [anon_sym_DASH] = ACTIONS(1), + [anon_sym_LBRACK] = ACTIONS(1), + [anon_sym_COMMA] = ACTIONS(1), + [anon_sym_RBRACK] = ACTIONS(1), + [anon_sym_message] = ACTIONS(1), + [anon_sym_extend] = ACTIONS(1), + [anon_sym_optional] = ACTIONS(1), + [anon_sym_required] = ACTIONS(1), + [anon_sym_repeated] = ACTIONS(1), + [anon_sym_group] = ACTIONS(1), + [anon_sym_oneof] = ACTIONS(1), + [anon_sym_map] = ACTIONS(1), + [anon_sym_LT] = ACTIONS(1), + [anon_sym_GT] = ACTIONS(1), + [anon_sym_int32] = ACTIONS(1), + [anon_sym_int64] = ACTIONS(1), + [anon_sym_uint32] = ACTIONS(1), + [anon_sym_uint64] = ACTIONS(1), + [anon_sym_sint32] = ACTIONS(1), + [anon_sym_sint64] = ACTIONS(1), + [anon_sym_fixed32] = ACTIONS(1), + [anon_sym_fixed64] = ACTIONS(1), + [anon_sym_sfixed32] = ACTIONS(1), + [anon_sym_sfixed64] = ACTIONS(1), + [anon_sym_bool] = ACTIONS(1), + [anon_sym_string] = ACTIONS(1), + [anon_sym_double] = ACTIONS(1), + [anon_sym_float] = ACTIONS(1), + [anon_sym_bytes] = ACTIONS(1), + [anon_sym_reserved] = ACTIONS(1), + [anon_sym_extensions] = ACTIONS(1), + [anon_sym_to] = ACTIONS(1), + [anon_sym_max] = ACTIONS(1), + [anon_sym_service] = ACTIONS(1), + [anon_sym_rpc] = ACTIONS(1), + [anon_sym_stream] = ACTIONS(1), + [anon_sym_returns] = ACTIONS(1), + [anon_sym_PLUS] = ACTIONS(1), + [anon_sym_COLON] = ACTIONS(1), + [sym_true] = ACTIONS(1), + [sym_false] = ACTIONS(1), + [sym_decimal_lit] = ACTIONS(1), + [sym_octal_lit] = ACTIONS(1), + [sym_hex_lit] = ACTIONS(1), + [sym_float_lit] = ACTIONS(1), + [anon_sym_DQUOTE] = ACTIONS(1), + [anon_sym_SQUOTE] = ACTIONS(1), + [sym_escape_sequence] = ACTIONS(1), + [sym_comment] = ACTIONS(3), + }, + [1] = { + [sym_source_file] = STATE(319), + [sym_empty_statement] = STATE(53), + [sym_edition] = STATE(44), + [sym_syntax] = STATE(44), + [sym_import] = STATE(53), + [sym_package] = STATE(53), + [sym_option] = STATE(53), + [sym_enum] = STATE(53), + [sym_message] = STATE(53), + [sym_extend] = STATE(53), + [sym_service] = STATE(53), + [aux_sym_source_file_repeat1] = STATE(53), + [ts_builtin_sym_end] = ACTIONS(5), + [anon_sym_SEMI] = ACTIONS(7), + [anon_sym_edition] = ACTIONS(9), + [anon_sym_syntax] = ACTIONS(11), + [anon_sym_import] = ACTIONS(13), + [anon_sym_option] = ACTIONS(15), + [anon_sym_package] = ACTIONS(17), + [anon_sym_export] = ACTIONS(19), + [anon_sym_local] = ACTIONS(19), + [anon_sym_enum] = ACTIONS(21), + [anon_sym_message] = ACTIONS(23), + [anon_sym_extend] = ACTIONS(25), + [anon_sym_service] = ACTIONS(27), + [sym_comment] = ACTIONS(3), + }, +}; + +static const uint16_t ts_small_parse_table[] = { + [0] = 22, + ACTIONS(3), 1, + sym_comment, + ACTIONS(29), 1, + anon_sym_SEMI, + ACTIONS(31), 1, + anon_sym_option, + ACTIONS(33), 1, + anon_sym_DOT, + ACTIONS(37), 1, + anon_sym_enum, + ACTIONS(39), 1, + anon_sym_RBRACE, + ACTIONS(41), 1, + anon_sym_message, + ACTIONS(43), 1, + anon_sym_extend, + ACTIONS(47), 1, + anon_sym_repeated, + ACTIONS(49), 1, + anon_sym_group, + ACTIONS(51), 1, + anon_sym_oneof, + ACTIONS(53), 1, + anon_sym_map, + ACTIONS(57), 1, + anon_sym_reserved, + ACTIONS(59), 1, + anon_sym_extensions, + ACTIONS(61), 1, + sym_identifier, + STATE(241), 1, + sym_message_or_enum_type, + STATE(271), 1, + aux_sym_message_or_enum_type_repeat1, + STATE(299), 1, + sym_type, + ACTIONS(35), 2, + anon_sym_export, + anon_sym_local, + ACTIONS(45), 2, + anon_sym_optional, + anon_sym_required, + STATE(3), 12, + sym_empty_statement, + sym_option, + sym_enum, + sym_message, + sym_extend, + sym_group, + sym_field, + sym_oneof, + sym_map_field, + sym_reserved, + sym_extensions, + aux_sym_message_body_repeat1, + ACTIONS(55), 15, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + [94] = 22, + ACTIONS(3), 1, + sym_comment, + ACTIONS(29), 1, + anon_sym_SEMI, + ACTIONS(31), 1, + anon_sym_option, + ACTIONS(33), 1, + anon_sym_DOT, + ACTIONS(37), 1, + anon_sym_enum, + ACTIONS(41), 1, + anon_sym_message, + ACTIONS(43), 1, + anon_sym_extend, + ACTIONS(47), 1, + anon_sym_repeated, + ACTIONS(49), 1, + anon_sym_group, + ACTIONS(51), 1, + anon_sym_oneof, + ACTIONS(53), 1, + anon_sym_map, + ACTIONS(57), 1, + anon_sym_reserved, + ACTIONS(59), 1, + anon_sym_extensions, + ACTIONS(61), 1, + sym_identifier, + ACTIONS(63), 1, + anon_sym_RBRACE, + STATE(241), 1, + sym_message_or_enum_type, + STATE(271), 1, + aux_sym_message_or_enum_type_repeat1, + STATE(299), 1, + sym_type, + ACTIONS(35), 2, + anon_sym_export, + anon_sym_local, + ACTIONS(45), 2, + anon_sym_optional, + anon_sym_required, + STATE(4), 12, + sym_empty_statement, + sym_option, + sym_enum, + sym_message, + sym_extend, + sym_group, + sym_field, + sym_oneof, + sym_map_field, + sym_reserved, + sym_extensions, + aux_sym_message_body_repeat1, + ACTIONS(55), 15, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + [188] = 22, + ACTIONS(3), 1, + sym_comment, + ACTIONS(65), 1, + anon_sym_SEMI, + ACTIONS(68), 1, + anon_sym_option, + ACTIONS(71), 1, + anon_sym_DOT, + ACTIONS(77), 1, + anon_sym_enum, + ACTIONS(80), 1, + anon_sym_RBRACE, + ACTIONS(82), 1, + anon_sym_message, + ACTIONS(85), 1, + anon_sym_extend, + ACTIONS(91), 1, + anon_sym_repeated, + ACTIONS(94), 1, + anon_sym_group, + ACTIONS(97), 1, + anon_sym_oneof, + ACTIONS(100), 1, + anon_sym_map, + ACTIONS(106), 1, + anon_sym_reserved, + ACTIONS(109), 1, + anon_sym_extensions, + ACTIONS(112), 1, + sym_identifier, + STATE(241), 1, + sym_message_or_enum_type, + STATE(271), 1, + aux_sym_message_or_enum_type_repeat1, + STATE(299), 1, + sym_type, + ACTIONS(74), 2, + anon_sym_export, + anon_sym_local, + ACTIONS(88), 2, + anon_sym_optional, + anon_sym_required, + STATE(4), 12, + sym_empty_statement, + sym_option, + sym_enum, + sym_message, + sym_extend, + sym_group, + sym_field, + sym_oneof, + sym_map_field, + sym_reserved, + sym_extensions, + aux_sym_message_body_repeat1, + ACTIONS(103), 15, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + [282] = 22, + ACTIONS(3), 1, + sym_comment, + ACTIONS(29), 1, + anon_sym_SEMI, + ACTIONS(31), 1, + anon_sym_option, + ACTIONS(33), 1, + anon_sym_DOT, + ACTIONS(37), 1, + anon_sym_enum, + ACTIONS(41), 1, + anon_sym_message, + ACTIONS(43), 1, + anon_sym_extend, + ACTIONS(47), 1, + anon_sym_repeated, + ACTIONS(49), 1, + anon_sym_group, + ACTIONS(51), 1, + anon_sym_oneof, + ACTIONS(53), 1, + anon_sym_map, + ACTIONS(57), 1, + anon_sym_reserved, + ACTIONS(59), 1, + anon_sym_extensions, + ACTIONS(61), 1, + sym_identifier, + ACTIONS(115), 1, + anon_sym_RBRACE, + STATE(241), 1, + sym_message_or_enum_type, + STATE(271), 1, + aux_sym_message_or_enum_type_repeat1, + STATE(299), 1, + sym_type, + ACTIONS(35), 2, + anon_sym_export, + anon_sym_local, + ACTIONS(45), 2, + anon_sym_optional, + anon_sym_required, + STATE(4), 12, + sym_empty_statement, + sym_option, + sym_enum, + sym_message, + sym_extend, + sym_group, + sym_field, + sym_oneof, + sym_map_field, + sym_reserved, + sym_extensions, + aux_sym_message_body_repeat1, + ACTIONS(55), 15, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + [376] = 22, + ACTIONS(3), 1, + sym_comment, + ACTIONS(29), 1, + anon_sym_SEMI, + ACTIONS(31), 1, + anon_sym_option, + ACTIONS(33), 1, + anon_sym_DOT, + ACTIONS(37), 1, + anon_sym_enum, + ACTIONS(41), 1, + anon_sym_message, + ACTIONS(43), 1, + anon_sym_extend, + ACTIONS(47), 1, + anon_sym_repeated, + ACTIONS(49), 1, + anon_sym_group, + ACTIONS(51), 1, + anon_sym_oneof, + ACTIONS(53), 1, + anon_sym_map, + ACTIONS(57), 1, + anon_sym_reserved, + ACTIONS(59), 1, + anon_sym_extensions, + ACTIONS(61), 1, + sym_identifier, + ACTIONS(117), 1, + anon_sym_RBRACE, + STATE(241), 1, + sym_message_or_enum_type, + STATE(271), 1, + aux_sym_message_or_enum_type_repeat1, + STATE(299), 1, + sym_type, + ACTIONS(35), 2, + anon_sym_export, + anon_sym_local, + ACTIONS(45), 2, + anon_sym_optional, + anon_sym_required, + STATE(5), 12, + sym_empty_statement, + sym_option, + sym_enum, + sym_message, + sym_extend, + sym_group, + sym_field, + sym_oneof, + sym_map_field, + sym_reserved, + sym_extensions, + aux_sym_message_body_repeat1, + ACTIONS(55), 15, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + [470] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(119), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(121), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [511] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(123), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(125), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [552] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(127), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(129), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [593] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(131), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(133), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [634] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(135), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(137), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [675] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(139), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(141), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [716] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(143), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(145), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [757] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(147), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(149), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [798] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(151), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(153), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [839] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(155), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(157), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [880] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(159), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(161), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [921] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(163), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(165), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [962] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(167), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(169), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [1003] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(171), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(173), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [1044] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(175), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(177), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [1085] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(179), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(181), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [1126] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(183), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(185), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [1167] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(187), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(189), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [1208] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(191), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(193), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [1249] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(195), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(197), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [1290] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(199), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(201), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [1331] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(203), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(205), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [1372] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(207), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(209), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [1413] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(211), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(213), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [1454] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(215), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(217), 30, + anon_sym_option, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_optional, + anon_sym_required, + anon_sym_repeated, + anon_sym_group, + anon_sym_oneof, + anon_sym_map, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + anon_sym_reserved, + anon_sym_extensions, + sym_identifier, + [1495] = 11, + ACTIONS(3), 1, + sym_comment, + ACTIONS(33), 1, + anon_sym_DOT, + ACTIONS(61), 1, + sym_identifier, + ACTIONS(219), 1, + anon_sym_SEMI, + ACTIONS(221), 1, + anon_sym_option, + ACTIONS(223), 1, + anon_sym_RBRACE, + STATE(241), 1, + sym_message_or_enum_type, + STATE(271), 1, + aux_sym_message_or_enum_type_repeat1, + STATE(342), 1, + sym_type, + STATE(34), 4, + sym_empty_statement, + sym_option, + sym_oneof_field, + aux_sym_oneof_repeat1, + ACTIONS(55), 15, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + [1546] = 11, + ACTIONS(3), 1, + sym_comment, + ACTIONS(225), 1, + anon_sym_SEMI, + ACTIONS(228), 1, + anon_sym_option, + ACTIONS(231), 1, + anon_sym_DOT, + ACTIONS(234), 1, + anon_sym_RBRACE, + ACTIONS(239), 1, + sym_identifier, + STATE(241), 1, + sym_message_or_enum_type, + STATE(271), 1, + aux_sym_message_or_enum_type_repeat1, + STATE(342), 1, + sym_type, + STATE(33), 4, + sym_empty_statement, + sym_option, + sym_oneof_field, + aux_sym_oneof_repeat1, + ACTIONS(236), 15, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + [1597] = 11, + ACTIONS(3), 1, + sym_comment, + ACTIONS(33), 1, + anon_sym_DOT, + ACTIONS(61), 1, + sym_identifier, + ACTIONS(219), 1, + anon_sym_SEMI, + ACTIONS(221), 1, + anon_sym_option, + ACTIONS(242), 1, + anon_sym_RBRACE, + STATE(241), 1, + sym_message_or_enum_type, + STATE(271), 1, + aux_sym_message_or_enum_type_repeat1, + STATE(342), 1, + sym_type, + STATE(33), 4, + sym_empty_statement, + sym_option, + sym_oneof_field, + aux_sym_oneof_repeat1, + ACTIONS(55), 15, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + [1648] = 9, + ACTIONS(3), 1, + sym_comment, + ACTIONS(33), 1, + anon_sym_DOT, + ACTIONS(61), 1, + sym_identifier, + ACTIONS(244), 1, + anon_sym_repeated, + ACTIONS(246), 1, + anon_sym_group, + STATE(241), 1, + sym_message_or_enum_type, + STATE(271), 1, + aux_sym_message_or_enum_type_repeat1, + STATE(276), 1, + sym_type, + ACTIONS(55), 15, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + [1690] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(248), 5, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_LBRACE, + anon_sym_RBRACE, + anon_sym_LBRACK, + ACTIONS(250), 17, + anon_sym_option, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + sym_identifier, + [1720] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(256), 1, + anon_sym_LBRACK, + ACTIONS(252), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(254), 17, + anon_sym_option, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + sym_identifier, + [1751] = 8, + ACTIONS(3), 1, + sym_comment, + ACTIONS(33), 1, + anon_sym_DOT, + ACTIONS(61), 1, + sym_identifier, + ACTIONS(246), 1, + anon_sym_group, + STATE(241), 1, + sym_message_or_enum_type, + STATE(271), 1, + aux_sym_message_or_enum_type_repeat1, + STATE(276), 1, + sym_type, + ACTIONS(55), 15, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + [1790] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(258), 4, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + anon_sym_LBRACK, + ACTIONS(260), 17, + anon_sym_option, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + sym_identifier, + [1819] = 15, + ACTIONS(3), 1, + sym_comment, + ACTIONS(262), 1, + anon_sym_LBRACE, + ACTIONS(266), 1, + anon_sym_LBRACK, + ACTIONS(268), 1, + anon_sym_COLON, + ACTIONS(270), 1, + sym_identifier, + ACTIONS(276), 1, + sym_hex_lit, + ACTIONS(278), 1, + sym_float_lit, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + STATE(93), 1, + aux_sym_string_repeat3, + STATE(127), 1, + sym_constant, + ACTIONS(264), 2, + anon_sym_DASH, + anon_sym_PLUS, + ACTIONS(272), 2, + sym_true, + sym_false, + ACTIONS(274), 2, + sym_decimal_lit, + sym_octal_lit, + STATE(112), 5, + sym_block_lit, + sym_full_ident, + sym_bool, + sym_int_lit, + sym_string, + [1872] = 15, + ACTIONS(3), 1, + sym_comment, + ACTIONS(262), 1, + anon_sym_LBRACE, + ACTIONS(270), 1, + sym_identifier, + ACTIONS(276), 1, + sym_hex_lit, + ACTIONS(278), 1, + sym_float_lit, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + ACTIONS(284), 1, + anon_sym_LBRACK, + ACTIONS(286), 1, + anon_sym_COLON, + STATE(93), 1, + aux_sym_string_repeat3, + STATE(142), 1, + sym_constant, + ACTIONS(264), 2, + anon_sym_DASH, + anon_sym_PLUS, + ACTIONS(272), 2, + sym_true, + sym_false, + ACTIONS(274), 2, + sym_decimal_lit, + sym_octal_lit, + STATE(112), 5, + sym_block_lit, + sym_full_ident, + sym_bool, + sym_int_lit, + sym_string, + [1925] = 12, + ACTIONS(3), 1, + sym_comment, + ACTIONS(7), 1, + anon_sym_SEMI, + ACTIONS(13), 1, + anon_sym_import, + ACTIONS(15), 1, + anon_sym_option, + ACTIONS(17), 1, + anon_sym_package, + ACTIONS(21), 1, + anon_sym_enum, + ACTIONS(23), 1, + anon_sym_message, + ACTIONS(25), 1, + anon_sym_extend, + ACTIONS(27), 1, + anon_sym_service, + ACTIONS(288), 1, + ts_builtin_sym_end, + ACTIONS(19), 2, + anon_sym_export, + anon_sym_local, + STATE(45), 9, + sym_empty_statement, + sym_import, + sym_package, + sym_option, + sym_enum, + sym_message, + sym_extend, + sym_service, + aux_sym_source_file_repeat1, + [1971] = 7, + ACTIONS(3), 1, + sym_comment, + ACTIONS(33), 1, + anon_sym_DOT, + ACTIONS(61), 1, + sym_identifier, + STATE(241), 1, + sym_message_or_enum_type, + STATE(271), 1, + aux_sym_message_or_enum_type_repeat1, + STATE(320), 1, + sym_type, + ACTIONS(55), 15, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + [2007] = 12, + ACTIONS(3), 1, + sym_comment, + ACTIONS(7), 1, + anon_sym_SEMI, + ACTIONS(13), 1, + anon_sym_import, + ACTIONS(15), 1, + anon_sym_option, + ACTIONS(17), 1, + anon_sym_package, + ACTIONS(21), 1, + anon_sym_enum, + ACTIONS(23), 1, + anon_sym_message, + ACTIONS(25), 1, + anon_sym_extend, + ACTIONS(27), 1, + anon_sym_service, + ACTIONS(290), 1, + ts_builtin_sym_end, + ACTIONS(19), 2, + anon_sym_export, + anon_sym_local, + STATE(42), 9, + sym_empty_statement, + sym_import, + sym_package, + sym_option, + sym_enum, + sym_message, + sym_extend, + sym_service, + aux_sym_source_file_repeat1, + [2053] = 12, + ACTIONS(3), 1, + sym_comment, + ACTIONS(292), 1, + ts_builtin_sym_end, + ACTIONS(294), 1, + anon_sym_SEMI, + ACTIONS(297), 1, + anon_sym_import, + ACTIONS(300), 1, + anon_sym_option, + ACTIONS(303), 1, + anon_sym_package, + ACTIONS(309), 1, + anon_sym_enum, + ACTIONS(312), 1, + anon_sym_message, + ACTIONS(315), 1, + anon_sym_extend, + ACTIONS(318), 1, + anon_sym_service, + ACTIONS(306), 2, + anon_sym_export, + anon_sym_local, + STATE(45), 9, + sym_empty_statement, + sym_import, + sym_package, + sym_option, + sym_enum, + sym_message, + sym_extend, + sym_service, + aux_sym_source_file_repeat1, + [2099] = 14, + ACTIONS(3), 1, + sym_comment, + ACTIONS(262), 1, + anon_sym_LBRACE, + ACTIONS(270), 1, + sym_identifier, + ACTIONS(278), 1, + sym_float_lit, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + ACTIONS(323), 1, + anon_sym_RBRACK, + ACTIONS(327), 1, + sym_hex_lit, + STATE(93), 1, + aux_sym_string_repeat3, + STATE(202), 1, + sym_constant, + ACTIONS(272), 2, + sym_true, + sym_false, + ACTIONS(321), 2, + anon_sym_DASH, + anon_sym_PLUS, + ACTIONS(325), 2, + sym_decimal_lit, + sym_octal_lit, + STATE(112), 5, + sym_block_lit, + sym_full_ident, + sym_bool, + sym_int_lit, + sym_string, + [2149] = 14, + ACTIONS(3), 1, + sym_comment, + ACTIONS(262), 1, + anon_sym_LBRACE, + ACTIONS(270), 1, + sym_identifier, + ACTIONS(278), 1, + sym_float_lit, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + ACTIONS(327), 1, + sym_hex_lit, + ACTIONS(329), 1, + anon_sym_RBRACK, + STATE(93), 1, + aux_sym_string_repeat3, + STATE(183), 1, + sym_constant, + ACTIONS(272), 2, + sym_true, + sym_false, + ACTIONS(321), 2, + anon_sym_DASH, + anon_sym_PLUS, + ACTIONS(325), 2, + sym_decimal_lit, + sym_octal_lit, + STATE(112), 5, + sym_block_lit, + sym_full_ident, + sym_bool, + sym_int_lit, + sym_string, + [2199] = 14, + ACTIONS(3), 1, + sym_comment, + ACTIONS(262), 1, + anon_sym_LBRACE, + ACTIONS(270), 1, + sym_identifier, + ACTIONS(276), 1, + sym_hex_lit, + ACTIONS(278), 1, + sym_float_lit, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + ACTIONS(331), 1, + anon_sym_LBRACK, + STATE(93), 1, + aux_sym_string_repeat3, + STATE(141), 1, + sym_constant, + ACTIONS(264), 2, + anon_sym_DASH, + anon_sym_PLUS, + ACTIONS(272), 2, + sym_true, + sym_false, + ACTIONS(274), 2, + sym_decimal_lit, + sym_octal_lit, + STATE(112), 5, + sym_block_lit, + sym_full_ident, + sym_bool, + sym_int_lit, + sym_string, + [2249] = 7, + ACTIONS(3), 1, + sym_comment, + ACTIONS(33), 1, + anon_sym_DOT, + ACTIONS(61), 1, + sym_identifier, + STATE(241), 1, + sym_message_or_enum_type, + STATE(271), 1, + aux_sym_message_or_enum_type_repeat1, + STATE(274), 1, + sym_type, + ACTIONS(55), 15, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + [2285] = 14, + ACTIONS(3), 1, + sym_comment, + ACTIONS(262), 1, + anon_sym_LBRACE, + ACTIONS(270), 1, + sym_identifier, + ACTIONS(278), 1, + sym_float_lit, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + ACTIONS(327), 1, + sym_hex_lit, + ACTIONS(333), 1, + anon_sym_RBRACK, + STATE(93), 1, + aux_sym_string_repeat3, + STATE(197), 1, + sym_constant, + ACTIONS(272), 2, + sym_true, + sym_false, + ACTIONS(321), 2, + anon_sym_DASH, + anon_sym_PLUS, + ACTIONS(325), 2, + sym_decimal_lit, + sym_octal_lit, + STATE(112), 5, + sym_block_lit, + sym_full_ident, + sym_bool, + sym_int_lit, + sym_string, + [2335] = 14, + ACTIONS(3), 1, + sym_comment, + ACTIONS(262), 1, + anon_sym_LBRACE, + ACTIONS(270), 1, + sym_identifier, + ACTIONS(276), 1, + sym_hex_lit, + ACTIONS(278), 1, + sym_float_lit, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + ACTIONS(335), 1, + anon_sym_LBRACK, + STATE(93), 1, + aux_sym_string_repeat3, + STATE(139), 1, + sym_constant, + ACTIONS(264), 2, + anon_sym_DASH, + anon_sym_PLUS, + ACTIONS(272), 2, + sym_true, + sym_false, + ACTIONS(274), 2, + sym_decimal_lit, + sym_octal_lit, + STATE(112), 5, + sym_block_lit, + sym_full_ident, + sym_bool, + sym_int_lit, + sym_string, + [2385] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(337), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(339), 17, + anon_sym_option, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + sym_identifier, + [2413] = 12, + ACTIONS(3), 1, + sym_comment, + ACTIONS(7), 1, + anon_sym_SEMI, + ACTIONS(13), 1, + anon_sym_import, + ACTIONS(15), 1, + anon_sym_option, + ACTIONS(17), 1, + anon_sym_package, + ACTIONS(21), 1, + anon_sym_enum, + ACTIONS(23), 1, + anon_sym_message, + ACTIONS(25), 1, + anon_sym_extend, + ACTIONS(27), 1, + anon_sym_service, + ACTIONS(290), 1, + ts_builtin_sym_end, + ACTIONS(19), 2, + anon_sym_export, + anon_sym_local, + STATE(45), 9, + sym_empty_statement, + sym_import, + sym_package, + sym_option, + sym_enum, + sym_message, + sym_extend, + sym_service, + aux_sym_source_file_repeat1, + [2459] = 14, + ACTIONS(3), 1, + sym_comment, + ACTIONS(262), 1, + anon_sym_LBRACE, + ACTIONS(270), 1, + sym_identifier, + ACTIONS(278), 1, + sym_float_lit, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + ACTIONS(327), 1, + sym_hex_lit, + ACTIONS(341), 1, + anon_sym_RBRACK, + STATE(93), 1, + aux_sym_string_repeat3, + STATE(195), 1, + sym_constant, + ACTIONS(272), 2, + sym_true, + sym_false, + ACTIONS(321), 2, + anon_sym_DASH, + anon_sym_PLUS, + ACTIONS(325), 2, + sym_decimal_lit, + sym_octal_lit, + STATE(112), 5, + sym_block_lit, + sym_full_ident, + sym_bool, + sym_int_lit, + sym_string, + [2509] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(211), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(213), 17, + anon_sym_option, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + sym_identifier, + [2537] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(123), 3, + anon_sym_SEMI, + anon_sym_DOT, + anon_sym_RBRACE, + ACTIONS(125), 17, + anon_sym_option, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + anon_sym_double, + anon_sym_float, + anon_sym_bytes, + sym_identifier, + [2565] = 13, + ACTIONS(3), 1, + sym_comment, + ACTIONS(262), 1, + anon_sym_LBRACE, + ACTIONS(270), 1, + sym_identifier, + ACTIONS(278), 1, + sym_float_lit, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + ACTIONS(327), 1, + sym_hex_lit, + STATE(93), 1, + aux_sym_string_repeat3, + STATE(327), 1, + sym_constant, + ACTIONS(272), 2, + sym_true, + sym_false, + ACTIONS(321), 2, + anon_sym_DASH, + anon_sym_PLUS, + ACTIONS(325), 2, + sym_decimal_lit, + sym_octal_lit, + STATE(112), 5, + sym_block_lit, + sym_full_ident, + sym_bool, + sym_int_lit, + sym_string, + [2612] = 13, + ACTIONS(3), 1, + sym_comment, + ACTIONS(262), 1, + anon_sym_LBRACE, + ACTIONS(270), 1, + sym_identifier, + ACTIONS(278), 1, + sym_float_lit, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + ACTIONS(327), 1, + sym_hex_lit, + STATE(93), 1, + aux_sym_string_repeat3, + STATE(308), 1, + sym_constant, + ACTIONS(272), 2, + sym_true, + sym_false, + ACTIONS(321), 2, + anon_sym_DASH, + anon_sym_PLUS, + ACTIONS(325), 2, + sym_decimal_lit, + sym_octal_lit, + STATE(112), 5, + sym_block_lit, + sym_full_ident, + sym_bool, + sym_int_lit, + sym_string, + [2659] = 13, + ACTIONS(3), 1, + sym_comment, + ACTIONS(262), 1, + anon_sym_LBRACE, + ACTIONS(270), 1, + sym_identifier, + ACTIONS(278), 1, + sym_float_lit, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + ACTIONS(327), 1, + sym_hex_lit, + STATE(93), 1, + aux_sym_string_repeat3, + STATE(232), 1, + sym_constant, + ACTIONS(272), 2, + sym_true, + sym_false, + ACTIONS(321), 2, + anon_sym_DASH, + anon_sym_PLUS, + ACTIONS(325), 2, + sym_decimal_lit, + sym_octal_lit, + STATE(112), 5, + sym_block_lit, + sym_full_ident, + sym_bool, + sym_int_lit, + sym_string, + [2706] = 13, + ACTIONS(3), 1, + sym_comment, + ACTIONS(262), 1, + anon_sym_LBRACE, + ACTIONS(270), 1, + sym_identifier, + ACTIONS(278), 1, + sym_float_lit, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + ACTIONS(327), 1, + sym_hex_lit, + STATE(93), 1, + aux_sym_string_repeat3, + STATE(222), 1, + sym_constant, + ACTIONS(272), 2, + sym_true, + sym_false, + ACTIONS(321), 2, + anon_sym_DASH, + anon_sym_PLUS, + ACTIONS(325), 2, + sym_decimal_lit, + sym_octal_lit, + STATE(112), 5, + sym_block_lit, + sym_full_ident, + sym_bool, + sym_int_lit, + sym_string, + [2753] = 13, + ACTIONS(3), 1, + sym_comment, + ACTIONS(262), 1, + anon_sym_LBRACE, + ACTIONS(270), 1, + sym_identifier, + ACTIONS(278), 1, + sym_float_lit, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + ACTIONS(327), 1, + sym_hex_lit, + STATE(93), 1, + aux_sym_string_repeat3, + STATE(251), 1, + sym_constant, + ACTIONS(272), 2, + sym_true, + sym_false, + ACTIONS(321), 2, + anon_sym_DASH, + anon_sym_PLUS, + ACTIONS(325), 2, + sym_decimal_lit, + sym_octal_lit, + STATE(112), 5, + sym_block_lit, + sym_full_ident, + sym_bool, + sym_int_lit, + sym_string, + [2800] = 13, + ACTIONS(3), 1, + sym_comment, + ACTIONS(262), 1, + anon_sym_LBRACE, + ACTIONS(270), 1, + sym_identifier, + ACTIONS(278), 1, + sym_float_lit, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + ACTIONS(327), 1, + sym_hex_lit, + STATE(93), 1, + aux_sym_string_repeat3, + STATE(322), 1, + sym_constant, + ACTIONS(272), 2, + sym_true, + sym_false, + ACTIONS(321), 2, + anon_sym_DASH, + anon_sym_PLUS, + ACTIONS(325), 2, + sym_decimal_lit, + sym_octal_lit, + STATE(112), 5, + sym_block_lit, + sym_full_ident, + sym_bool, + sym_int_lit, + sym_string, + [2847] = 13, + ACTIONS(3), 1, + sym_comment, + ACTIONS(262), 1, + anon_sym_LBRACE, + ACTIONS(270), 1, + sym_identifier, + ACTIONS(278), 1, + sym_float_lit, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + ACTIONS(327), 1, + sym_hex_lit, + STATE(93), 1, + aux_sym_string_repeat3, + STATE(326), 1, + sym_constant, + ACTIONS(272), 2, + sym_true, + sym_false, + ACTIONS(321), 2, + anon_sym_DASH, + anon_sym_PLUS, + ACTIONS(325), 2, + sym_decimal_lit, + sym_octal_lit, + STATE(112), 5, + sym_block_lit, + sym_full_ident, + sym_bool, + sym_int_lit, + sym_string, + [2894] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(123), 13, + ts_builtin_sym_end, + anon_sym_SEMI, + anon_sym_import, + anon_sym_option, + anon_sym_package, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_RBRACE, + anon_sym_message, + anon_sym_extend, + anon_sym_service, + anon_sym_rpc, + [2913] = 3, + ACTIONS(3), 1, + sym_comment, + STATE(335), 1, + sym_key_type, + ACTIONS(343), 12, + anon_sym_int32, + anon_sym_int64, + anon_sym_uint32, + anon_sym_uint64, + anon_sym_sint32, + anon_sym_sint64, + anon_sym_fixed32, + anon_sym_fixed64, + anon_sym_sfixed32, + anon_sym_sfixed64, + anon_sym_bool, + anon_sym_string, + [2934] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(211), 13, + ts_builtin_sym_end, + anon_sym_SEMI, + anon_sym_import, + anon_sym_option, + anon_sym_package, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_RBRACE, + anon_sym_message, + anon_sym_extend, + anon_sym_service, + anon_sym_rpc, + [2953] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(345), 11, + ts_builtin_sym_end, + anon_sym_SEMI, + anon_sym_import, + anon_sym_option, + anon_sym_package, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_service, + [2970] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(347), 11, + ts_builtin_sym_end, + anon_sym_SEMI, + anon_sym_import, + anon_sym_option, + anon_sym_package, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_service, + [2987] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(203), 11, + ts_builtin_sym_end, + anon_sym_SEMI, + anon_sym_import, + anon_sym_option, + anon_sym_package, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_service, + [3004] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(179), 11, + ts_builtin_sym_end, + anon_sym_SEMI, + anon_sym_import, + anon_sym_option, + anon_sym_package, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_service, + [3021] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(349), 11, + ts_builtin_sym_end, + anon_sym_SEMI, + anon_sym_import, + anon_sym_option, + anon_sym_package, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_service, + [3038] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(119), 11, + ts_builtin_sym_end, + anon_sym_SEMI, + anon_sym_import, + anon_sym_option, + anon_sym_package, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_service, + [3055] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(351), 11, + ts_builtin_sym_end, + anon_sym_SEMI, + anon_sym_import, + anon_sym_option, + anon_sym_package, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_service, + [3072] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(175), 11, + ts_builtin_sym_end, + anon_sym_SEMI, + anon_sym_import, + anon_sym_option, + anon_sym_package, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_service, + [3089] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(183), 11, + ts_builtin_sym_end, + anon_sym_SEMI, + anon_sym_import, + anon_sym_option, + anon_sym_package, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_service, + [3106] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(353), 11, + ts_builtin_sym_end, + anon_sym_SEMI, + anon_sym_import, + anon_sym_option, + anon_sym_package, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_service, + [3123] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(355), 11, + ts_builtin_sym_end, + anon_sym_SEMI, + anon_sym_import, + anon_sym_option, + anon_sym_package, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_service, + [3140] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(359), 1, + anon_sym_DOT, + STATE(78), 1, + aux_sym__option_name_repeat1, + ACTIONS(357), 9, + anon_sym_SEMI, + anon_sym_EQ, + anon_sym_RPAREN, + anon_sym_LBRACE, + anon_sym_RBRACE, + anon_sym_LBRACK, + anon_sym_COMMA, + anon_sym_RBRACK, + sym_identifier, + [3161] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(187), 11, + ts_builtin_sym_end, + anon_sym_SEMI, + anon_sym_import, + anon_sym_option, + anon_sym_package, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_service, + [3178] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(191), 11, + ts_builtin_sym_end, + anon_sym_SEMI, + anon_sym_import, + anon_sym_option, + anon_sym_package, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_service, + [3195] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(195), 11, + ts_builtin_sym_end, + anon_sym_SEMI, + anon_sym_import, + anon_sym_option, + anon_sym_package, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_service, + [3212] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(199), 11, + ts_builtin_sym_end, + anon_sym_SEMI, + anon_sym_import, + anon_sym_option, + anon_sym_package, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_service, + [3229] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(362), 11, + ts_builtin_sym_end, + anon_sym_SEMI, + anon_sym_import, + anon_sym_option, + anon_sym_package, + anon_sym_export, + anon_sym_local, + anon_sym_enum, + anon_sym_message, + anon_sym_extend, + anon_sym_service, + [3246] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(366), 1, + anon_sym_DOT, + STATE(78), 1, + aux_sym__option_name_repeat1, + ACTIONS(364), 8, + anon_sym_SEMI, + anon_sym_RPAREN, + anon_sym_LBRACE, + anon_sym_RBRACE, + anon_sym_LBRACK, + anon_sym_COMMA, + anon_sym_RBRACK, + sym_identifier, + [3266] = 7, + ACTIONS(3), 1, + sym_comment, + ACTIONS(368), 1, + anon_sym_SEMI, + ACTIONS(371), 1, + anon_sym_option, + ACTIONS(374), 1, + anon_sym_RBRACE, + ACTIONS(376), 1, + anon_sym_reserved, + ACTIONS(379), 1, + sym_identifier, + STATE(85), 5, + sym_empty_statement, + sym_option, + sym_enum_field, + sym_reserved, + aux_sym_enum_body_repeat1, + [3292] = 7, + ACTIONS(3), 1, + sym_comment, + ACTIONS(382), 1, + anon_sym_SEMI, + ACTIONS(384), 1, + anon_sym_option, + ACTIONS(386), 1, + anon_sym_RBRACE, + ACTIONS(388), 1, + anon_sym_reserved, + ACTIONS(390), 1, + sym_identifier, + STATE(87), 5, + sym_empty_statement, + sym_option, + sym_enum_field, + sym_reserved, + aux_sym_enum_body_repeat1, + [3318] = 7, + ACTIONS(3), 1, + sym_comment, + ACTIONS(382), 1, + anon_sym_SEMI, + ACTIONS(384), 1, + anon_sym_option, + ACTIONS(388), 1, + anon_sym_reserved, + ACTIONS(390), 1, + sym_identifier, + ACTIONS(392), 1, + anon_sym_RBRACE, + STATE(85), 5, + sym_empty_statement, + sym_option, + sym_enum_field, + sym_reserved, + aux_sym_enum_body_repeat1, + [3344] = 7, + ACTIONS(3), 1, + sym_comment, + ACTIONS(382), 1, + anon_sym_SEMI, + ACTIONS(384), 1, + anon_sym_option, + ACTIONS(388), 1, + anon_sym_reserved, + ACTIONS(390), 1, + sym_identifier, + ACTIONS(394), 1, + anon_sym_RBRACE, + STATE(91), 5, + sym_empty_statement, + sym_option, + sym_enum_field, + sym_reserved, + aux_sym_enum_body_repeat1, + [3370] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(366), 1, + anon_sym_DOT, + STATE(84), 1, + aux_sym__option_name_repeat1, + ACTIONS(396), 8, + anon_sym_SEMI, + anon_sym_RPAREN, + anon_sym_LBRACE, + anon_sym_RBRACE, + anon_sym_LBRACK, + anon_sym_COMMA, + anon_sym_RBRACK, + sym_identifier, + [3390] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(357), 10, + anon_sym_SEMI, + anon_sym_EQ, + anon_sym_RPAREN, + anon_sym_DOT, + anon_sym_LBRACE, + anon_sym_RBRACE, + anon_sym_LBRACK, + anon_sym_COMMA, + anon_sym_RBRACK, + sym_identifier, + [3406] = 7, + ACTIONS(3), 1, + sym_comment, + ACTIONS(382), 1, + anon_sym_SEMI, + ACTIONS(384), 1, + anon_sym_option, + ACTIONS(388), 1, + anon_sym_reserved, + ACTIONS(390), 1, + sym_identifier, + ACTIONS(398), 1, + anon_sym_RBRACE, + STATE(85), 5, + sym_empty_statement, + sym_option, + sym_enum_field, + sym_reserved, + aux_sym_enum_body_repeat1, + [3432] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(402), 1, + anon_sym_DQUOTE, + ACTIONS(405), 1, + anon_sym_SQUOTE, + STATE(92), 1, + aux_sym_string_repeat3, + ACTIONS(400), 6, + anon_sym_SEMI, + anon_sym_RBRACE, + anon_sym_LBRACK, + anon_sym_COMMA, + anon_sym_RBRACK, + sym_identifier, + [3453] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + STATE(92), 1, + aux_sym_string_repeat3, + ACTIONS(408), 6, + anon_sym_SEMI, + anon_sym_RBRACE, + anon_sym_LBRACK, + anon_sym_COMMA, + anon_sym_RBRACK, + sym_identifier, + [3474] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(400), 8, + anon_sym_SEMI, + anon_sym_RBRACE, + anon_sym_LBRACK, + anon_sym_COMMA, + anon_sym_RBRACK, + sym_identifier, + anon_sym_DQUOTE, + anon_sym_SQUOTE, + [3488] = 7, + ACTIONS(3), 1, + sym_comment, + ACTIONS(325), 1, + sym_octal_lit, + ACTIONS(410), 1, + sym_reserved_identifier, + STATE(179), 1, + sym_range, + STATE(189), 1, + sym_int_lit, + ACTIONS(327), 2, + sym_decimal_lit, + sym_hex_lit, + STATE(325), 2, + sym_ranges, + sym_reserved_field_names, + [3512] = 6, + ACTIONS(3), 1, + sym_comment, + ACTIONS(412), 1, + anon_sym_SEMI, + ACTIONS(415), 1, + anon_sym_option, + ACTIONS(418), 1, + anon_sym_RBRACE, + ACTIONS(420), 1, + anon_sym_rpc, + STATE(96), 4, + sym_empty_statement, + sym_option, + sym_rpc, + aux_sym_service_repeat1, + [3534] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(423), 8, + anon_sym_SEMI, + anon_sym_RBRACE, + anon_sym_LBRACK, + anon_sym_COMMA, + anon_sym_RBRACK, + sym_identifier, + anon_sym_DQUOTE, + anon_sym_SQUOTE, + [3548] = 7, + ACTIONS(3), 1, + sym_comment, + ACTIONS(325), 1, + sym_octal_lit, + ACTIONS(410), 1, + sym_reserved_identifier, + STATE(179), 1, + sym_range, + STATE(189), 1, + sym_int_lit, + ACTIONS(327), 2, + sym_decimal_lit, + sym_hex_lit, + STATE(303), 2, + sym_ranges, + sym_reserved_field_names, + [3572] = 6, + ACTIONS(3), 1, + sym_comment, + ACTIONS(7), 1, + anon_sym_SEMI, + ACTIONS(15), 1, + anon_sym_option, + ACTIONS(425), 1, + anon_sym_RBRACE, + ACTIONS(427), 1, + anon_sym_rpc, + STATE(96), 4, + sym_empty_statement, + sym_option, + sym_rpc, + aux_sym_service_repeat1, + [3594] = 6, + ACTIONS(3), 1, + sym_comment, + ACTIONS(7), 1, + anon_sym_SEMI, + ACTIONS(15), 1, + anon_sym_option, + ACTIONS(427), 1, + anon_sym_rpc, + ACTIONS(429), 1, + anon_sym_RBRACE, + STATE(99), 4, + sym_empty_statement, + sym_option, + sym_rpc, + aux_sym_service_repeat1, + [3616] = 6, + ACTIONS(3), 1, + sym_comment, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + STATE(93), 1, + aux_sym_string_repeat3, + STATE(302), 1, + sym_string, + ACTIONS(431), 3, + anon_sym_weak, + anon_sym_public, + anon_sym_option, + [3637] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(7), 1, + anon_sym_SEMI, + ACTIONS(15), 1, + anon_sym_option, + ACTIONS(433), 1, + anon_sym_RBRACE, + STATE(104), 3, + sym_empty_statement, + sym_option, + aux_sym_rpc_repeat1, + [3655] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(7), 1, + anon_sym_SEMI, + ACTIONS(15), 1, + anon_sym_option, + ACTIONS(435), 1, + anon_sym_RBRACE, + STATE(106), 3, + sym_empty_statement, + sym_option, + aux_sym_rpc_repeat1, + [3673] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(7), 1, + anon_sym_SEMI, + ACTIONS(15), 1, + anon_sym_option, + ACTIONS(435), 1, + anon_sym_RBRACE, + STATE(107), 3, + sym_empty_statement, + sym_option, + aux_sym_rpc_repeat1, + [3691] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(7), 1, + anon_sym_SEMI, + ACTIONS(15), 1, + anon_sym_option, + ACTIONS(437), 1, + anon_sym_RBRACE, + STATE(108), 3, + sym_empty_statement, + sym_option, + aux_sym_rpc_repeat1, + [3709] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(7), 1, + anon_sym_SEMI, + ACTIONS(15), 1, + anon_sym_option, + ACTIONS(437), 1, + anon_sym_RBRACE, + STATE(107), 3, + sym_empty_statement, + sym_option, + aux_sym_rpc_repeat1, + [3727] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(439), 1, + anon_sym_SEMI, + ACTIONS(442), 1, + anon_sym_option, + ACTIONS(445), 1, + anon_sym_RBRACE, + STATE(107), 3, + sym_empty_statement, + sym_option, + aux_sym_rpc_repeat1, + [3745] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(7), 1, + anon_sym_SEMI, + ACTIONS(15), 1, + anon_sym_option, + ACTIONS(447), 1, + anon_sym_RBRACE, + STATE(107), 3, + sym_empty_statement, + sym_option, + aux_sym_rpc_repeat1, + [3763] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(258), 6, + anon_sym_SEMI, + anon_sym_LBRACE, + anon_sym_LBRACK, + anon_sym_COMMA, + anon_sym_RBRACK, + anon_sym_to, + [3775] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(449), 6, + anon_sym_SEMI, + anon_sym_RBRACE, + anon_sym_LBRACK, + anon_sym_COMMA, + anon_sym_RBRACK, + sym_identifier, + [3787] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(451), 6, + anon_sym_SEMI, + anon_sym_RBRACE, + anon_sym_LBRACK, + anon_sym_COMMA, + anon_sym_RBRACK, + sym_identifier, + [3799] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(453), 6, + anon_sym_SEMI, + anon_sym_RBRACE, + anon_sym_LBRACK, + anon_sym_COMMA, + anon_sym_RBRACK, + sym_identifier, + [3811] = 6, + ACTIONS(3), 1, + sym_comment, + ACTIONS(325), 1, + sym_octal_lit, + STATE(179), 1, + sym_range, + STATE(189), 1, + sym_int_lit, + STATE(286), 1, + sym_ranges, + ACTIONS(327), 2, + sym_decimal_lit, + sym_hex_lit, + [3831] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(455), 6, + anon_sym_SEMI, + anon_sym_RBRACE, + anon_sym_LBRACK, + anon_sym_COMMA, + anon_sym_RBRACK, + sym_identifier, + [3843] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(457), 6, + anon_sym_SEMI, + anon_sym_RBRACE, + anon_sym_LBRACK, + anon_sym_COMMA, + anon_sym_RBRACK, + sym_identifier, + [3855] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(327), 1, + sym_hex_lit, + ACTIONS(459), 1, + sym_float_lit, + STATE(111), 1, + sym_int_lit, + ACTIONS(325), 2, + sym_decimal_lit, + sym_octal_lit, + [3872] = 6, + ACTIONS(3), 1, + sym_comment, + ACTIONS(461), 1, + anon_sym_LPAREN, + ACTIONS(463), 1, + sym_identifier, + STATE(210), 1, + sym_field_option, + STATE(305), 1, + sym_field_options, + STATE(317), 1, + sym__option_name, + [3891] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(465), 2, + anon_sym_SEMI, + anon_sym_COMMA, + ACTIONS(467), 3, + anon_sym_RBRACE, + anon_sym_LBRACK, + sym_identifier, + [3904] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(325), 1, + sym_octal_lit, + STATE(189), 1, + sym_int_lit, + STATE(229), 1, + sym_range, + ACTIONS(327), 2, + sym_decimal_lit, + sym_hex_lit, + [3921] = 6, + ACTIONS(3), 1, + sym_comment, + ACTIONS(33), 1, + anon_sym_DOT, + ACTIONS(61), 1, + sym_identifier, + ACTIONS(469), 1, + anon_sym_stream, + STATE(271), 1, + aux_sym_message_or_enum_type_repeat1, + STATE(318), 1, + sym_message_or_enum_type, + [3940] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(471), 2, + anon_sym_SEMI, + anon_sym_COMMA, + ACTIONS(473), 3, + anon_sym_RBRACE, + anon_sym_LBRACK, + sym_identifier, + [3953] = 6, + ACTIONS(3), 1, + sym_comment, + ACTIONS(461), 1, + anon_sym_LPAREN, + ACTIONS(463), 1, + sym_identifier, + STATE(210), 1, + sym_field_option, + STATE(317), 1, + sym__option_name, + STATE(331), 1, + sym_field_options, + [3972] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(475), 2, + anon_sym_SEMI, + anon_sym_RBRACE, + ACTIONS(477), 3, + anon_sym_option, + anon_sym_reserved, + sym_identifier, + [3985] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(211), 2, + anon_sym_SEMI, + anon_sym_RBRACE, + ACTIONS(213), 3, + anon_sym_option, + anon_sym_reserved, + sym_identifier, + [3998] = 6, + ACTIONS(3), 1, + sym_comment, + ACTIONS(461), 1, + anon_sym_LPAREN, + ACTIONS(463), 1, + sym_identifier, + STATE(210), 1, + sym_field_option, + STATE(317), 1, + sym__option_name, + STATE(332), 1, + sym_field_options, + [4017] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(325), 1, + sym_octal_lit, + STATE(36), 1, + sym_int_lit, + STATE(218), 1, + sym_field_number, + ACTIONS(327), 2, + sym_decimal_lit, + sym_hex_lit, + [4034] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(479), 2, + anon_sym_SEMI, + anon_sym_COMMA, + ACTIONS(481), 3, + anon_sym_RBRACE, + anon_sym_LBRACK, + sym_identifier, + [4047] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(258), 5, + anon_sym_SEMI, + anon_sym_RBRACE, + anon_sym_LBRACK, + anon_sym_COMMA, + sym_identifier, + [4058] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(325), 1, + sym_octal_lit, + STATE(36), 1, + sym_int_lit, + STATE(269), 1, + sym_field_number, + ACTIONS(327), 2, + sym_decimal_lit, + sym_hex_lit, + [4075] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(325), 1, + sym_octal_lit, + ACTIONS(483), 1, + anon_sym_DASH, + STATE(256), 1, + sym_int_lit, + ACTIONS(327), 2, + sym_decimal_lit, + sym_hex_lit, + [4092] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(485), 2, + anon_sym_SEMI, + anon_sym_RBRACE, + ACTIONS(487), 3, + anon_sym_option, + anon_sym_reserved, + sym_identifier, + [4105] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(491), 1, + sym_octal_lit, + STATE(36), 1, + sym_int_lit, + STATE(37), 1, + sym_field_number, + ACTIONS(489), 2, + sym_decimal_lit, + sym_hex_lit, + [4122] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(123), 2, + anon_sym_SEMI, + anon_sym_RBRACE, + ACTIONS(125), 3, + anon_sym_option, + anon_sym_reserved, + sym_identifier, + [4135] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(493), 2, + anon_sym_SEMI, + anon_sym_COMMA, + ACTIONS(495), 3, + anon_sym_RBRACE, + anon_sym_LBRACK, + sym_identifier, + [4148] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(325), 1, + sym_octal_lit, + STATE(36), 1, + sym_int_lit, + STATE(234), 1, + sym_field_number, + ACTIONS(327), 2, + sym_decimal_lit, + sym_hex_lit, + [4165] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(497), 2, + anon_sym_SEMI, + anon_sym_RBRACE, + ACTIONS(499), 3, + anon_sym_option, + anon_sym_reserved, + sym_identifier, + [4178] = 6, + ACTIONS(3), 1, + sym_comment, + ACTIONS(461), 1, + anon_sym_LPAREN, + ACTIONS(463), 1, + sym_identifier, + STATE(210), 1, + sym_field_option, + STATE(298), 1, + sym_field_options, + STATE(317), 1, + sym__option_name, + [4197] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(501), 2, + anon_sym_SEMI, + anon_sym_RBRACE, + ACTIONS(503), 3, + anon_sym_option, + anon_sym_reserved, + sym_identifier, + [4210] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(505), 2, + anon_sym_SEMI, + anon_sym_COMMA, + ACTIONS(507), 3, + anon_sym_RBRACE, + anon_sym_LBRACK, + sym_identifier, + [4223] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(325), 1, + sym_octal_lit, + STATE(36), 1, + sym_int_lit, + STATE(253), 1, + sym_field_number, + ACTIONS(327), 2, + sym_decimal_lit, + sym_hex_lit, + [4240] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(509), 2, + anon_sym_SEMI, + anon_sym_COMMA, + ACTIONS(511), 3, + anon_sym_RBRACE, + anon_sym_LBRACK, + sym_identifier, + [4253] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(513), 2, + anon_sym_SEMI, + anon_sym_COMMA, + ACTIONS(515), 3, + anon_sym_RBRACE, + anon_sym_LBRACK, + sym_identifier, + [4266] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(325), 1, + sym_octal_lit, + ACTIONS(517), 1, + anon_sym_max, + STATE(230), 1, + sym_int_lit, + ACTIONS(327), 2, + sym_decimal_lit, + sym_hex_lit, + [4283] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(276), 1, + sym_hex_lit, + ACTIONS(459), 1, + sym_float_lit, + STATE(111), 1, + sym_int_lit, + ACTIONS(274), 2, + sym_decimal_lit, + sym_octal_lit, + [4300] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(325), 1, + sym_octal_lit, + STATE(36), 1, + sym_int_lit, + STATE(235), 1, + sym_field_number, + ACTIONS(327), 2, + sym_decimal_lit, + sym_hex_lit, + [4317] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(519), 2, + anon_sym_SEMI, + anon_sym_RBRACE, + ACTIONS(521), 3, + anon_sym_option, + anon_sym_reserved, + sym_identifier, + [4330] = 6, + ACTIONS(3), 1, + sym_comment, + ACTIONS(461), 1, + anon_sym_LPAREN, + ACTIONS(463), 1, + sym_identifier, + STATE(210), 1, + sym_field_option, + STATE(273), 1, + sym_field_options, + STATE(317), 1, + sym__option_name, + [4349] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(207), 2, + anon_sym_SEMI, + anon_sym_RBRACE, + ACTIONS(209), 3, + anon_sym_option, + anon_sym_reserved, + sym_identifier, + [4362] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(325), 1, + sym_octal_lit, + STATE(36), 1, + sym_int_lit, + STATE(219), 1, + sym_field_number, + ACTIONS(327), 2, + sym_decimal_lit, + sym_hex_lit, + [4379] = 6, + ACTIONS(3), 1, + sym_comment, + ACTIONS(33), 1, + anon_sym_DOT, + ACTIONS(61), 1, + sym_identifier, + ACTIONS(523), 1, + anon_sym_stream, + STATE(271), 1, + aux_sym_message_or_enum_type_repeat1, + STATE(341), 1, + sym_message_or_enum_type, + [4398] = 6, + ACTIONS(3), 1, + sym_comment, + ACTIONS(33), 1, + anon_sym_DOT, + ACTIONS(61), 1, + sym_identifier, + ACTIONS(525), 1, + anon_sym_stream, + STATE(271), 1, + aux_sym_message_or_enum_type_repeat1, + STATE(337), 1, + sym_message_or_enum_type, + [4417] = 4, + ACTIONS(527), 1, + anon_sym_SQUOTE, + ACTIONS(531), 1, + sym_comment, + STATE(160), 1, + aux_sym_string_repeat2, + ACTIONS(529), 2, + aux_sym_string_token2, + sym_escape_sequence, + [4431] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(533), 1, + anon_sym_RBRACE, + ACTIONS(535), 1, + anon_sym_LBRACK, + ACTIONS(537), 1, + sym_identifier, + STATE(161), 1, + aux_sym_block_lit_repeat2, + [4447] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(461), 1, + anon_sym_LPAREN, + ACTIONS(463), 1, + sym_identifier, + STATE(194), 1, + sym_enum_value_option, + STATE(310), 1, + sym__option_name, + [4463] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(33), 1, + anon_sym_DOT, + ACTIONS(539), 1, + sym_identifier, + STATE(271), 1, + aux_sym_message_or_enum_type_repeat1, + STATE(288), 1, + sym_message_or_enum_type, + [4479] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(461), 1, + anon_sym_LPAREN, + ACTIONS(463), 1, + sym_identifier, + STATE(201), 1, + sym_enum_value_option, + STATE(310), 1, + sym__option_name, + [4495] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(543), 1, + anon_sym_DOT, + ACTIONS(541), 3, + anon_sym_RPAREN, + anon_sym_GT, + sym_identifier, + [4507] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(461), 1, + anon_sym_LPAREN, + ACTIONS(463), 1, + sym_identifier, + STATE(223), 1, + sym_enum_value_option, + STATE(310), 1, + sym__option_name, + [4523] = 4, + ACTIONS(531), 1, + sym_comment, + ACTIONS(545), 1, + anon_sym_DQUOTE, + STATE(177), 1, + aux_sym_string_repeat1, + ACTIONS(547), 2, + aux_sym_string_token1, + sym_escape_sequence, + [4537] = 4, + ACTIONS(531), 1, + sym_comment, + ACTIONS(545), 1, + anon_sym_SQUOTE, + STATE(178), 1, + aux_sym_string_repeat2, + ACTIONS(549), 2, + aux_sym_string_token2, + sym_escape_sequence, + [4551] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(535), 1, + anon_sym_LBRACK, + ACTIONS(537), 1, + sym_identifier, + ACTIONS(551), 1, + anon_sym_RBRACE, + STATE(174), 1, + aux_sym_block_lit_repeat2, + [4567] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(461), 1, + anon_sym_LPAREN, + ACTIONS(463), 1, + sym_identifier, + STATE(233), 1, + sym_field_option, + STATE(317), 1, + sym__option_name, + [4583] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(33), 1, + anon_sym_DOT, + ACTIONS(539), 1, + sym_identifier, + STATE(271), 1, + aux_sym_message_or_enum_type_repeat1, + STATE(337), 1, + sym_message_or_enum_type, + [4599] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(543), 1, + anon_sym_DOT, + ACTIONS(553), 3, + anon_sym_RPAREN, + anon_sym_GT, + sym_identifier, + [4611] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(33), 1, + anon_sym_DOT, + ACTIONS(539), 1, + sym_identifier, + STATE(271), 1, + aux_sym_message_or_enum_type_repeat1, + STATE(278), 1, + sym_message_or_enum_type, + [4627] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + STATE(93), 1, + aux_sym_string_repeat3, + STATE(295), 1, + sym_string, + [4643] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(555), 4, + anon_sym_SEMI, + anon_sym_option, + anon_sym_RBRACE, + anon_sym_rpc, + [4653] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(557), 4, + anon_sym_SEMI, + anon_sym_option, + anon_sym_RBRACE, + anon_sym_rpc, + [4663] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(559), 4, + anon_sym_SEMI, + anon_sym_option, + anon_sym_RBRACE, + anon_sym_rpc, + [4673] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(561), 4, + anon_sym_SEMI, + anon_sym_option, + anon_sym_RBRACE, + anon_sym_rpc, + [4683] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(563), 4, + anon_sym_SEMI, + anon_sym_option, + anon_sym_RBRACE, + anon_sym_rpc, + [4693] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(280), 1, + anon_sym_DQUOTE, + ACTIONS(282), 1, + anon_sym_SQUOTE, + STATE(93), 1, + aux_sym_string_repeat3, + STATE(284), 1, + sym_string, + [4709] = 4, + ACTIONS(527), 1, + anon_sym_DQUOTE, + ACTIONS(531), 1, + sym_comment, + STATE(159), 1, + aux_sym_string_repeat1, + ACTIONS(565), 2, + aux_sym_string_token1, + sym_escape_sequence, + [4723] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(515), 1, + anon_sym_RBRACE, + ACTIONS(567), 1, + anon_sym_LBRACK, + ACTIONS(570), 1, + sym_identifier, + STATE(174), 1, + aux_sym_block_lit_repeat2, + [4739] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(325), 1, + sym_octal_lit, + STATE(231), 1, + sym_int_lit, + ACTIONS(327), 2, + sym_decimal_lit, + sym_hex_lit, + [4753] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(543), 1, + anon_sym_DOT, + ACTIONS(573), 3, + anon_sym_RPAREN, + anon_sym_GT, + sym_identifier, + [4765] = 4, + ACTIONS(531), 1, + sym_comment, + ACTIONS(575), 1, + anon_sym_DQUOTE, + STATE(177), 1, + aux_sym_string_repeat1, + ACTIONS(577), 2, + aux_sym_string_token1, + sym_escape_sequence, + [4779] = 4, + ACTIONS(531), 1, + sym_comment, + ACTIONS(580), 1, + anon_sym_SQUOTE, + STATE(178), 1, + aux_sym_string_repeat2, + ACTIONS(582), 2, + aux_sym_string_token2, + sym_escape_sequence, + [4793] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(585), 1, + anon_sym_SEMI, + ACTIONS(587), 1, + anon_sym_COMMA, + STATE(209), 1, + aux_sym_ranges_repeat1, + [4806] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(589), 1, + anon_sym_COMMA, + ACTIONS(591), 1, + anon_sym_RBRACK, + STATE(181), 1, + aux_sym_enum_field_repeat1, + [4819] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(593), 1, + anon_sym_COMMA, + ACTIONS(596), 1, + anon_sym_RBRACK, + STATE(181), 1, + aux_sym_enum_field_repeat1, + [4832] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(333), 1, + anon_sym_RBRACK, + ACTIONS(598), 1, + anon_sym_COMMA, + STATE(199), 1, + aux_sym_block_lit_repeat1, + [4845] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(333), 1, + anon_sym_RBRACK, + ACTIONS(598), 1, + anon_sym_COMMA, + STATE(200), 1, + aux_sym_block_lit_repeat1, + [4858] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(600), 1, + anon_sym_COMMA, + ACTIONS(602), 1, + anon_sym_RBRACK, + STATE(191), 1, + aux_sym_field_options_repeat1, + [4871] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(473), 3, + anon_sym_RBRACE, + anon_sym_LBRACK, + sym_identifier, + [4880] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(604), 1, + anon_sym_SEMI, + ACTIONS(606), 1, + anon_sym_COMMA, + STATE(207), 1, + aux_sym_reserved_field_names_repeat1, + [4893] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(598), 1, + anon_sym_COMMA, + ACTIONS(608), 1, + anon_sym_RBRACK, + STATE(199), 1, + aux_sym_block_lit_repeat1, + [4906] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(461), 1, + anon_sym_LPAREN, + ACTIONS(463), 1, + sym_identifier, + STATE(340), 1, + sym__option_name, + [4919] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(612), 1, + anon_sym_to, + ACTIONS(610), 2, + anon_sym_SEMI, + anon_sym_COMMA, + [4930] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(366), 1, + anon_sym_DOT, + ACTIONS(614), 1, + anon_sym_EQ, + STATE(78), 1, + aux_sym__option_name_repeat1, + [4943] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(616), 1, + anon_sym_COMMA, + ACTIONS(619), 1, + anon_sym_RBRACK, + STATE(191), 1, + aux_sym_field_options_repeat1, + [4956] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(621), 1, + anon_sym_SEMI, + ACTIONS(623), 1, + anon_sym_COMMA, + STATE(192), 1, + aux_sym_reserved_field_names_repeat1, + [4969] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(495), 3, + anon_sym_RBRACE, + anon_sym_LBRACK, + sym_identifier, + [4978] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(589), 1, + anon_sym_COMMA, + ACTIONS(626), 1, + anon_sym_RBRACK, + STATE(203), 1, + aux_sym_enum_field_repeat1, + [4991] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(598), 1, + anon_sym_COMMA, + ACTIONS(628), 1, + anon_sym_RBRACK, + STATE(187), 1, + aux_sym_block_lit_repeat1, + [5004] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(630), 3, + anon_sym_RBRACE, + anon_sym_LBRACK, + sym_identifier, + [5013] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(341), 1, + anon_sym_RBRACK, + ACTIONS(598), 1, + anon_sym_COMMA, + STATE(213), 1, + aux_sym_block_lit_repeat1, + [5026] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(507), 3, + anon_sym_RBRACE, + anon_sym_LBRACK, + sym_identifier, + [5035] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(632), 1, + anon_sym_COMMA, + ACTIONS(635), 1, + anon_sym_RBRACK, + STATE(199), 1, + aux_sym_block_lit_repeat1, + [5048] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(341), 1, + anon_sym_RBRACK, + ACTIONS(598), 1, + anon_sym_COMMA, + STATE(199), 1, + aux_sym_block_lit_repeat1, + [5061] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(589), 1, + anon_sym_COMMA, + ACTIONS(637), 1, + anon_sym_RBRACK, + STATE(180), 1, + aux_sym_enum_field_repeat1, + [5074] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(329), 1, + anon_sym_RBRACK, + ACTIONS(598), 1, + anon_sym_COMMA, + STATE(182), 1, + aux_sym_block_lit_repeat1, + [5087] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(589), 1, + anon_sym_COMMA, + ACTIONS(637), 1, + anon_sym_RBRACK, + STATE(181), 1, + aux_sym_enum_field_repeat1, + [5100] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(511), 3, + anon_sym_RBRACE, + anon_sym_LBRACK, + sym_identifier, + [5109] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(461), 1, + anon_sym_LPAREN, + ACTIONS(463), 1, + sym_identifier, + STATE(344), 1, + sym__option_name, + [5122] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(481), 3, + anon_sym_RBRACE, + anon_sym_LBRACK, + sym_identifier, + [5131] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(606), 1, + anon_sym_COMMA, + ACTIONS(639), 1, + anon_sym_SEMI, + STATE(192), 1, + aux_sym_reserved_field_names_repeat1, + [5144] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(366), 1, + anon_sym_DOT, + ACTIONS(641), 1, + anon_sym_EQ, + STATE(190), 1, + aux_sym__option_name_repeat1, + [5157] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(587), 1, + anon_sym_COMMA, + ACTIONS(643), 1, + anon_sym_SEMI, + STATE(217), 1, + aux_sym_ranges_repeat1, + [5170] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(600), 1, + anon_sym_COMMA, + ACTIONS(645), 1, + anon_sym_RBRACK, + STATE(184), 1, + aux_sym_field_options_repeat1, + [5183] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(467), 3, + anon_sym_RBRACE, + anon_sym_LBRACK, + sym_identifier, + [5192] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(366), 1, + anon_sym_DOT, + ACTIONS(647), 1, + anon_sym_EQ, + STATE(78), 1, + aux_sym__option_name_repeat1, + [5205] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(598), 1, + anon_sym_COMMA, + ACTIONS(628), 1, + anon_sym_RBRACK, + STATE(199), 1, + aux_sym_block_lit_repeat1, + [5218] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(366), 1, + anon_sym_DOT, + ACTIONS(649), 1, + anon_sym_EQ, + STATE(212), 1, + aux_sym__option_name_repeat1, + [5231] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(461), 1, + anon_sym_LPAREN, + ACTIONS(463), 1, + sym_identifier, + STATE(338), 1, + sym__option_name, + [5244] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(461), 1, + anon_sym_LPAREN, + ACTIONS(463), 1, + sym_identifier, + STATE(339), 1, + sym__option_name, + [5257] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(651), 1, + anon_sym_SEMI, + ACTIONS(653), 1, + anon_sym_COMMA, + STATE(217), 1, + aux_sym_ranges_repeat1, + [5270] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(656), 1, + anon_sym_SEMI, + ACTIONS(658), 1, + anon_sym_LBRACK, + [5280] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(660), 1, + anon_sym_SEMI, + ACTIONS(662), 1, + anon_sym_LBRACK, + [5290] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(621), 2, + anon_sym_SEMI, + anon_sym_COMMA, + [5298] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(664), 1, + sym_identifier, + STATE(247), 1, + sym_enum_name, + [5308] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(666), 2, + anon_sym_COMMA, + anon_sym_RBRACK, + [5316] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(596), 2, + anon_sym_COMMA, + anon_sym_RBRACK, + [5324] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(668), 1, + sym_identifier, + STATE(311), 1, + sym_service_name, + [5334] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(670), 1, + sym_identifier, + STATE(237), 1, + aux_sym_message_or_enum_type_repeat1, + [5344] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(672), 1, + sym_identifier, + STATE(300), 1, + sym_full_ident, + [5354] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(664), 1, + sym_identifier, + STATE(243), 1, + sym_enum_name, + [5364] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(674), 1, + sym_identifier, + STATE(262), 1, + sym_message_name, + [5374] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(651), 2, + anon_sym_SEMI, + anon_sym_COMMA, + [5382] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(676), 2, + anon_sym_SEMI, + anon_sym_COMMA, + [5390] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(678), 1, + anon_sym_SEMI, + ACTIONS(680), 1, + anon_sym_LBRACK, + [5400] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(682), 2, + anon_sym_COMMA, + anon_sym_RBRACK, + [5408] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(619), 2, + anon_sym_COMMA, + anon_sym_RBRACK, + [5416] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(684), 1, + anon_sym_LBRACE, + STATE(14), 1, + sym_message_body, + [5426] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(686), 1, + anon_sym_SEMI, + ACTIONS(688), 1, + anon_sym_LBRACK, + [5436] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(674), 1, + sym_identifier, + STATE(277), 1, + sym_message_name, + [5446] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(690), 1, + sym_identifier, + STATE(246), 1, + aux_sym_message_or_enum_type_repeat1, + [5456] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(692), 1, + anon_sym_SEMI, + ACTIONS(694), 1, + anon_sym_LBRACE, + [5466] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(674), 1, + sym_identifier, + STATE(321), 1, + sym_message_name, + [5476] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(433), 1, + anon_sym_SEMI, + ACTIONS(696), 1, + anon_sym_LBRACE, + [5486] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(698), 2, + anon_sym_GT, + sym_identifier, + [5494] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(700), 1, + sym_identifier, + STATE(297), 1, + sym_rpc_name, + [5504] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(702), 1, + anon_sym_LBRACE, + STATE(79), 1, + sym_enum_body, + [5514] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(704), 1, + anon_sym_LBRACE, + STATE(80), 1, + sym_message_body, + [5524] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(706), 2, + anon_sym_DQUOTEproto3_DQUOTE, + anon_sym_DQUOTEproto2_DQUOTE, + [5532] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(708), 1, + sym_identifier, + STATE(246), 1, + aux_sym_message_or_enum_type_repeat1, + [5542] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(702), 1, + anon_sym_LBRACE, + STATE(74), 1, + sym_enum_body, + [5552] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(674), 1, + sym_identifier, + STATE(244), 1, + sym_message_name, + [5562] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(672), 1, + sym_identifier, + STATE(333), 1, + sym_full_ident, + [5572] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(672), 1, + sym_identifier, + STATE(279), 1, + sym_full_ident, + [5582] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(635), 2, + anon_sym_COMMA, + anon_sym_RBRACK, + [5590] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(711), 2, + anon_sym_EQ, + anon_sym_LBRACE, + [5598] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(684), 1, + anon_sym_LBRACE, + STATE(11), 1, + sym_message_body, + [5608] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(713), 1, + anon_sym_enum, + ACTIONS(715), 1, + anon_sym_message, + [5618] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(672), 1, + sym_identifier, + STATE(268), 1, + sym_full_ident, + [5628] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(717), 1, + anon_sym_SEMI, + ACTIONS(719), 1, + anon_sym_LBRACK, + [5638] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(721), 1, + anon_sym_LBRACE, + STATE(21), 1, + sym_enum_body, + [5648] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(684), 1, + anon_sym_LBRACE, + STATE(22), 1, + sym_message_body, + [5658] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(684), 1, + anon_sym_LBRACE, + STATE(23), 1, + sym_message_body, + [5668] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(721), 1, + anon_sym_LBRACE, + STATE(24), 1, + sym_enum_body, + [5678] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(684), 1, + anon_sym_LBRACE, + STATE(25), 1, + sym_message_body, + [5688] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(704), 1, + anon_sym_LBRACE, + STATE(70), 1, + sym_message_body, + [5698] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(664), 1, + sym_identifier, + STATE(257), 1, + sym_enum_name, + [5708] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(674), 1, + sym_identifier, + STATE(258), 1, + sym_message_name, + [5718] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(672), 1, + sym_identifier, + STATE(259), 1, + sym_full_ident, + [5728] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(664), 1, + sym_identifier, + STATE(260), 1, + sym_enum_name, + [5738] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(674), 1, + sym_identifier, + STATE(261), 1, + sym_message_name, + [5748] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(704), 1, + anon_sym_LBRACE, + STATE(75), 1, + sym_message_body, + [5758] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(723), 1, + anon_sym_SEMI, + ACTIONS(725), 1, + anon_sym_LBRACK, + [5768] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(727), 1, + anon_sym_enum, + ACTIONS(729), 1, + anon_sym_message, + [5778] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(670), 1, + sym_identifier, + STATE(246), 1, + aux_sym_message_or_enum_type_repeat1, + [5788] = 3, + ACTIONS(3), 1, + sym_comment, + ACTIONS(435), 1, + anon_sym_SEMI, + ACTIONS(731), 1, + anon_sym_LBRACE, + [5798] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(733), 1, + anon_sym_RBRACK, + [5805] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(735), 1, + anon_sym_GT, + [5812] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(737), 1, + anon_sym_EQ, + [5819] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(739), 1, + sym_identifier, + [5826] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(741), 1, + anon_sym_EQ, + [5833] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(743), 1, + anon_sym_RPAREN, + [5840] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(745), 1, + anon_sym_RPAREN, + [5847] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(747), 1, + anon_sym_SEMI, + [5854] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(749), 1, + anon_sym_LBRACE, + [5861] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(543), 1, + anon_sym_DOT, + [5868] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(751), 1, + anon_sym_EQ, + [5875] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(753), 1, + anon_sym_SEMI, + [5882] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(755), 1, + anon_sym_SEMI, + [5889] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(757), 1, + anon_sym_SEMI, + [5896] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(759), 1, + anon_sym_SEMI, + [5903] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(761), 1, + anon_sym_RPAREN, + [5910] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(763), 1, + sym_identifier, + [5917] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(765), 1, + anon_sym_EQ, + [5924] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(767), 1, + anon_sym_LPAREN, + [5931] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(769), 1, + sym_identifier, + [5938] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(771), 1, + anon_sym_returns, + [5945] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(773), 1, + anon_sym_LBRACE, + [5952] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(775), 1, + anon_sym_SEMI, + [5959] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(777), 1, + anon_sym_LPAREN, + [5966] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(779), 1, + anon_sym_LPAREN, + [5973] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(781), 1, + anon_sym_RBRACK, + [5980] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(783), 1, + sym_identifier, + [5987] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(785), 1, + anon_sym_RBRACK, + [5994] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(787), 1, + anon_sym_EQ, + [6001] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(789), 1, + anon_sym_SEMI, + [6008] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(791), 1, + anon_sym_SEMI, + [6015] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(793), 1, + anon_sym_SEMI, + [6022] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(795), 1, + anon_sym_RBRACK, + [6029] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(797), 1, + sym_reserved_identifier, + [6036] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(799), 1, + anon_sym_SEMI, + [6043] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(801), 1, + anon_sym_SEMI, + [6050] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(803), 1, + anon_sym_LBRACE, + [6057] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(805), 1, + anon_sym_EQ, + [6064] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(807), 1, + anon_sym_LBRACE, + [6071] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(809), 1, + anon_sym_returns, + [6078] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(811), 1, + anon_sym_SEMI, + [6085] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(813), 1, + anon_sym_LPAREN, + [6092] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(815), 1, + sym_identifier, + [6099] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(817), 1, + anon_sym_EQ, + [6106] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(819), 1, + anon_sym_EQ, + [6113] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(821), 1, + anon_sym_RPAREN, + [6120] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(823), 1, + ts_builtin_sym_end, + [6127] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(825), 1, + sym_identifier, + [6134] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(827), 1, + anon_sym_EQ, + [6141] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(829), 1, + anon_sym_SEMI, + [6148] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(831), 1, + anon_sym_EQ, + [6155] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(833), 1, + anon_sym_SEMI, + [6162] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(835), 1, + anon_sym_SEMI, + [6169] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(837), 1, + anon_sym_SEMI, + [6176] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(839), 1, + anon_sym_SEMI, + [6183] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(841), 1, + anon_sym_EQ, + [6190] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(843), 1, + anon_sym_EQ, + [6197] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(845), 1, + anon_sym_SEMI, + [6204] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(847), 1, + anon_sym_RBRACK, + [6211] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(849), 1, + anon_sym_RBRACK, + [6218] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(851), 1, + anon_sym_SEMI, + [6225] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(853), 1, + anon_sym_COMMA, + [6232] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(855), 1, + anon_sym_COMMA, + [6239] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(857), 1, + sym_identifier, + [6246] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(859), 1, + anon_sym_RPAREN, + [6253] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(861), 1, + anon_sym_EQ, + [6260] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(863), 1, + anon_sym_EQ, + [6267] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(865), 1, + anon_sym_EQ, + [6274] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(867), 1, + anon_sym_RPAREN, + [6281] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(869), 1, + sym_identifier, + [6288] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(871), 1, + anon_sym_LT, + [6295] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(873), 1, + anon_sym_EQ, +}; + +static const uint32_t ts_small_parse_table_map[] = { + [SMALL_STATE(2)] = 0, + [SMALL_STATE(3)] = 94, + [SMALL_STATE(4)] = 188, + [SMALL_STATE(5)] = 282, + [SMALL_STATE(6)] = 376, + [SMALL_STATE(7)] = 470, + [SMALL_STATE(8)] = 511, + [SMALL_STATE(9)] = 552, + [SMALL_STATE(10)] = 593, + [SMALL_STATE(11)] = 634, + [SMALL_STATE(12)] = 675, + [SMALL_STATE(13)] = 716, + [SMALL_STATE(14)] = 757, + [SMALL_STATE(15)] = 798, + [SMALL_STATE(16)] = 839, + [SMALL_STATE(17)] = 880, + [SMALL_STATE(18)] = 921, + [SMALL_STATE(19)] = 962, + [SMALL_STATE(20)] = 1003, + [SMALL_STATE(21)] = 1044, + [SMALL_STATE(22)] = 1085, + [SMALL_STATE(23)] = 1126, + [SMALL_STATE(24)] = 1167, + [SMALL_STATE(25)] = 1208, + [SMALL_STATE(26)] = 1249, + [SMALL_STATE(27)] = 1290, + [SMALL_STATE(28)] = 1331, + [SMALL_STATE(29)] = 1372, + [SMALL_STATE(30)] = 1413, + [SMALL_STATE(31)] = 1454, + [SMALL_STATE(32)] = 1495, + [SMALL_STATE(33)] = 1546, + [SMALL_STATE(34)] = 1597, + [SMALL_STATE(35)] = 1648, + [SMALL_STATE(36)] = 1690, + [SMALL_STATE(37)] = 1720, + [SMALL_STATE(38)] = 1751, + [SMALL_STATE(39)] = 1790, + [SMALL_STATE(40)] = 1819, + [SMALL_STATE(41)] = 1872, + [SMALL_STATE(42)] = 1925, + [SMALL_STATE(43)] = 1971, + [SMALL_STATE(44)] = 2007, + [SMALL_STATE(45)] = 2053, + [SMALL_STATE(46)] = 2099, + [SMALL_STATE(47)] = 2149, + [SMALL_STATE(48)] = 2199, + [SMALL_STATE(49)] = 2249, + [SMALL_STATE(50)] = 2285, + [SMALL_STATE(51)] = 2335, + [SMALL_STATE(52)] = 2385, + [SMALL_STATE(53)] = 2413, + [SMALL_STATE(54)] = 2459, + [SMALL_STATE(55)] = 2509, + [SMALL_STATE(56)] = 2537, + [SMALL_STATE(57)] = 2565, + [SMALL_STATE(58)] = 2612, + [SMALL_STATE(59)] = 2659, + [SMALL_STATE(60)] = 2706, + [SMALL_STATE(61)] = 2753, + [SMALL_STATE(62)] = 2800, + [SMALL_STATE(63)] = 2847, + [SMALL_STATE(64)] = 2894, + [SMALL_STATE(65)] = 2913, + [SMALL_STATE(66)] = 2934, + [SMALL_STATE(67)] = 2953, + [SMALL_STATE(68)] = 2970, + [SMALL_STATE(69)] = 2987, + [SMALL_STATE(70)] = 3004, + [SMALL_STATE(71)] = 3021, + [SMALL_STATE(72)] = 3038, + [SMALL_STATE(73)] = 3055, + [SMALL_STATE(74)] = 3072, + [SMALL_STATE(75)] = 3089, + [SMALL_STATE(76)] = 3106, + [SMALL_STATE(77)] = 3123, + [SMALL_STATE(78)] = 3140, + [SMALL_STATE(79)] = 3161, + [SMALL_STATE(80)] = 3178, + [SMALL_STATE(81)] = 3195, + [SMALL_STATE(82)] = 3212, + [SMALL_STATE(83)] = 3229, + [SMALL_STATE(84)] = 3246, + [SMALL_STATE(85)] = 3266, + [SMALL_STATE(86)] = 3292, + [SMALL_STATE(87)] = 3318, + [SMALL_STATE(88)] = 3344, + [SMALL_STATE(89)] = 3370, + [SMALL_STATE(90)] = 3390, + [SMALL_STATE(91)] = 3406, + [SMALL_STATE(92)] = 3432, + [SMALL_STATE(93)] = 3453, + [SMALL_STATE(94)] = 3474, + [SMALL_STATE(95)] = 3488, + [SMALL_STATE(96)] = 3512, + [SMALL_STATE(97)] = 3534, + [SMALL_STATE(98)] = 3548, + [SMALL_STATE(99)] = 3572, + [SMALL_STATE(100)] = 3594, + [SMALL_STATE(101)] = 3616, + [SMALL_STATE(102)] = 3637, + [SMALL_STATE(103)] = 3655, + [SMALL_STATE(104)] = 3673, + [SMALL_STATE(105)] = 3691, + [SMALL_STATE(106)] = 3709, + [SMALL_STATE(107)] = 3727, + [SMALL_STATE(108)] = 3745, + [SMALL_STATE(109)] = 3763, + [SMALL_STATE(110)] = 3775, + [SMALL_STATE(111)] = 3787, + [SMALL_STATE(112)] = 3799, + [SMALL_STATE(113)] = 3811, + [SMALL_STATE(114)] = 3831, + [SMALL_STATE(115)] = 3843, + [SMALL_STATE(116)] = 3855, + [SMALL_STATE(117)] = 3872, + [SMALL_STATE(118)] = 3891, + [SMALL_STATE(119)] = 3904, + [SMALL_STATE(120)] = 3921, + [SMALL_STATE(121)] = 3940, + [SMALL_STATE(122)] = 3953, + [SMALL_STATE(123)] = 3972, + [SMALL_STATE(124)] = 3985, + [SMALL_STATE(125)] = 3998, + [SMALL_STATE(126)] = 4017, + [SMALL_STATE(127)] = 4034, + [SMALL_STATE(128)] = 4047, + [SMALL_STATE(129)] = 4058, + [SMALL_STATE(130)] = 4075, + [SMALL_STATE(131)] = 4092, + [SMALL_STATE(132)] = 4105, + [SMALL_STATE(133)] = 4122, + [SMALL_STATE(134)] = 4135, + [SMALL_STATE(135)] = 4148, + [SMALL_STATE(136)] = 4165, + [SMALL_STATE(137)] = 4178, + [SMALL_STATE(138)] = 4197, + [SMALL_STATE(139)] = 4210, + [SMALL_STATE(140)] = 4223, + [SMALL_STATE(141)] = 4240, + [SMALL_STATE(142)] = 4253, + [SMALL_STATE(143)] = 4266, + [SMALL_STATE(144)] = 4283, + [SMALL_STATE(145)] = 4300, + [SMALL_STATE(146)] = 4317, + [SMALL_STATE(147)] = 4330, + [SMALL_STATE(148)] = 4349, + [SMALL_STATE(149)] = 4362, + [SMALL_STATE(150)] = 4379, + [SMALL_STATE(151)] = 4398, + [SMALL_STATE(152)] = 4417, + [SMALL_STATE(153)] = 4431, + [SMALL_STATE(154)] = 4447, + [SMALL_STATE(155)] = 4463, + [SMALL_STATE(156)] = 4479, + [SMALL_STATE(157)] = 4495, + [SMALL_STATE(158)] = 4507, + [SMALL_STATE(159)] = 4523, + [SMALL_STATE(160)] = 4537, + [SMALL_STATE(161)] = 4551, + [SMALL_STATE(162)] = 4567, + [SMALL_STATE(163)] = 4583, + [SMALL_STATE(164)] = 4599, + [SMALL_STATE(165)] = 4611, + [SMALL_STATE(166)] = 4627, + [SMALL_STATE(167)] = 4643, + [SMALL_STATE(168)] = 4653, + [SMALL_STATE(169)] = 4663, + [SMALL_STATE(170)] = 4673, + [SMALL_STATE(171)] = 4683, + [SMALL_STATE(172)] = 4693, + [SMALL_STATE(173)] = 4709, + [SMALL_STATE(174)] = 4723, + [SMALL_STATE(175)] = 4739, + [SMALL_STATE(176)] = 4753, + [SMALL_STATE(177)] = 4765, + [SMALL_STATE(178)] = 4779, + [SMALL_STATE(179)] = 4793, + [SMALL_STATE(180)] = 4806, + [SMALL_STATE(181)] = 4819, + [SMALL_STATE(182)] = 4832, + [SMALL_STATE(183)] = 4845, + [SMALL_STATE(184)] = 4858, + [SMALL_STATE(185)] = 4871, + [SMALL_STATE(186)] = 4880, + [SMALL_STATE(187)] = 4893, + [SMALL_STATE(188)] = 4906, + [SMALL_STATE(189)] = 4919, + [SMALL_STATE(190)] = 4930, + [SMALL_STATE(191)] = 4943, + [SMALL_STATE(192)] = 4956, + [SMALL_STATE(193)] = 4969, + [SMALL_STATE(194)] = 4978, + [SMALL_STATE(195)] = 4991, + [SMALL_STATE(196)] = 5004, + [SMALL_STATE(197)] = 5013, + [SMALL_STATE(198)] = 5026, + [SMALL_STATE(199)] = 5035, + [SMALL_STATE(200)] = 5048, + [SMALL_STATE(201)] = 5061, + [SMALL_STATE(202)] = 5074, + [SMALL_STATE(203)] = 5087, + [SMALL_STATE(204)] = 5100, + [SMALL_STATE(205)] = 5109, + [SMALL_STATE(206)] = 5122, + [SMALL_STATE(207)] = 5131, + [SMALL_STATE(208)] = 5144, + [SMALL_STATE(209)] = 5157, + [SMALL_STATE(210)] = 5170, + [SMALL_STATE(211)] = 5183, + [SMALL_STATE(212)] = 5192, + [SMALL_STATE(213)] = 5205, + [SMALL_STATE(214)] = 5218, + [SMALL_STATE(215)] = 5231, + [SMALL_STATE(216)] = 5244, + [SMALL_STATE(217)] = 5257, + [SMALL_STATE(218)] = 5270, + [SMALL_STATE(219)] = 5280, + [SMALL_STATE(220)] = 5290, + [SMALL_STATE(221)] = 5298, + [SMALL_STATE(222)] = 5308, + [SMALL_STATE(223)] = 5316, + [SMALL_STATE(224)] = 5324, + [SMALL_STATE(225)] = 5334, + [SMALL_STATE(226)] = 5344, + [SMALL_STATE(227)] = 5354, + [SMALL_STATE(228)] = 5364, + [SMALL_STATE(229)] = 5374, + [SMALL_STATE(230)] = 5382, + [SMALL_STATE(231)] = 5390, + [SMALL_STATE(232)] = 5400, + [SMALL_STATE(233)] = 5408, + [SMALL_STATE(234)] = 5416, + [SMALL_STATE(235)] = 5426, + [SMALL_STATE(236)] = 5436, + [SMALL_STATE(237)] = 5446, + [SMALL_STATE(238)] = 5456, + [SMALL_STATE(239)] = 5466, + [SMALL_STATE(240)] = 5476, + [SMALL_STATE(241)] = 5486, + [SMALL_STATE(242)] = 5494, + [SMALL_STATE(243)] = 5504, + [SMALL_STATE(244)] = 5514, + [SMALL_STATE(245)] = 5524, + [SMALL_STATE(246)] = 5532, + [SMALL_STATE(247)] = 5542, + [SMALL_STATE(248)] = 5552, + [SMALL_STATE(249)] = 5562, + [SMALL_STATE(250)] = 5572, + [SMALL_STATE(251)] = 5582, + [SMALL_STATE(252)] = 5590, + [SMALL_STATE(253)] = 5598, + [SMALL_STATE(254)] = 5608, + [SMALL_STATE(255)] = 5618, + [SMALL_STATE(256)] = 5628, + [SMALL_STATE(257)] = 5638, + [SMALL_STATE(258)] = 5648, + [SMALL_STATE(259)] = 5658, + [SMALL_STATE(260)] = 5668, + [SMALL_STATE(261)] = 5678, + [SMALL_STATE(262)] = 5688, + [SMALL_STATE(263)] = 5698, + [SMALL_STATE(264)] = 5708, + [SMALL_STATE(265)] = 5718, + [SMALL_STATE(266)] = 5728, + [SMALL_STATE(267)] = 5738, + [SMALL_STATE(268)] = 5748, + [SMALL_STATE(269)] = 5758, + [SMALL_STATE(270)] = 5768, + [SMALL_STATE(271)] = 5778, + [SMALL_STATE(272)] = 5788, + [SMALL_STATE(273)] = 5798, + [SMALL_STATE(274)] = 5805, + [SMALL_STATE(275)] = 5812, + [SMALL_STATE(276)] = 5819, + [SMALL_STATE(277)] = 5826, + [SMALL_STATE(278)] = 5833, + [SMALL_STATE(279)] = 5840, + [SMALL_STATE(280)] = 5847, + [SMALL_STATE(281)] = 5854, + [SMALL_STATE(282)] = 5861, + [SMALL_STATE(283)] = 5868, + [SMALL_STATE(284)] = 5875, + [SMALL_STATE(285)] = 5882, + [SMALL_STATE(286)] = 5889, + [SMALL_STATE(287)] = 5896, + [SMALL_STATE(288)] = 5903, + [SMALL_STATE(289)] = 5910, + [SMALL_STATE(290)] = 5917, + [SMALL_STATE(291)] = 5924, + [SMALL_STATE(292)] = 5931, + [SMALL_STATE(293)] = 5938, + [SMALL_STATE(294)] = 5945, + [SMALL_STATE(295)] = 5952, + [SMALL_STATE(296)] = 5959, + [SMALL_STATE(297)] = 5966, + [SMALL_STATE(298)] = 5973, + [SMALL_STATE(299)] = 5980, + [SMALL_STATE(300)] = 5987, + [SMALL_STATE(301)] = 5994, + [SMALL_STATE(302)] = 6001, + [SMALL_STATE(303)] = 6008, + [SMALL_STATE(304)] = 6015, + [SMALL_STATE(305)] = 6022, + [SMALL_STATE(306)] = 6029, + [SMALL_STATE(307)] = 6036, + [SMALL_STATE(308)] = 6043, + [SMALL_STATE(309)] = 6050, + [SMALL_STATE(310)] = 6057, + [SMALL_STATE(311)] = 6064, + [SMALL_STATE(312)] = 6071, + [SMALL_STATE(313)] = 6078, + [SMALL_STATE(314)] = 6085, + [SMALL_STATE(315)] = 6092, + [SMALL_STATE(316)] = 6099, + [SMALL_STATE(317)] = 6106, + [SMALL_STATE(318)] = 6113, + [SMALL_STATE(319)] = 6120, + [SMALL_STATE(320)] = 6127, + [SMALL_STATE(321)] = 6134, + [SMALL_STATE(322)] = 6141, + [SMALL_STATE(323)] = 6148, + [SMALL_STATE(324)] = 6155, + [SMALL_STATE(325)] = 6162, + [SMALL_STATE(326)] = 6169, + [SMALL_STATE(327)] = 6176, + [SMALL_STATE(328)] = 6183, + [SMALL_STATE(329)] = 6190, + [SMALL_STATE(330)] = 6197, + [SMALL_STATE(331)] = 6204, + [SMALL_STATE(332)] = 6211, + [SMALL_STATE(333)] = 6218, + [SMALL_STATE(334)] = 6225, + [SMALL_STATE(335)] = 6232, + [SMALL_STATE(336)] = 6239, + [SMALL_STATE(337)] = 6246, + [SMALL_STATE(338)] = 6253, + [SMALL_STATE(339)] = 6260, + [SMALL_STATE(340)] = 6267, + [SMALL_STATE(341)] = 6274, + [SMALL_STATE(342)] = 6281, + [SMALL_STATE(343)] = 6288, + [SMALL_STATE(344)] = 6295, +}; + +static const TSParseActionEntry ts_parse_actions[] = { + [0] = {.entry = {.count = 0, .reusable = false}}, + [1] = {.entry = {.count = 1, .reusable = false}}, RECOVER(), + [3] = {.entry = {.count = 1, .reusable = true}}, SHIFT_EXTRA(), + [5] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_source_file, 0, 0, 0), + [7] = {.entry = {.count = 1, .reusable = true}}, SHIFT(66), + [9] = {.entry = {.count = 1, .reusable = true}}, SHIFT(328), + [11] = {.entry = {.count = 1, .reusable = true}}, SHIFT(316), + [13] = {.entry = {.count = 1, .reusable = true}}, SHIFT(101), + [15] = {.entry = {.count = 1, .reusable = true}}, SHIFT(205), + [17] = {.entry = {.count = 1, .reusable = true}}, SHIFT(249), + [19] = {.entry = {.count = 1, .reusable = true}}, SHIFT(254), + [21] = {.entry = {.count = 1, .reusable = true}}, SHIFT(221), + [23] = {.entry = {.count = 1, .reusable = true}}, SHIFT(228), + [25] = {.entry = {.count = 1, .reusable = true}}, SHIFT(255), + [27] = {.entry = {.count = 1, .reusable = true}}, SHIFT(224), + [29] = {.entry = {.count = 1, .reusable = true}}, SHIFT(30), + [31] = {.entry = {.count = 1, .reusable = false}}, SHIFT(216), + [33] = {.entry = {.count = 1, .reusable = true}}, SHIFT(225), + [35] = {.entry = {.count = 1, .reusable = false}}, SHIFT(270), + [37] = {.entry = {.count = 1, .reusable = false}}, SHIFT(263), + [39] = {.entry = {.count = 1, .reusable = true}}, SHIFT(82), + [41] = {.entry = {.count = 1, .reusable = false}}, SHIFT(264), + [43] = {.entry = {.count = 1, .reusable = false}}, SHIFT(265), + [45] = {.entry = {.count = 1, .reusable = false}}, SHIFT(35), + [47] = {.entry = {.count = 1, .reusable = false}}, SHIFT(38), + [49] = {.entry = {.count = 1, .reusable = false}}, SHIFT(236), + [51] = {.entry = {.count = 1, .reusable = false}}, SHIFT(336), + [53] = {.entry = {.count = 1, .reusable = false}}, SHIFT(343), + [55] = {.entry = {.count = 1, .reusable = false}}, SHIFT(241), + [57] = {.entry = {.count = 1, .reusable = false}}, SHIFT(95), + [59] = {.entry = {.count = 1, .reusable = false}}, SHIFT(113), + [61] = {.entry = {.count = 1, .reusable = false}}, SHIFT(157), + [63] = {.entry = {.count = 1, .reusable = true}}, SHIFT(69), + [65] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_message_body_repeat1, 2, 0, 0), SHIFT_REPEAT(30), + [68] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_message_body_repeat1, 2, 0, 0), SHIFT_REPEAT(216), + [71] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_message_body_repeat1, 2, 0, 0), SHIFT_REPEAT(225), + [74] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_message_body_repeat1, 2, 0, 0), SHIFT_REPEAT(270), + [77] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_message_body_repeat1, 2, 0, 0), SHIFT_REPEAT(263), + [80] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_message_body_repeat1, 2, 0, 0), + [82] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_message_body_repeat1, 2, 0, 0), SHIFT_REPEAT(264), + [85] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_message_body_repeat1, 2, 0, 0), SHIFT_REPEAT(265), + [88] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_message_body_repeat1, 2, 0, 0), SHIFT_REPEAT(35), + [91] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_message_body_repeat1, 2, 0, 0), SHIFT_REPEAT(38), + [94] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_message_body_repeat1, 2, 0, 0), SHIFT_REPEAT(236), + [97] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_message_body_repeat1, 2, 0, 0), SHIFT_REPEAT(336), + [100] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_message_body_repeat1, 2, 0, 0), SHIFT_REPEAT(343), + [103] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_message_body_repeat1, 2, 0, 0), SHIFT_REPEAT(241), + [106] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_message_body_repeat1, 2, 0, 0), SHIFT_REPEAT(95), + [109] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_message_body_repeat1, 2, 0, 0), SHIFT_REPEAT(113), + [112] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_message_body_repeat1, 2, 0, 0), SHIFT_REPEAT(157), + [115] = {.entry = {.count = 1, .reusable = true}}, SHIFT(28), + [117] = {.entry = {.count = 1, .reusable = true}}, SHIFT(27), + [119] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_enum_body, 3, 0, 0), + [121] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_enum_body, 3, 0, 0), + [123] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_option, 5, 0, 0), + [125] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_option, 5, 0, 0), + [127] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_extensions, 3, 0, 0), + [129] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_extensions, 3, 0, 0), + [131] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_oneof, 4, 0, 0), + [133] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_oneof, 4, 0, 0), + [135] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_group, 5, 0, 0), + [137] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_group, 5, 0, 0), + [139] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_oneof, 5, 0, 0), + [141] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_oneof, 5, 0, 0), + [143] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_field, 5, 0, 0), + [145] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_field, 5, 0, 0), + [147] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_group, 6, 0, 0), + [149] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_group, 6, 0, 0), + [151] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_field, 6, 0, 0), + [153] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_field, 6, 0, 0), + [155] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_field, 7, 0, 0), + [157] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_field, 7, 0, 0), + [159] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_field, 8, 0, 0), + [161] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_field, 8, 0, 0), + [163] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_field, 10, 0, 0), + [165] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_field, 10, 0, 0), + [167] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_map_field, 10, 0, 0), + [169] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_map_field, 10, 0, 0), + [171] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_map_field, 13, 0, 0), + [173] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_map_field, 13, 0, 0), + [175] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_enum, 3, 0, 0), + [177] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_enum, 3, 0, 0), + [179] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_message, 3, 0, 0), + [181] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_message, 3, 0, 0), + [183] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_extend, 3, 0, 0), + [185] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_extend, 3, 0, 0), + [187] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_enum, 4, 0, 0), + [189] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_enum, 4, 0, 0), + [191] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_message, 4, 0, 0), + [193] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_message, 4, 0, 0), + [195] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_enum_body, 2, 0, 0), + [197] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_enum_body, 2, 0, 0), + [199] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_message_body, 2, 0, 0), + [201] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_message_body, 2, 0, 0), + [203] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_message_body, 3, 0, 0), + [205] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_message_body, 3, 0, 0), + [207] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_reserved, 3, 0, 0), + [209] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_reserved, 3, 0, 0), + [211] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_empty_statement, 1, 0, 0), + [213] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_empty_statement, 1, 0, 0), + [215] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_field, 9, 0, 0), + [217] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_field, 9, 0, 0), + [219] = {.entry = {.count = 1, .reusable = true}}, SHIFT(55), + [221] = {.entry = {.count = 1, .reusable = false}}, SHIFT(188), + [223] = {.entry = {.count = 1, .reusable = true}}, SHIFT(10), + [225] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_oneof_repeat1, 2, 0, 0), SHIFT_REPEAT(55), + [228] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_oneof_repeat1, 2, 0, 0), SHIFT_REPEAT(188), + [231] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_oneof_repeat1, 2, 0, 0), SHIFT_REPEAT(225), + [234] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_oneof_repeat1, 2, 0, 0), + [236] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_oneof_repeat1, 2, 0, 0), SHIFT_REPEAT(241), + [239] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_oneof_repeat1, 2, 0, 0), SHIFT_REPEAT(157), + [242] = {.entry = {.count = 1, .reusable = true}}, SHIFT(12), + [244] = {.entry = {.count = 1, .reusable = false}}, SHIFT(43), + [246] = {.entry = {.count = 1, .reusable = false}}, SHIFT(239), + [248] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_field_number, 1, 0, 0), + [250] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_field_number, 1, 0, 0), + [252] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_oneof_field, 4, 0, 0), + [254] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_oneof_field, 4, 0, 0), + [256] = {.entry = {.count = 1, .reusable = true}}, SHIFT(125), + [258] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_int_lit, 1, 0, 0), + [260] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_int_lit, 1, 0, 0), + [262] = {.entry = {.count = 1, .reusable = true}}, SHIFT(153), + [264] = {.entry = {.count = 1, .reusable = true}}, SHIFT(144), + [266] = {.entry = {.count = 1, .reusable = true}}, SHIFT(50), + [268] = {.entry = {.count = 1, .reusable = true}}, SHIFT(51), + [270] = {.entry = {.count = 1, .reusable = false}}, SHIFT(89), + [272] = {.entry = {.count = 1, .reusable = false}}, SHIFT(110), + [274] = {.entry = {.count = 1, .reusable = false}}, SHIFT(128), + [276] = {.entry = {.count = 1, .reusable = true}}, SHIFT(128), + [278] = {.entry = {.count = 1, .reusable = false}}, SHIFT(112), + [280] = {.entry = {.count = 1, .reusable = true}}, SHIFT(173), + [282] = {.entry = {.count = 1, .reusable = true}}, SHIFT(152), + [284] = {.entry = {.count = 1, .reusable = true}}, SHIFT(46), + [286] = {.entry = {.count = 1, .reusable = true}}, SHIFT(48), + [288] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_source_file, 2, 0, 0), + [290] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_source_file, 1, 0, 0), + [292] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), + [294] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), SHIFT_REPEAT(66), + [297] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), SHIFT_REPEAT(101), + [300] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), SHIFT_REPEAT(205), + [303] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), SHIFT_REPEAT(249), + [306] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), SHIFT_REPEAT(254), + [309] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), SHIFT_REPEAT(221), + [312] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), SHIFT_REPEAT(228), + [315] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), SHIFT_REPEAT(255), + [318] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), SHIFT_REPEAT(224), + [321] = {.entry = {.count = 1, .reusable = true}}, SHIFT(116), + [323] = {.entry = {.count = 1, .reusable = true}}, SHIFT(141), + [325] = {.entry = {.count = 1, .reusable = false}}, SHIFT(109), + [327] = {.entry = {.count = 1, .reusable = true}}, SHIFT(109), + [329] = {.entry = {.count = 1, .reusable = true}}, SHIFT(127), + [331] = {.entry = {.count = 1, .reusable = true}}, SHIFT(47), + [333] = {.entry = {.count = 1, .reusable = true}}, SHIFT(139), + [335] = {.entry = {.count = 1, .reusable = true}}, SHIFT(54), + [337] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_oneof_field, 7, 0, 0), + [339] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_oneof_field, 7, 0, 0), + [341] = {.entry = {.count = 1, .reusable = true}}, SHIFT(118), + [343] = {.entry = {.count = 1, .reusable = true}}, SHIFT(334), + [345] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_edition, 4, 0, 2), + [347] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_import, 4, 0, 3), + [349] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_package, 3, 0, 0), + [351] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_service, 5, 0, 0), + [353] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_syntax, 4, 0, 0), + [355] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_import, 3, 0, 1), + [357] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym__option_name_repeat1, 2, 0, 0), + [359] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym__option_name_repeat1, 2, 0, 0), SHIFT_REPEAT(292), + [362] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_service, 4, 0, 0), + [364] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_full_ident, 2, 0, 0), + [366] = {.entry = {.count = 1, .reusable = true}}, SHIFT(292), + [368] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_enum_body_repeat1, 2, 0, 0), SHIFT_REPEAT(124), + [371] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_enum_body_repeat1, 2, 0, 0), SHIFT_REPEAT(215), + [374] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_enum_body_repeat1, 2, 0, 0), + [376] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_enum_body_repeat1, 2, 0, 0), SHIFT_REPEAT(98), + [379] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_enum_body_repeat1, 2, 0, 0), SHIFT_REPEAT(283), + [382] = {.entry = {.count = 1, .reusable = true}}, SHIFT(124), + [384] = {.entry = {.count = 1, .reusable = false}}, SHIFT(215), + [386] = {.entry = {.count = 1, .reusable = true}}, SHIFT(81), + [388] = {.entry = {.count = 1, .reusable = false}}, SHIFT(98), + [390] = {.entry = {.count = 1, .reusable = false}}, SHIFT(283), + [392] = {.entry = {.count = 1, .reusable = true}}, SHIFT(72), + [394] = {.entry = {.count = 1, .reusable = true}}, SHIFT(26), + [396] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_full_ident, 1, 0, 0), + [398] = {.entry = {.count = 1, .reusable = true}}, SHIFT(7), + [400] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_string_repeat3, 2, 0, 0), + [402] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_string_repeat3, 2, 0, 0), SHIFT_REPEAT(173), + [405] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_string_repeat3, 2, 0, 0), SHIFT_REPEAT(152), + [408] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_string, 1, 0, 0), + [410] = {.entry = {.count = 1, .reusable = true}}, SHIFT(186), + [412] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_service_repeat1, 2, 0, 0), SHIFT_REPEAT(66), + [415] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_service_repeat1, 2, 0, 0), SHIFT_REPEAT(205), + [418] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_service_repeat1, 2, 0, 0), + [420] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_service_repeat1, 2, 0, 0), SHIFT_REPEAT(242), + [423] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_string_repeat3, 3, 0, 0), + [425] = {.entry = {.count = 1, .reusable = true}}, SHIFT(73), + [427] = {.entry = {.count = 1, .reusable = true}}, SHIFT(242), + [429] = {.entry = {.count = 1, .reusable = true}}, SHIFT(83), + [431] = {.entry = {.count = 1, .reusable = true}}, SHIFT(172), + [433] = {.entry = {.count = 1, .reusable = true}}, SHIFT(168), + [435] = {.entry = {.count = 1, .reusable = true}}, SHIFT(169), + [437] = {.entry = {.count = 1, .reusable = true}}, SHIFT(170), + [439] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_rpc_repeat1, 2, 0, 0), SHIFT_REPEAT(66), + [442] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_rpc_repeat1, 2, 0, 0), SHIFT_REPEAT(205), + [445] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_rpc_repeat1, 2, 0, 0), + [447] = {.entry = {.count = 1, .reusable = true}}, SHIFT(171), + [449] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_bool, 1, 0, 0), + [451] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_constant, 2, 0, 0), + [453] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_constant, 1, 0, 0), + [455] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_block_lit, 2, 0, 0), + [457] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_block_lit, 3, 0, 0), + [459] = {.entry = {.count = 1, .reusable = true}}, SHIFT(111), + [461] = {.entry = {.count = 1, .reusable = true}}, SHIFT(250), + [463] = {.entry = {.count = 1, .reusable = true}}, SHIFT(208), + [465] = {.entry = {.count = 1, .reusable = true}}, SHIFT(185), + [467] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_block_lit_repeat2, 6, 0, 0), + [469] = {.entry = {.count = 1, .reusable = false}}, SHIFT(163), + [471] = {.entry = {.count = 1, .reusable = true}}, SHIFT(193), + [473] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_block_lit_repeat2, 7, 0, 0), + [475] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_enum_field, 8, 0, 0), + [477] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_enum_field, 8, 0, 0), + [479] = {.entry = {.count = 1, .reusable = true}}, SHIFT(198), + [481] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_block_lit_repeat2, 4, 0, 0), + [483] = {.entry = {.count = 1, .reusable = true}}, SHIFT(175), + [485] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_enum_field, 4, 0, 0), + [487] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_enum_field, 4, 0, 0), + [489] = {.entry = {.count = 1, .reusable = true}}, SHIFT(39), + [491] = {.entry = {.count = 1, .reusable = false}}, SHIFT(39), + [493] = {.entry = {.count = 1, .reusable = true}}, SHIFT(196), + [495] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_block_lit_repeat2, 8, 0, 0), + [497] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_enum_field, 9, 0, 0), + [499] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_enum_field, 9, 0, 0), + [501] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_enum_field, 7, 0, 0), + [503] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_enum_field, 7, 0, 0), + [505] = {.entry = {.count = 1, .reusable = true}}, SHIFT(211), + [507] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_block_lit_repeat2, 5, 0, 0), + [509] = {.entry = {.count = 1, .reusable = true}}, SHIFT(206), + [511] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_block_lit_repeat2, 3, 0, 0), + [513] = {.entry = {.count = 1, .reusable = true}}, SHIFT(204), + [515] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_block_lit_repeat2, 2, 0, 0), + [517] = {.entry = {.count = 1, .reusable = true}}, SHIFT(230), + [519] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_enum_field, 5, 0, 0), + [521] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_enum_field, 5, 0, 0), + [523] = {.entry = {.count = 1, .reusable = false}}, SHIFT(155), + [525] = {.entry = {.count = 1, .reusable = false}}, SHIFT(165), + [527] = {.entry = {.count = 1, .reusable = false}}, SHIFT(94), + [529] = {.entry = {.count = 1, .reusable = true}}, SHIFT(160), + [531] = {.entry = {.count = 1, .reusable = false}}, SHIFT_EXTRA(), + [533] = {.entry = {.count = 1, .reusable = true}}, SHIFT(114), + [535] = {.entry = {.count = 1, .reusable = true}}, SHIFT(226), + [537] = {.entry = {.count = 1, .reusable = true}}, SHIFT(41), + [539] = {.entry = {.count = 1, .reusable = true}}, SHIFT(157), + [541] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_message_or_enum_type, 1, 0, 0), + [543] = {.entry = {.count = 1, .reusable = true}}, SHIFT(289), + [545] = {.entry = {.count = 1, .reusable = false}}, SHIFT(97), + [547] = {.entry = {.count = 1, .reusable = true}}, SHIFT(177), + [549] = {.entry = {.count = 1, .reusable = true}}, SHIFT(178), + [551] = {.entry = {.count = 1, .reusable = true}}, SHIFT(115), + [553] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_message_or_enum_type, 2, 0, 0), + [555] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_rpc, 10, 0, 0), + [557] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_rpc, 11, 0, 0), + [559] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_rpc, 12, 0, 0), + [561] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_rpc, 13, 0, 0), + [563] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_rpc, 14, 0, 0), + [565] = {.entry = {.count = 1, .reusable = true}}, SHIFT(159), + [567] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_block_lit_repeat2, 2, 0, 0), SHIFT_REPEAT(226), + [570] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_block_lit_repeat2, 2, 0, 0), SHIFT_REPEAT(41), + [573] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_message_or_enum_type, 3, 0, 0), + [575] = {.entry = {.count = 1, .reusable = false}}, REDUCE(aux_sym_string_repeat1, 2, 0, 0), + [577] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_string_repeat1, 2, 0, 0), SHIFT_REPEAT(177), + [580] = {.entry = {.count = 1, .reusable = false}}, REDUCE(aux_sym_string_repeat2, 2, 0, 0), + [582] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_string_repeat2, 2, 0, 0), SHIFT_REPEAT(178), + [585] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_ranges, 1, 0, 0), + [587] = {.entry = {.count = 1, .reusable = true}}, SHIFT(119), + [589] = {.entry = {.count = 1, .reusable = true}}, SHIFT(158), + [591] = {.entry = {.count = 1, .reusable = true}}, SHIFT(304), + [593] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_enum_field_repeat1, 2, 0, 0), SHIFT_REPEAT(158), + [596] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_enum_field_repeat1, 2, 0, 0), + [598] = {.entry = {.count = 1, .reusable = true}}, SHIFT(61), + [600] = {.entry = {.count = 1, .reusable = true}}, SHIFT(162), + [602] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_field_options, 2, 0, 0), + [604] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_reserved_field_names, 1, 0, 0), + [606] = {.entry = {.count = 1, .reusable = true}}, SHIFT(306), + [608] = {.entry = {.count = 1, .reusable = true}}, SHIFT(134), + [610] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_range, 1, 0, 0), + [612] = {.entry = {.count = 1, .reusable = true}}, SHIFT(143), + [614] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym__option_name, 2, 0, 0), + [616] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_field_options_repeat1, 2, 0, 0), SHIFT_REPEAT(162), + [619] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_field_options_repeat1, 2, 0, 0), + [621] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_reserved_field_names_repeat1, 2, 0, 0), + [623] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_reserved_field_names_repeat1, 2, 0, 0), SHIFT_REPEAT(306), + [626] = {.entry = {.count = 1, .reusable = true}}, SHIFT(324), + [628] = {.entry = {.count = 1, .reusable = true}}, SHIFT(121), + [630] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_block_lit_repeat2, 9, 0, 0), + [632] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_block_lit_repeat1, 2, 0, 0), SHIFT_REPEAT(61), + [635] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_block_lit_repeat1, 2, 0, 0), + [637] = {.entry = {.count = 1, .reusable = true}}, SHIFT(285), + [639] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_reserved_field_names, 2, 0, 0), + [641] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym__option_name, 1, 0, 0), + [643] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_ranges, 2, 0, 0), + [645] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_field_options, 1, 0, 0), + [647] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym__option_name, 4, 0, 0), + [649] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym__option_name, 3, 0, 0), + [651] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_ranges_repeat1, 2, 0, 0), + [653] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_ranges_repeat1, 2, 0, 0), SHIFT_REPEAT(119), + [656] = {.entry = {.count = 1, .reusable = true}}, SHIFT(19), + [658] = {.entry = {.count = 1, .reusable = true}}, SHIFT(147), + [660] = {.entry = {.count = 1, .reusable = true}}, SHIFT(13), + [662] = {.entry = {.count = 1, .reusable = true}}, SHIFT(137), + [664] = {.entry = {.count = 1, .reusable = true}}, SHIFT(294), + [666] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_enum_value_option, 3, 0, 0), + [668] = {.entry = {.count = 1, .reusable = true}}, SHIFT(309), + [670] = {.entry = {.count = 1, .reusable = true}}, SHIFT(164), + [672] = {.entry = {.count = 1, .reusable = true}}, SHIFT(89), + [674] = {.entry = {.count = 1, .reusable = true}}, SHIFT(252), + [676] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_range, 3, 0, 0), + [678] = {.entry = {.count = 1, .reusable = true}}, SHIFT(146), + [680] = {.entry = {.count = 1, .reusable = true}}, SHIFT(156), + [682] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_field_option, 3, 0, 0), + [684] = {.entry = {.count = 1, .reusable = true}}, SHIFT(6), + [686] = {.entry = {.count = 1, .reusable = true}}, SHIFT(15), + [688] = {.entry = {.count = 1, .reusable = true}}, SHIFT(122), + [690] = {.entry = {.count = 1, .reusable = true}}, SHIFT(176), + [692] = {.entry = {.count = 1, .reusable = true}}, SHIFT(167), + [694] = {.entry = {.count = 1, .reusable = true}}, SHIFT(102), + [696] = {.entry = {.count = 1, .reusable = true}}, SHIFT(103), + [698] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_type, 1, 0, 0), + [700] = {.entry = {.count = 1, .reusable = true}}, SHIFT(296), + [702] = {.entry = {.count = 1, .reusable = true}}, SHIFT(86), + [704] = {.entry = {.count = 1, .reusable = true}}, SHIFT(2), + [706] = {.entry = {.count = 1, .reusable = true}}, SHIFT(313), + [708] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_message_or_enum_type_repeat1, 2, 0, 0), SHIFT_REPEAT(282), + [711] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_message_name, 1, 0, 0), + [713] = {.entry = {.count = 1, .reusable = true}}, SHIFT(227), + [715] = {.entry = {.count = 1, .reusable = true}}, SHIFT(248), + [717] = {.entry = {.count = 1, .reusable = true}}, SHIFT(131), + [719] = {.entry = {.count = 1, .reusable = true}}, SHIFT(154), + [721] = {.entry = {.count = 1, .reusable = true}}, SHIFT(88), + [723] = {.entry = {.count = 1, .reusable = true}}, SHIFT(16), + [725] = {.entry = {.count = 1, .reusable = true}}, SHIFT(117), + [727] = {.entry = {.count = 1, .reusable = true}}, SHIFT(266), + [729] = {.entry = {.count = 1, .reusable = true}}, SHIFT(267), + [731] = {.entry = {.count = 1, .reusable = true}}, SHIFT(105), + [733] = {.entry = {.count = 1, .reusable = true}}, SHIFT(287), + [735] = {.entry = {.count = 1, .reusable = true}}, SHIFT(315), + [737] = {.entry = {.count = 1, .reusable = true}}, SHIFT(126), + [739] = {.entry = {.count = 1, .reusable = true}}, SHIFT(323), + [741] = {.entry = {.count = 1, .reusable = true}}, SHIFT(140), + [743] = {.entry = {.count = 1, .reusable = true}}, SHIFT(272), + [745] = {.entry = {.count = 1, .reusable = true}}, SHIFT(214), + [747] = {.entry = {.count = 1, .reusable = true}}, SHIFT(17), + [749] = {.entry = {.count = 1, .reusable = true}}, SHIFT(32), + [751] = {.entry = {.count = 1, .reusable = true}}, SHIFT(130), + [753] = {.entry = {.count = 1, .reusable = true}}, SHIFT(68), + [755] = {.entry = {.count = 1, .reusable = true}}, SHIFT(123), + [757] = {.entry = {.count = 1, .reusable = true}}, SHIFT(9), + [759] = {.entry = {.count = 1, .reusable = true}}, SHIFT(20), + [761] = {.entry = {.count = 1, .reusable = true}}, SHIFT(312), + [763] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_message_or_enum_type_repeat1, 2, 0, 0), + [765] = {.entry = {.count = 1, .reusable = true}}, SHIFT(149), + [767] = {.entry = {.count = 1, .reusable = true}}, SHIFT(151), + [769] = {.entry = {.count = 1, .reusable = true}}, SHIFT(90), + [771] = {.entry = {.count = 1, .reusable = true}}, SHIFT(314), + [773] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_enum_name, 1, 0, 0), + [775] = {.entry = {.count = 1, .reusable = true}}, SHIFT(67), + [777] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_rpc_name, 1, 0, 0), + [779] = {.entry = {.count = 1, .reusable = true}}, SHIFT(150), + [781] = {.entry = {.count = 1, .reusable = true}}, SHIFT(280), + [783] = {.entry = {.count = 1, .reusable = true}}, SHIFT(290), + [785] = {.entry = {.count = 1, .reusable = true}}, SHIFT(40), + [787] = {.entry = {.count = 1, .reusable = true}}, SHIFT(129), + [789] = {.entry = {.count = 1, .reusable = true}}, SHIFT(77), + [791] = {.entry = {.count = 1, .reusable = true}}, SHIFT(148), + [793] = {.entry = {.count = 1, .reusable = true}}, SHIFT(136), + [795] = {.entry = {.count = 1, .reusable = true}}, SHIFT(330), + [797] = {.entry = {.count = 1, .reusable = true}}, SHIFT(220), + [799] = {.entry = {.count = 1, .reusable = true}}, SHIFT(31), + [801] = {.entry = {.count = 1, .reusable = true}}, SHIFT(64), + [803] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_service_name, 1, 0, 0), + [805] = {.entry = {.count = 1, .reusable = true}}, SHIFT(60), + [807] = {.entry = {.count = 1, .reusable = true}}, SHIFT(100), + [809] = {.entry = {.count = 1, .reusable = true}}, SHIFT(291), + [811] = {.entry = {.count = 1, .reusable = true}}, SHIFT(76), + [813] = {.entry = {.count = 1, .reusable = true}}, SHIFT(120), + [815] = {.entry = {.count = 1, .reusable = true}}, SHIFT(275), + [817] = {.entry = {.count = 1, .reusable = true}}, SHIFT(245), + [819] = {.entry = {.count = 1, .reusable = true}}, SHIFT(59), + [821] = {.entry = {.count = 1, .reusable = true}}, SHIFT(238), + [823] = {.entry = {.count = 1, .reusable = true}}, ACCEPT_INPUT(), + [825] = {.entry = {.count = 1, .reusable = true}}, SHIFT(301), + [827] = {.entry = {.count = 1, .reusable = true}}, SHIFT(135), + [829] = {.entry = {.count = 1, .reusable = true}}, SHIFT(133), + [831] = {.entry = {.count = 1, .reusable = true}}, SHIFT(145), + [833] = {.entry = {.count = 1, .reusable = true}}, SHIFT(138), + [835] = {.entry = {.count = 1, .reusable = true}}, SHIFT(29), + [837] = {.entry = {.count = 1, .reusable = true}}, SHIFT(8), + [839] = {.entry = {.count = 1, .reusable = true}}, SHIFT(56), + [841] = {.entry = {.count = 1, .reusable = true}}, SHIFT(166), + [843] = {.entry = {.count = 1, .reusable = true}}, SHIFT(132), + [845] = {.entry = {.count = 1, .reusable = true}}, SHIFT(18), + [847] = {.entry = {.count = 1, .reusable = true}}, SHIFT(307), + [849] = {.entry = {.count = 1, .reusable = true}}, SHIFT(52), + [851] = {.entry = {.count = 1, .reusable = true}}, SHIFT(71), + [853] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_key_type, 1, 0, 0), + [855] = {.entry = {.count = 1, .reusable = true}}, SHIFT(49), + [857] = {.entry = {.count = 1, .reusable = true}}, SHIFT(281), + [859] = {.entry = {.count = 1, .reusable = true}}, SHIFT(240), + [861] = {.entry = {.count = 1, .reusable = true}}, SHIFT(62), + [863] = {.entry = {.count = 1, .reusable = true}}, SHIFT(63), + [865] = {.entry = {.count = 1, .reusable = true}}, SHIFT(57), + [867] = {.entry = {.count = 1, .reusable = true}}, SHIFT(293), + [869] = {.entry = {.count = 1, .reusable = true}}, SHIFT(329), + [871] = {.entry = {.count = 1, .reusable = true}}, SHIFT(65), + [873] = {.entry = {.count = 1, .reusable = true}}, SHIFT(58), +}; + +#ifdef __cplusplus +extern "C" { +#endif +#ifdef TREE_SITTER_HIDE_SYMBOLS +#define TS_PUBLIC +#elif defined(_WIN32) +#define TS_PUBLIC __declspec(dllexport) +#else +#define TS_PUBLIC __attribute__((visibility("default"))) +#endif + +TS_PUBLIC const TSLanguage *tree_sitter_proto(void) { + static const TSLanguage language = { + .version = LANGUAGE_VERSION, + .symbol_count = SYMBOL_COUNT, + .alias_count = ALIAS_COUNT, + .token_count = TOKEN_COUNT, + .external_token_count = EXTERNAL_TOKEN_COUNT, + .state_count = STATE_COUNT, + .large_state_count = LARGE_STATE_COUNT, + .production_id_count = PRODUCTION_ID_COUNT, + .field_count = FIELD_COUNT, + .max_alias_sequence_length = MAX_ALIAS_SEQUENCE_LENGTH, + .parse_table = &ts_parse_table[0][0], + .small_parse_table = ts_small_parse_table, + .small_parse_table_map = ts_small_parse_table_map, + .parse_actions = ts_parse_actions, + .symbol_names = ts_symbol_names, + .field_names = ts_field_names, + .field_map_slices = ts_field_map_slices, + .field_map_entries = ts_field_map_entries, + .symbol_metadata = ts_symbol_metadata, + .public_symbol_map = ts_symbol_map, + .alias_map = ts_non_terminal_alias_map, + .alias_sequences = &ts_alias_sequences[0][0], + .lex_modes = ts_lex_modes, + .lex_fn = ts_lex, + .primary_state_ids = ts_primary_state_ids, + }; + return &language; +} +#ifdef __cplusplus +} +#endif diff --git a/gitnexus/vendor/tree-sitter-proto/src/tree_sitter/alloc.h b/gitnexus/vendor/tree-sitter-proto/src/tree_sitter/alloc.h new file mode 100644 index 000000000..1abdd1201 --- /dev/null +++ b/gitnexus/vendor/tree-sitter-proto/src/tree_sitter/alloc.h @@ -0,0 +1,54 @@ +#ifndef TREE_SITTER_ALLOC_H_ +#define TREE_SITTER_ALLOC_H_ + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include +#include + +// Allow clients to override allocation functions +#ifdef TREE_SITTER_REUSE_ALLOCATOR + +extern void *(*ts_current_malloc)(size_t size); +extern void *(*ts_current_calloc)(size_t count, size_t size); +extern void *(*ts_current_realloc)(void *ptr, size_t size); +extern void (*ts_current_free)(void *ptr); + +#ifndef ts_malloc +#define ts_malloc ts_current_malloc +#endif +#ifndef ts_calloc +#define ts_calloc ts_current_calloc +#endif +#ifndef ts_realloc +#define ts_realloc ts_current_realloc +#endif +#ifndef ts_free +#define ts_free ts_current_free +#endif + +#else + +#ifndef ts_malloc +#define ts_malloc malloc +#endif +#ifndef ts_calloc +#define ts_calloc calloc +#endif +#ifndef ts_realloc +#define ts_realloc realloc +#endif +#ifndef ts_free +#define ts_free free +#endif + +#endif + +#ifdef __cplusplus +} +#endif + +#endif // TREE_SITTER_ALLOC_H_ diff --git a/gitnexus/vendor/tree-sitter-proto/src/tree_sitter/array.h b/gitnexus/vendor/tree-sitter-proto/src/tree_sitter/array.h new file mode 100644 index 000000000..a17a574f0 --- /dev/null +++ b/gitnexus/vendor/tree-sitter-proto/src/tree_sitter/array.h @@ -0,0 +1,291 @@ +#ifndef TREE_SITTER_ARRAY_H_ +#define TREE_SITTER_ARRAY_H_ + +#ifdef __cplusplus +extern "C" { +#endif + +#include "./alloc.h" + +#include +#include +#include +#include +#include + +#ifdef _MSC_VER +#pragma warning(push) +#pragma warning(disable : 4101) +#elif defined(__GNUC__) || defined(__clang__) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wunused-variable" +#endif + +#define Array(T) \ + struct { \ + T *contents; \ + uint32_t size; \ + uint32_t capacity; \ + } + +/// Initialize an array. +#define array_init(self) \ + ((self)->size = 0, (self)->capacity = 0, (self)->contents = NULL) + +/// Create an empty array. +#define array_new() \ + { NULL, 0, 0 } + +/// Get a pointer to the element at a given `index` in the array. +#define array_get(self, _index) \ + (assert((uint32_t)(_index) < (self)->size), &(self)->contents[_index]) + +/// Get a pointer to the first element in the array. +#define array_front(self) array_get(self, 0) + +/// Get a pointer to the last element in the array. +#define array_back(self) array_get(self, (self)->size - 1) + +/// Clear the array, setting its size to zero. Note that this does not free any +/// memory allocated for the array's contents. +#define array_clear(self) ((self)->size = 0) + +/// Reserve `new_capacity` elements of space in the array. If `new_capacity` is +/// less than the array's current capacity, this function has no effect. +#define array_reserve(self, new_capacity) \ + _array__reserve((Array *)(self), array_elem_size(self), new_capacity) + +/// Free any memory allocated for this array. Note that this does not free any +/// memory allocated for the array's contents. +#define array_delete(self) _array__delete((Array *)(self)) + +/// Push a new `element` onto the end of the array. +#define array_push(self, element) \ + (_array__grow((Array *)(self), 1, array_elem_size(self)), \ + (self)->contents[(self)->size++] = (element)) + +/// Increase the array's size by `count` elements. +/// New elements are zero-initialized. +#define array_grow_by(self, count) \ + do { \ + if ((count) == 0) break; \ + _array__grow((Array *)(self), count, array_elem_size(self)); \ + memset((self)->contents + (self)->size, 0, (count) * array_elem_size(self)); \ + (self)->size += (count); \ + } while (0) + +/// Append all elements from one array to the end of another. +#define array_push_all(self, other) \ + array_extend((self), (other)->size, (other)->contents) + +/// Append `count` elements to the end of the array, reading their values from the +/// `contents` pointer. +#define array_extend(self, count, contents) \ + _array__splice( \ + (Array *)(self), array_elem_size(self), (self)->size, \ + 0, count, contents \ + ) + +/// Remove `old_count` elements from the array starting at the given `index`. At +/// the same index, insert `new_count` new elements, reading their values from the +/// `new_contents` pointer. +#define array_splice(self, _index, old_count, new_count, new_contents) \ + _array__splice( \ + (Array *)(self), array_elem_size(self), _index, \ + old_count, new_count, new_contents \ + ) + +/// Insert one `element` into the array at the given `index`. +#define array_insert(self, _index, element) \ + _array__splice((Array *)(self), array_elem_size(self), _index, 0, 1, &(element)) + +/// Remove one element from the array at the given `index`. +#define array_erase(self, _index) \ + _array__erase((Array *)(self), array_elem_size(self), _index) + +/// Pop the last element off the array, returning the element by value. +#define array_pop(self) ((self)->contents[--(self)->size]) + +/// Assign the contents of one array to another, reallocating if necessary. +#define array_assign(self, other) \ + _array__assign((Array *)(self), (const Array *)(other), array_elem_size(self)) + +/// Swap one array with another +#define array_swap(self, other) \ + _array__swap((Array *)(self), (Array *)(other)) + +/// Get the size of the array contents +#define array_elem_size(self) (sizeof *(self)->contents) + +/// Search a sorted array for a given `needle` value, using the given `compare` +/// callback to determine the order. +/// +/// If an existing element is found to be equal to `needle`, then the `index` +/// out-parameter is set to the existing value's index, and the `exists` +/// out-parameter is set to true. Otherwise, `index` is set to an index where +/// `needle` should be inserted in order to preserve the sorting, and `exists` +/// is set to false. +#define array_search_sorted_with(self, compare, needle, _index, _exists) \ + _array__search_sorted(self, 0, compare, , needle, _index, _exists) + +/// Search a sorted array for a given `needle` value, using integer comparisons +/// of a given struct field (specified with a leading dot) to determine the order. +/// +/// See also `array_search_sorted_with`. +#define array_search_sorted_by(self, field, needle, _index, _exists) \ + _array__search_sorted(self, 0, _compare_int, field, needle, _index, _exists) + +/// Insert a given `value` into a sorted array, using the given `compare` +/// callback to determine the order. +#define array_insert_sorted_with(self, compare, value) \ + do { \ + unsigned _index, _exists; \ + array_search_sorted_with(self, compare, &(value), &_index, &_exists); \ + if (!_exists) array_insert(self, _index, value); \ + } while (0) + +/// Insert a given `value` into a sorted array, using integer comparisons of +/// a given struct field (specified with a leading dot) to determine the order. +/// +/// See also `array_search_sorted_by`. +#define array_insert_sorted_by(self, field, value) \ + do { \ + unsigned _index, _exists; \ + array_search_sorted_by(self, field, (value) field, &_index, &_exists); \ + if (!_exists) array_insert(self, _index, value); \ + } while (0) + +// Private + +typedef Array(void) Array; + +/// This is not what you're looking for, see `array_delete`. +static inline void _array__delete(Array *self) { + if (self->contents) { + ts_free(self->contents); + self->contents = NULL; + self->size = 0; + self->capacity = 0; + } +} + +/// This is not what you're looking for, see `array_erase`. +static inline void _array__erase(Array *self, size_t element_size, + uint32_t index) { + assert(index < self->size); + char *contents = (char *)self->contents; + memmove(contents + index * element_size, contents + (index + 1) * element_size, + (self->size - index - 1) * element_size); + self->size--; +} + +/// This is not what you're looking for, see `array_reserve`. +static inline void _array__reserve(Array *self, size_t element_size, uint32_t new_capacity) { + if (new_capacity > self->capacity) { + if (self->contents) { + self->contents = ts_realloc(self->contents, new_capacity * element_size); + } else { + self->contents = ts_malloc(new_capacity * element_size); + } + self->capacity = new_capacity; + } +} + +/// This is not what you're looking for, see `array_assign`. +static inline void _array__assign(Array *self, const Array *other, size_t element_size) { + _array__reserve(self, element_size, other->size); + self->size = other->size; + memcpy(self->contents, other->contents, self->size * element_size); +} + +/// This is not what you're looking for, see `array_swap`. +static inline void _array__swap(Array *self, Array *other) { + Array swap = *other; + *other = *self; + *self = swap; +} + +/// This is not what you're looking for, see `array_push` or `array_grow_by`. +static inline void _array__grow(Array *self, uint32_t count, size_t element_size) { + uint32_t new_size = self->size + count; + if (new_size > self->capacity) { + uint32_t new_capacity = self->capacity * 2; + if (new_capacity < 8) new_capacity = 8; + if (new_capacity < new_size) new_capacity = new_size; + _array__reserve(self, element_size, new_capacity); + } +} + +/// This is not what you're looking for, see `array_splice`. +static inline void _array__splice(Array *self, size_t element_size, + uint32_t index, uint32_t old_count, + uint32_t new_count, const void *elements) { + uint32_t new_size = self->size + new_count - old_count; + uint32_t old_end = index + old_count; + uint32_t new_end = index + new_count; + assert(old_end <= self->size); + + _array__reserve(self, element_size, new_size); + + char *contents = (char *)self->contents; + if (self->size > old_end) { + memmove( + contents + new_end * element_size, + contents + old_end * element_size, + (self->size - old_end) * element_size + ); + } + if (new_count > 0) { + if (elements) { + memcpy( + (contents + index * element_size), + elements, + new_count * element_size + ); + } else { + memset( + (contents + index * element_size), + 0, + new_count * element_size + ); + } + } + self->size += new_count - old_count; +} + +/// A binary search routine, based on Rust's `std::slice::binary_search_by`. +/// This is not what you're looking for, see `array_search_sorted_with` or `array_search_sorted_by`. +#define _array__search_sorted(self, start, compare, suffix, needle, _index, _exists) \ + do { \ + *(_index) = start; \ + *(_exists) = false; \ + uint32_t size = (self)->size - *(_index); \ + if (size == 0) break; \ + int comparison; \ + while (size > 1) { \ + uint32_t half_size = size / 2; \ + uint32_t mid_index = *(_index) + half_size; \ + comparison = compare(&((self)->contents[mid_index] suffix), (needle)); \ + if (comparison <= 0) *(_index) = mid_index; \ + size -= half_size; \ + } \ + comparison = compare(&((self)->contents[*(_index)] suffix), (needle)); \ + if (comparison == 0) *(_exists) = true; \ + else if (comparison < 0) *(_index) += 1; \ + } while (0) + +/// Helper macro for the `_sorted_by` routines below. This takes the left (existing) +/// parameter by reference in order to work with the generic sorting function above. +#define _compare_int(a, b) ((int)*(a) - (int)(b)) + +#ifdef _MSC_VER +#pragma warning(pop) +#elif defined(__GNUC__) || defined(__clang__) +#pragma GCC diagnostic pop +#endif + +#ifdef __cplusplus +} +#endif + +#endif // TREE_SITTER_ARRAY_H_ diff --git a/gitnexus/vendor/tree-sitter-proto/src/tree_sitter/parser.h b/gitnexus/vendor/tree-sitter-proto/src/tree_sitter/parser.h new file mode 100644 index 000000000..799f599bd --- /dev/null +++ b/gitnexus/vendor/tree-sitter-proto/src/tree_sitter/parser.h @@ -0,0 +1,266 @@ +#ifndef TREE_SITTER_PARSER_H_ +#define TREE_SITTER_PARSER_H_ + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include +#include + +#define ts_builtin_sym_error ((TSSymbol)-1) +#define ts_builtin_sym_end 0 +#define TREE_SITTER_SERIALIZATION_BUFFER_SIZE 1024 + +#ifndef TREE_SITTER_API_H_ +typedef uint16_t TSStateId; +typedef uint16_t TSSymbol; +typedef uint16_t TSFieldId; +typedef struct TSLanguage TSLanguage; +#endif + +typedef struct { + TSFieldId field_id; + uint8_t child_index; + bool inherited; +} TSFieldMapEntry; + +typedef struct { + uint16_t index; + uint16_t length; +} TSFieldMapSlice; + +typedef struct { + bool visible; + bool named; + bool supertype; +} TSSymbolMetadata; + +typedef struct TSLexer TSLexer; + +struct TSLexer { + int32_t lookahead; + TSSymbol result_symbol; + void (*advance)(TSLexer *, bool); + void (*mark_end)(TSLexer *); + uint32_t (*get_column)(TSLexer *); + bool (*is_at_included_range_start)(const TSLexer *); + bool (*eof)(const TSLexer *); + void (*log)(const TSLexer *, const char *, ...); +}; + +typedef enum { + TSParseActionTypeShift, + TSParseActionTypeReduce, + TSParseActionTypeAccept, + TSParseActionTypeRecover, +} TSParseActionType; + +typedef union { + struct { + uint8_t type; + TSStateId state; + bool extra; + bool repetition; + } shift; + struct { + uint8_t type; + uint8_t child_count; + TSSymbol symbol; + int16_t dynamic_precedence; + uint16_t production_id; + } reduce; + uint8_t type; +} TSParseAction; + +typedef struct { + uint16_t lex_state; + uint16_t external_lex_state; +} TSLexMode; + +typedef union { + TSParseAction action; + struct { + uint8_t count; + bool reusable; + } entry; +} TSParseActionEntry; + +typedef struct { + int32_t start; + int32_t end; +} TSCharacterRange; + +struct TSLanguage { + uint32_t version; + uint32_t symbol_count; + uint32_t alias_count; + uint32_t token_count; + uint32_t external_token_count; + uint32_t state_count; + uint32_t large_state_count; + uint32_t production_id_count; + uint32_t field_count; + uint16_t max_alias_sequence_length; + const uint16_t *parse_table; + const uint16_t *small_parse_table; + const uint32_t *small_parse_table_map; + const TSParseActionEntry *parse_actions; + const char * const *symbol_names; + const char * const *field_names; + const TSFieldMapSlice *field_map_slices; + const TSFieldMapEntry *field_map_entries; + const TSSymbolMetadata *symbol_metadata; + const TSSymbol *public_symbol_map; + const uint16_t *alias_map; + const TSSymbol *alias_sequences; + const TSLexMode *lex_modes; + bool (*lex_fn)(TSLexer *, TSStateId); + bool (*keyword_lex_fn)(TSLexer *, TSStateId); + TSSymbol keyword_capture_token; + struct { + const bool *states; + const TSSymbol *symbol_map; + void *(*create)(void); + void (*destroy)(void *); + bool (*scan)(void *, TSLexer *, const bool *symbol_whitelist); + unsigned (*serialize)(void *, char *); + void (*deserialize)(void *, const char *, unsigned); + } external_scanner; + const TSStateId *primary_state_ids; +}; + +static inline bool set_contains(TSCharacterRange *ranges, uint32_t len, int32_t lookahead) { + uint32_t index = 0; + uint32_t size = len - index; + while (size > 1) { + uint32_t half_size = size / 2; + uint32_t mid_index = index + half_size; + TSCharacterRange *range = &ranges[mid_index]; + if (lookahead >= range->start && lookahead <= range->end) { + return true; + } else if (lookahead > range->end) { + index = mid_index; + } + size -= half_size; + } + TSCharacterRange *range = &ranges[index]; + return (lookahead >= range->start && lookahead <= range->end); +} + +/* + * Lexer Macros + */ + +#ifdef _MSC_VER +#define UNUSED __pragma(warning(suppress : 4101)) +#else +#define UNUSED __attribute__((unused)) +#endif + +#define START_LEXER() \ + bool result = false; \ + bool skip = false; \ + UNUSED \ + bool eof = false; \ + int32_t lookahead; \ + goto start; \ + next_state: \ + lexer->advance(lexer, skip); \ + start: \ + skip = false; \ + lookahead = lexer->lookahead; + +#define ADVANCE(state_value) \ + { \ + state = state_value; \ + goto next_state; \ + } + +#define ADVANCE_MAP(...) \ + { \ + static const uint16_t map[] = { __VA_ARGS__ }; \ + for (uint32_t i = 0; i < sizeof(map) / sizeof(map[0]); i += 2) { \ + if (map[i] == lookahead) { \ + state = map[i + 1]; \ + goto next_state; \ + } \ + } \ + } + +#define SKIP(state_value) \ + { \ + skip = true; \ + state = state_value; \ + goto next_state; \ + } + +#define ACCEPT_TOKEN(symbol_value) \ + result = true; \ + lexer->result_symbol = symbol_value; \ + lexer->mark_end(lexer); + +#define END_STATE() return result; + +/* + * Parse Table Macros + */ + +#define SMALL_STATE(id) ((id) - LARGE_STATE_COUNT) + +#define STATE(id) id + +#define ACTIONS(id) id + +#define SHIFT(state_value) \ + {{ \ + .shift = { \ + .type = TSParseActionTypeShift, \ + .state = (state_value) \ + } \ + }} + +#define SHIFT_REPEAT(state_value) \ + {{ \ + .shift = { \ + .type = TSParseActionTypeShift, \ + .state = (state_value), \ + .repetition = true \ + } \ + }} + +#define SHIFT_EXTRA() \ + {{ \ + .shift = { \ + .type = TSParseActionTypeShift, \ + .extra = true \ + } \ + }} + +#define REDUCE(symbol_name, children, precedence, prod_id) \ + {{ \ + .reduce = { \ + .type = TSParseActionTypeReduce, \ + .symbol = symbol_name, \ + .child_count = children, \ + .dynamic_precedence = precedence, \ + .production_id = prod_id \ + }, \ + }} + +#define RECOVER() \ + {{ \ + .type = TSParseActionTypeRecover \ + }} + +#define ACCEPT_INPUT() \ + {{ \ + .type = TSParseActionTypeAccept \ + }} + +#ifdef __cplusplus +} +#endif + +#endif // TREE_SITTER_PARSER_H_ From 79e1d933fa3ade75c295b32752c544b3882d84d1 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:16:42 +0100 Subject: [PATCH 23/67] fix: resolve generic TypeScript awaited function calls missing from call graph (#804) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * fix: resolve generic TypeScript function callers missed by impact analysis When a generic function call is combined with `await` (e.g. `await fn(args)`), tree-sitter-typescript parses it as a `call_expression` whose `function` field is an `await_expression` rather than a bare `identifier`. The existing queries only matched `call_expression { function: identifier }`, so these calls produced no `@call.name` capture and were silently dropped from the call graph. Fix: add two new tree-sitter query patterns to `TYPESCRIPT_QUERIES` that handle: 1. `await fn(args)` — awaited generic free call 2. `await obj.fn(args)` — awaited generic member call Both patterns require the `(type_arguments)` child to be present (which is what causes tree-sitter to parse the `function` field as an `await_expression`). Non-generic awaited calls (`await fn(args)`) are unaffected: tree-sitter parses them as `await_expression { call_expression { identifier } }`, which is still captured by the existing first pattern. Also adds a new test fixture `typescript-generic-calls` with two callers of a generic `verifyToken` function using `await` and three new integration tests. Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/4cf75290-900b-4cea-8a65-2a245ff86970 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: clean up test fixture interface ordering and imports Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/4cf75290-900b-4cea-8a65-2a245ff86970 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * test: add coverage for awaited generic member-call form (await obj.fn()) Address review feedback: the member-call query pattern was untested. Adds service.ts (TokenService with generic verify method) and guest.ts (calls await svc.verify()) to the typescript-generic-calls fixture, plus a new integration test asserting the CALLS edge resolves. Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/fcbf8d99-8dbc-40ce-b2a3-60b8d63c095a Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * revert: undo accidental ladybugdb version bump in package files Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/fcbf8d99-8dbc-40ce-b2a3-60b8d63c095a Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * style: run prettier on changed files Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/8c7d8291-74bb-4a86-ae47-7c79e2cbb57e --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --- .../src/core/ingestion/tree-sitter-queries.ts | 17 ++++++++ .../typescript-generic-calls/src/admin.ts | 10 +++++ .../typescript-generic-calls/src/auth.ts | 10 +++++ .../typescript-generic-calls/src/guest.ts | 12 ++++++ .../typescript-generic-calls/src/service.ts | 7 +++ .../typescript-generic-calls/src/token.ts | 7 +++ .../integration/resolvers/typescript.test.ts | 43 +++++++++++++++++++ 7 files changed, 106 insertions(+) create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/admin.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/auth.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/guest.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/service.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/token.ts diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index 7180806ae..eb96ffb4a 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -75,6 +75,23 @@ export const TYPESCRIPT_QUERIES = ` function: (member_expression property: (property_identifier) @call.name)) @call +; Generic awaited free call: await fn(args) +; tree-sitter-typescript parses "await fn(args)" as a call_expression whose +; "function" field is an await_expression (not a bare identifier), because the +; grammar resolves the ambiguity between generics and comparisons by consuming +; "await fn" as an expression before attaching as type_arguments. +(call_expression + function: (await_expression + (identifier) @call.name) + (type_arguments)) @call + +; Generic awaited member call: await obj.fn(args) +(call_expression + function: (await_expression + (member_expression + property: (property_identifier) @call.name)) + (type_arguments)) @call + ; Constructor calls: new Foo() (new_expression constructor: (identifier) @call.name) @call diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/admin.ts b/gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/admin.ts new file mode 100644 index 000000000..8a5891d60 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/admin.ts @@ -0,0 +1,10 @@ +import { verifyToken, BasePayload } from './token'; + +interface AdminPayload extends BasePayload { + role: string; +} + +export async function authenticateAdmin(token: string): Promise { + const payload = await verifyToken(token, 'admin-secret'); + return payload; +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/auth.ts b/gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/auth.ts new file mode 100644 index 000000000..5b056d034 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/auth.ts @@ -0,0 +1,10 @@ +import { verifyToken, BasePayload } from './token'; + +interface UserPayload extends BasePayload { + userId: string; +} + +export async function authenticateUser(token: string): Promise { + const payload = await verifyToken(token, 'secret'); + return payload; +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/guest.ts b/gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/guest.ts new file mode 100644 index 000000000..525da29cd --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/guest.ts @@ -0,0 +1,12 @@ +import { BasePayload } from './token'; +import { TokenService } from './service'; + +interface GuestPayload extends BasePayload { + sessionId: string; +} + +export async function authenticateGuest(token: string): Promise { + const svc = new TokenService(); + const payload = await svc.verify(token, 'guest-secret'); + return payload; +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/service.ts b/gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/service.ts new file mode 100644 index 000000000..675dab9c2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/service.ts @@ -0,0 +1,7 @@ +import { BasePayload } from './token'; + +export class TokenService { + verify(token: string, secret: string): T { + return JSON.parse(Buffer.from(token, 'base64').toString()) as T; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/token.ts b/gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/token.ts new file mode 100644 index 000000000..4fbf5001e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-generic-calls/src/token.ts @@ -0,0 +1,7 @@ +export interface BasePayload { + sub: string; +} + +export function verifyToken(token: string, secret: string): T { + return JSON.parse(Buffer.from(token, 'base64').toString()) as T; +} diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index c883678ac..0d6da9e27 100644 --- a/gitnexus/test/integration/resolvers/typescript.test.ts +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -145,6 +145,49 @@ describe('TypeScript call resolution with arity filtering', () => { }); }); +// --------------------------------------------------------------------------- +// Generic function call resolution: await fn(args) creates CALLS edges +// --------------------------------------------------------------------------- + +describe('TypeScript generic awaited call resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'typescript-generic-calls'), () => {}); + }, 60000); + + it('resolves authenticateUser → verifyToken via awaited generic call', () => { + const calls = getRelationships(result, 'CALLS'); + const authCall = calls.find( + (c) => c.source === 'authenticateUser' && c.target === 'verifyToken', + ); + expect(authCall).toBeDefined(); + expect(authCall!.targetFilePath).toBe('src/token.ts'); + }); + + it('resolves authenticateAdmin → verifyToken via awaited generic call', () => { + const calls = getRelationships(result, 'CALLS'); + const adminCall = calls.find( + (c) => c.source === 'authenticateAdmin' && c.target === 'verifyToken', + ); + expect(adminCall).toBeDefined(); + expect(adminCall!.targetFilePath).toBe('src/token.ts'); + }); + + it('resolves authenticateGuest → verify via awaited generic member call', () => { + const calls = getRelationships(result, 'CALLS'); + const guestCall = calls.find((c) => c.source === 'authenticateGuest' && c.target === 'verify'); + expect(guestCall).toBeDefined(); + expect(guestCall!.targetFilePath).toBe('src/service.ts'); + }); + + it('verifyToken has exactly 2 incoming CALLS edges (both free-call callers resolved)', () => { + const calls = getRelationships(result, 'CALLS'); + const incoming = calls.filter((c) => c.target === 'verifyToken'); + expect(incoming.length).toBe(2); + }); +}); + // --------------------------------------------------------------------------- // Member-call resolution: obj.method() resolves through pipeline // --------------------------------------------------------------------------- From 9f4109a33fd2c259ef42240fab5ef248368b68a4 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:17:30 +0100 Subject: [PATCH 24/67] fix: remove `file:../gitnexus-shared` from runtime dependencies (#803) * Initial plan * fix: remove file:../gitnexus-shared from dependencies to fix npm install outside monorepo Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/2ea9c1da-1b0c-4ab0-b370-f3970cc54ffa Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --- gitnexus/package-lock.json | 1 - gitnexus/package.json | 1 - 2 files changed, 2 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index a746292e8..644cc0058 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -18,7 +18,6 @@ "commander": "^12.0.0", "cors": "^2.8.5", "express": "^4.19.2", - "gitnexus-shared": "file:../gitnexus-shared", "glob": "^11.0.0", "graphology": "^0.25.4", "graphology-indices": "^0.17.0", diff --git a/gitnexus/package.json b/gitnexus/package.json index 435f9b325..064ede056 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -59,7 +59,6 @@ "commander": "^12.0.0", "cors": "^2.8.5", "express": "^4.19.2", - "gitnexus-shared": "file:../gitnexus-shared", "glob": "^11.0.0", "graphology": "^0.25.4", "graphology-indices": "^0.17.0", From a6421b3b1b821318f32ed34469c689045240c379 Mon Sep 17 00:00:00 2001 From: Arkh74278 <136110739+Arkh74278@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:21:11 +0300 Subject: [PATCH 25/67] [dart] Add call patterns for await, cascade, lambda, and widget-tree contexts (#801) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(dart): add call patterns for await, cascade, lambda, and widget-tree contexts * fix(dart): address review feedback — await member-chain, cascade comment, static_final comment, add to query-compilation smoke test * test(dart): add integration tests for await and widget-tree call patterns * style: apply prettier formatting to dart integration tests --------- Co-authored-by: arkh --- .../src/core/ingestion/tree-sitter-queries.ts | 52 ++++++++++++++ .../lang-resolution/dart-await-calls/app.dart | 6 ++ .../dart-await-calls/service.dart | 5 ++ .../dart-widget-tree-calls/app.dart | 10 +++ .../dart-widget-tree-calls/builders.dart | 3 + .../integration/query-compilation.test.ts | 1 + .../test/integration/resolvers/dart.test.ts | 67 +++++++++++++++++++ .../test/unit/tree-sitter-queries.test.ts | 64 ++++++++++++++++++ 8 files changed, 208 insertions(+) create mode 100644 gitnexus/test/fixtures/lang-resolution/dart-await-calls/app.dart create mode 100644 gitnexus/test/fixtures/lang-resolution/dart-await-calls/service.dart create mode 100644 gitnexus/test/fixtures/lang-resolution/dart-widget-tree-calls/app.dart create mode 100644 gitnexus/test/fixtures/lang-resolution/dart-widget-tree-calls/builders.dart diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index eb96ffb4a..99fd3b21c 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -1158,6 +1158,58 @@ export const DART_QUERIES = ` (identifier) @call.name)) (selector (argument_part))) @call +; ── Calls: await direct (await doSomething()) ──────────────────────────────── +(await_expression + (identifier) @call.name + . + (selector (argument_part))) @call + +; ── Calls: await method chain (await obj.method()) ─────────────────────────── +; Requires argument_part to distinguish method calls from field access (await obj.field) +(await_expression + (selector + (unconditional_assignable_selector + (identifier) @call.name)) + (selector (argument_part))) @call + +; ── Calls: named argument (foo(child: buildX())) ───────────────────────────── +(named_argument + (identifier) @call.name + . + (selector (argument_part))) @call + +; ── Calls: inside list literals ([buildA(), buildB()]) ─────────────────────── +(list_literal + (identifier) @call.name + . + (selector (argument_part))) @call + +; ── Calls: cascade (obj..add(x)..sort()) ───────────────────────────────────── +; Note: cascade_selector contains identifier directly (no unconditional_assignable_selector +; wrapper in Dart grammar), so inferCallForm() classifies these as free calls rather than +; member calls. Cross-file resolution still benefits from the call being recorded. +(cascade_section + (cascade_selector (identifier) @call.name) + (argument_part)) @call + +; ── Calls: static final field initializers (static final _svc = MyService()) ── +(static_final_declaration + (identifier) @call.name + . + (selector (argument_part))) @call + +; ── Calls: arrow function body (=> buildWidget()) ──────────────────────────── +(function_body "=>" + (identifier) @call.name + . + (selector (argument_part))) @call + +; ── Calls: lambda body (() => doSomething()) ───────────────────────────────── +(function_expression_body + (identifier) @call.name + . + (selector (argument_part))) @call + ; ── Re-exports (export 'foo.dart') ─────────────────────────────────────────── (import_or_export (library_export diff --git a/gitnexus/test/fixtures/lang-resolution/dart-await-calls/app.dart b/gitnexus/test/fixtures/lang-resolution/dart-await-calls/app.dart new file mode 100644 index 000000000..efa4c00ed --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/dart-await-calls/app.dart @@ -0,0 +1,6 @@ +import 'service.dart'; + +Future run() async { + final user = await fetchUser(); + await processData(user); +} diff --git a/gitnexus/test/fixtures/lang-resolution/dart-await-calls/service.dart b/gitnexus/test/fixtures/lang-resolution/dart-await-calls/service.dart new file mode 100644 index 000000000..c0251b632 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/dart-await-calls/service.dart @@ -0,0 +1,5 @@ +Future fetchUser() async { + return 'user'; +} + +Future processData(String data) async {} diff --git a/gitnexus/test/fixtures/lang-resolution/dart-widget-tree-calls/app.dart b/gitnexus/test/fixtures/lang-resolution/dart-widget-tree-calls/app.dart new file mode 100644 index 000000000..844e26abe --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/dart-widget-tree-calls/app.dart @@ -0,0 +1,10 @@ +import 'builders.dart'; + +// Named argument call: child: buildHeader() +// List literal calls: children: [buildBody(), buildFooter()] +dynamic buildPage() { + return Column( + child: buildHeader(), + children: [buildBody(), buildFooter()], + ); +} diff --git a/gitnexus/test/fixtures/lang-resolution/dart-widget-tree-calls/builders.dart b/gitnexus/test/fixtures/lang-resolution/dart-widget-tree-calls/builders.dart new file mode 100644 index 000000000..8e1d981d2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/dart-widget-tree-calls/builders.dart @@ -0,0 +1,3 @@ +dynamic buildHeader() => null; +dynamic buildBody() => null; +dynamic buildFooter() => null; diff --git a/gitnexus/test/integration/query-compilation.test.ts b/gitnexus/test/integration/query-compilation.test.ts index d0fd038a7..6652a59bf 100644 --- a/gitnexus/test/integration/query-compilation.test.ts +++ b/gitnexus/test/integration/query-compilation.test.ts @@ -33,6 +33,7 @@ describe('Query compilation smoke tests', () => { [SupportedLanguages.PHP]: 'test.php', [SupportedLanguages.Kotlin]: 'Test.kt', [SupportedLanguages.Swift]: 'test.swift', + [SupportedLanguages.Dart]: 'test.dart', }; // Known query compilation failures — remove from this set as PRs fix them diff --git a/gitnexus/test/integration/resolvers/dart.test.ts b/gitnexus/test/integration/resolvers/dart.test.ts index 82fa77f22..9989e0de0 100644 --- a/gitnexus/test/integration/resolvers/dart.test.ts +++ b/gitnexus/test/integration/resolvers/dart.test.ts @@ -512,3 +512,70 @@ describe.skipIf(!dartAvailable)( }); }, ); + +// --------------------------------------------------------------------------- +// await call patterns: await fetchUser(), await processData() +// --------------------------------------------------------------------------- + +describe.skipIf(!dartAvailable)('Dart await call resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'dart-await-calls'), () => {}); + }, 60000); + + it('detects fetchUser and processData as functions', () => { + const fns = getNodesByLabel(result, 'Function'); + expect(fns).toContain('fetchUser'); + expect(fns).toContain('processData'); + }); + + it('resolves run → fetchUser via await direct call', () => { + const calls = getRelationships(result, 'CALLS'); + const edge = calls.find((c) => c.source === 'run' && c.target === 'fetchUser'); + expect(edge).toBeDefined(); + }); + + it('resolves run → processData via await direct call', () => { + const calls = getRelationships(result, 'CALLS'); + const edge = calls.find((c) => c.source === 'run' && c.target === 'processData'); + expect(edge).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Widget-tree call patterns: named argument and list literal +// --------------------------------------------------------------------------- + +describe.skipIf(!dartAvailable)('Dart widget-tree call resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'dart-widget-tree-calls'), () => {}); + }, 60000); + + it('detects buildHeader, buildBody, buildFooter as functions', () => { + const fns = getNodesByLabel(result, 'Function'); + expect(fns).toContain('buildHeader'); + expect(fns).toContain('buildBody'); + expect(fns).toContain('buildFooter'); + }); + + it('resolves buildPage → buildHeader via named argument call', () => { + const calls = getRelationships(result, 'CALLS'); + const edge = calls.find((c) => c.source === 'buildPage' && c.target === 'buildHeader'); + expect(edge).toBeDefined(); + }); + + it('resolves buildPage → buildBody via list literal call', () => { + const calls = getRelationships(result, 'CALLS'); + const edge = calls.find((c) => c.source === 'buildPage' && c.target === 'buildBody'); + expect(edge).toBeDefined(); + }); + + it('resolves buildPage → buildFooter via list literal call', () => { + const calls = getRelationships(result, 'CALLS'); + const edge = calls.find((c) => c.source === 'buildPage' && c.target === 'buildFooter'); + expect(edge).toBeDefined(); + }); +}); diff --git a/gitnexus/test/unit/tree-sitter-queries.test.ts b/gitnexus/test/unit/tree-sitter-queries.test.ts index 6a926def2..ffb207bdd 100644 --- a/gitnexus/test/unit/tree-sitter-queries.test.ts +++ b/gitnexus/test/unit/tree-sitter-queries.test.ts @@ -11,6 +11,7 @@ import { RUST_QUERIES, PHP_QUERIES, SWIFT_QUERIES, + DART_QUERIES, } from '../../src/core/ingestion/tree-sitter-queries.js'; describe('tree-sitter queries', () => { @@ -292,4 +293,67 @@ describe('tree-sitter queries', () => { expect(SWIFT_QUERIES).toContain('"actor"'); }); }); + + describe('Dart queries', () => { + it('captures class, mixin, extension, enum declarations', () => { + expect(DART_QUERIES).toContain('@definition.class'); + expect(DART_QUERIES).toContain('@definition.trait'); + expect(DART_QUERIES).toContain('@definition.enum'); + }); + + it('captures top-level functions and methods', () => { + expect(DART_QUERIES).toContain('@definition.function'); + expect(DART_QUERIES).toContain('@definition.method'); + }); + + it('captures constructors including factory constructors', () => { + expect(DART_QUERIES).toContain('@definition.constructor'); + expect(DART_QUERIES).toContain('factory_constructor_signature'); + }); + + it('captures field declarations and getters/setters', () => { + expect(DART_QUERIES).toContain('@definition.property'); + expect(DART_QUERIES).toContain('getter_signature'); + expect(DART_QUERIES).toContain('setter_signature'); + }); + + it('captures import statements', () => { + expect(DART_QUERIES).toContain('@import'); + expect(DART_QUERIES).toContain('library_import'); + }); + + it('captures heritage (extends, implements, with)', () => { + expect(DART_QUERIES).toContain('@heritage.extends'); + }); + + it('captures direct calls and method chains', () => { + expect(DART_QUERIES).toContain('expression_statement'); + expect(DART_QUERIES).toContain('unconditional_assignable_selector'); + expect(DART_QUERIES).toContain('@call'); + }); + + it('captures await expressions as calls', () => { + expect(DART_QUERIES).toContain('await_expression'); + }); + + it('captures named argument calls (widget children)', () => { + expect(DART_QUERIES).toContain('named_argument'); + }); + + it('captures list literal calls (widget children lists)', () => { + expect(DART_QUERIES).toContain('list_literal'); + }); + + it('captures cascade calls (obj..method())', () => { + expect(DART_QUERIES).toContain('cascade_section'); + }); + + it('captures arrow function body calls (=> expr)', () => { + expect(DART_QUERIES).toContain('function_body "=>"'); + }); + + it('captures lambda body calls (() => expr)', () => { + expect(DART_QUERIES).toContain('function_expression_body'); + }); + }); }); From d786e692afe79ebd023eb4f3b65a1d01039895c1 Mon Sep 17 00:00:00 2001 From: Deepak Chauhan <91007260+ideepakchauhan7@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:42:52 +0530 Subject: [PATCH 26/67] [cli] Preserve Ruby singleton_class context in sequential parsing (#774) * fix(parsing): preserve ruby singleton class context * refactor(parsing): clarify singleton class helpers --- .../src/core/ingestion/parsing-processor.ts | 48 +++++++++++++------ .../test/integration/resolvers/ruby.test.ts | 27 +++++++++++ 2 files changed, 60 insertions(+), 15 deletions(-) diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 9cf8394fe..6eba41869 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -242,9 +242,14 @@ function seqFindEnclosingClassNode(node: SyntaxNode): SyntaxNode | null { let current = node.parent; while (current) { if (CLASS_CONTAINER_TYPES.has(current.type)) { - // Return singleton_class directly so the method extractor sees it as - // the owner node and correctly marks methods as static. Name resolution - // for qualified names is handled separately by findEnclosingClassInfo. + // Ruby singleton_class (class << self) has no name field, so owner/class + // resolution should skip it and return the enclosing class/module instead. + // A file-root `class << self` has no enclosing class/module, so this + // intentionally falls through to null rather than synthesizing an owner. + if (current.type === 'singleton_class') { + current = current.parent; + continue; + } return current; } current = current.parent; @@ -252,11 +257,22 @@ function seqFindEnclosingClassNode(node: SyntaxNode): SyntaxNode | null { return null; } -/** Minimal no-op SymbolTable stub for FieldExtractorContext (sequential - * path has a real SymbolTable, but it's incomplete at this stage — use - * the stub for safety). Implements the full {@link SymbolTableReader} - * surface so future extractor additions don't silently fall off an - * `as unknown as` cast. */ +/** Raw enclosing container lookup for extractor-only context. + * Unlike seqFindEnclosingClassNode(), this intentionally returns + * `singleton_class` so Ruby `class << self` methods preserve static context. */ +function seqFindRawEnclosingContainerNode(node: SyntaxNode): SyntaxNode | null { + let current = node.parent; + while (current) { + if (CLASS_CONTAINER_TYPES.has(current.type)) return current; + current = current.parent; + } + return null; +} + +/** Minimal no-op SymbolTable stub for sequential extractor contexts. The real + * SymbolTable is not fully populated yet at this stage, so use the stub for safety. + * Implements the full {@link SymbolTableReader} surface so future extractor additions + * don't silently fall off an `as unknown as` cast. */ const NOOP_SYMBOL_TABLE_SEQ: SymbolTableReader = { lookupExact: () => undefined, lookupExactFull: () => undefined, @@ -446,22 +462,24 @@ const processParsingSequential = async ( let enriched = false; if (provider.methodExtractor) { - // Try class-based extraction (method inside a class/struct/trait body) - const classNode = seqFindEnclosingClassNode(definitionNode); - if (classNode) { + // Try class-based extraction (method inside a class/struct/trait body). + // Ruby `class << self` needs the singleton_class node for `isStatic`, + // while owner/class resolution still skips it elsewhere. + const methodOwnerNode = seqFindRawEnclosingContainerNode(definitionNode); + if (methodOwnerNode) { // Cache extract() results per class node to avoid re-traversing the // same class body for every method it contains (O(N) -> O(1) per hit). let result: | { ownerName: string | undefined; methods: MethodInfo[] } | null - | undefined = seqMethodExtractCache.get(classNode.id); + | undefined = seqMethodExtractCache.get(methodOwnerNode.id); if (result === undefined) { result = - provider.methodExtractor.extract(classNode, { + provider.methodExtractor.extract(methodOwnerNode, { filePath: file.path, language, }) ?? null; - seqMethodExtractCache.set(classNode.id, result); + seqMethodExtractCache.set(methodOwnerNode.id, result); } if (result?.methods?.length) { const defLine = definitionNode.startPosition.row + 1; @@ -472,7 +490,7 @@ const processParsingSequential = async ( methodProps = buildMethodProps(info); seqDefMethodInfo = info; seqDefMethods = result.methods; - seqClassNodeId = classNode.id; + seqClassNodeId = methodOwnerNode.id; } } } diff --git a/gitnexus/test/integration/resolvers/ruby.test.ts b/gitnexus/test/integration/resolvers/ruby.test.ts index 7e508e756..8cb7a29e3 100644 --- a/gitnexus/test/integration/resolvers/ruby.test.ts +++ b/gitnexus/test/integration/resolvers/ruby.test.ts @@ -1262,6 +1262,33 @@ describe('Ruby method enrichment (visibility, isStatic, parameters)', () => { }); }); +describe('Ruby singleton_class handling via sequential path (skipWorkers)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-method-enrichment'), () => {}, { + skipWorkers: true, + }); + }, 60000); + + it('keeps Animal as the owner for class << self methods', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + expect( + hasMethod.find((e) => e.source === 'Animal' && e.target === 'from_habitat'), + ).toBeDefined(); + }); + + it('marks from_habitat as static in the sequential path', () => { + const methods = getNodesByLabelFull(result, 'Method'); + const fromHabitat = methods.find( + (m) => m.name === 'from_habitat' && m.properties.filePath?.includes('animal'), + ); + expect(fromHabitat).toBeDefined(); + expect(fromHabitat!.properties.isStatic).toBe(true); + expect(fromHabitat!.properties.parameterCount).toBe(1); + }); +}); + // --------------------------------------------------------------------------- // Overload Dispatch: methods with different arity resolve via receiver type // --------------------------------------------------------------------------- From c6726970122d8ea2c99e158741703d930a3ef3c7 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 18:57:56 +0100 Subject: [PATCH 27/67] fix: replace tree-sitter-dart git URL with tarball to fix npm install crash (#811) * Initial plan * fix: replace tree-sitter-dart git URL with tarball URL to fix npm install crash The `github:` git URL for tree-sitter-dart caused npm's arborist to create a dependency node with a null target during the rebuild phase, crashing global installs with: Cannot destructure property 'package' of 'node.target' as it is null. Using a GitHub archive tarball URL instead avoids the arborist bug while still installing from the exact same commit (80e23c0). Fixes #805 Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c15109ae-0865-4d69-bd08-9972dcfe18f9 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --- gitnexus/package-lock.json | 6 +++--- gitnexus/package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 644cc0058..abf0945d4 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -62,7 +62,7 @@ "node": ">=20.0.0" }, "optionalDependencies": { - "tree-sitter-dart": "github:UserNobody14/tree-sitter-dart#80e23c07b64494f7e21090bb3450223ef0b192f4", + "tree-sitter-dart": "https://github.com/UserNobody14/tree-sitter-dart/archive/80e23c07b64494f7e21090bb3450223ef0b192f4.tar.gz", "tree-sitter-kotlin": "^0.3.8", "tree-sitter-proto": "file:./vendor/tree-sitter-proto", "tree-sitter-swift": "^0.6.0" @@ -5132,8 +5132,8 @@ }, "node_modules/tree-sitter-dart": { "version": "1.0.0", - "resolved": "git+ssh://git@github.com/UserNobody14/tree-sitter-dart.git#80e23c07b64494f7e21090bb3450223ef0b192f4", - "integrity": "sha512-Bs/1wAOIJ2akPEXlE/XVpuES19Oo3NqoSJRJ/0N2r38qAd9nTXdqmaGHQ44/JXnA6QHcbgD2YzCCc4wUc98cyQ==", + "resolved": "https://github.com/UserNobody14/tree-sitter-dart/archive/80e23c07b64494f7e21090bb3450223ef0b192f4.tar.gz", + "integrity": "sha512-aqLZTEji2vAZPdbaCSjR0SXJGzFRKD//7VtrSV3st9bgrCM2tsXxXAHZlMlQLOCt7K2yKxM5K3gNXYph8TCjCQ==", "hasInstallScript": true, "license": "ISC", "optional": true, diff --git a/gitnexus/package.json b/gitnexus/package.json index 064ede056..30fd90578 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -84,7 +84,7 @@ "uuid": "^13.0.0" }, "optionalDependencies": { - "tree-sitter-dart": "github:UserNobody14/tree-sitter-dart#80e23c07b64494f7e21090bb3450223ef0b192f4", + "tree-sitter-dart": "https://github.com/UserNobody14/tree-sitter-dart/archive/80e23c07b64494f7e21090bb3450223ef0b192f4.tar.gz", "tree-sitter-kotlin": "^0.3.8", "tree-sitter-proto": "file:./vendor/tree-sitter-proto", "tree-sitter-swift": "^0.6.0" From 6388113e1087483d3b1f1d395cb1809ef01f99ad Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:08:49 +0100 Subject: [PATCH 28/67] fix: prevent stack overflow and memory exhaustion on large repo analysis (#814) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * fix: prevent stack overflow and memory issues on large repo analysis - Convert c3Linearize from recursive to iterative (explicit work stack) to handle deep class hierarchies without stack overflow - Replace push(...arr) spread patterns with safe loops in parse-worker.ts and lbug-adapter.ts to prevent stack overflow on large arrays - Stream relationship CSV lines directly to per-pair temp files in lbug-adapter.ts instead of accumulating millions of lines in memory - Add test for deep 500-level inheritance chain Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9cb2eed2-adc7-4fa4-9216-e7ac3facb9b5 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: add stack size flag and enhanced error messages for large repos - Auto-set --stack-size=4096 alongside --max-old-space-size in analyze command to prevent stack overflow on deep class hierarchies - Add helpful error guidance for known large-repo failure modes (stack overflow, heap OOM, Map size limits) Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9cb2eed2-adc7-4fa4-9216-e7ac3facb9b5 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: address code review feedback - Add error handling for write stream close in lbug-adapter.ts - Handle backpressure when writing relationship CSV lines to disk - Clarify ENTER/MERGE phase transition comment in resolve.ts - Fix inconsistent stack size in error message (4096 not 8192) Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9cb2eed2-adc7-4fa4-9216-e7ac3facb9b5 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: address PR review — resource leak, redundant guard, Set, test depth - Fix write-stream resource leak on readline error by destroying all open WriteStreams before rejecting (lbug-adapter.ts) - Switch failedPairCsvPaths from array to Set for O(1) lookup - Remove redundant MERGE-phase empty-parents guard in resolve.ts (unreachable — ENTER phase already handles that case) - Increase deep inheritance test DEPTH from 500 to 2000 for reliable regression coverage across platforms Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/cf1f3e22-3864-454a-a3a5-2bded9ebfdba Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * style: fix prettier formatting in lbug-adapter.ts Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b5ca33c4-bb03-402f-a206-21ea7e1e310e Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * chore: revert unintended package.json/lock changes Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b5ca33c4-bb03-402f-a206-21ea7e1e310e Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: strip NODE_OPTIONS in skip-git-cli test child processes Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/fc59cd11-348b-4e22-b9ea-98787300de48 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: don't put --stack-size in NODE_OPTIONS (rejected by Node 24) Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/fc59cd11-348b-4e22-b9ea-98787300de48 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: pass --stack-size as CLI arg only, not in NODE_OPTIONS (Node 24 compat) Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/fc59cd11-348b-4e22-b9ea-98787300de48 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --- gitnexus/src/cli/analyze.ts | 36 +++- gitnexus/src/core/ingestion/model/resolve.ts | 163 ++++++++++++------ .../core/ingestion/workers/parse-worker.ts | 6 +- gitnexus/src/core/lbug/lbug-adapter.ts | 96 ++++++++--- gitnexus/test/unit/mro-processor.test.ts | 24 +++ 5 files changed, 245 insertions(+), 80 deletions(-) diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index d520c3404..dc8fd87a9 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -20,8 +20,11 @@ import fs from 'fs/promises'; const HEAP_MB = 8192; const HEAP_FLAG = `--max-old-space-size=${HEAP_MB}`; +/** Increase default stack size (KB) to prevent stack overflow on deep class hierarchies. */ +const STACK_KB = 4096; +const STACK_FLAG = `--stack-size=${STACK_KB}`; -/** Re-exec the process with an 8GB heap if we're currently below that. */ +/** Re-exec the process with an 8GB heap and larger stack if we're currently below that. */ function ensureHeap(): boolean { const nodeOpts = process.env.NODE_OPTIONS || ''; if (nodeOpts.includes('--max-old-space-size')) return false; @@ -29,8 +32,13 @@ function ensureHeap(): boolean { const v8Heap = v8.getHeapStatistics().heap_size_limit; if (v8Heap >= HEAP_MB * 1024 * 1024 * 0.9) return false; + // --stack-size is a V8 flag not allowed in NODE_OPTIONS on Node 24+, + // so pass it only as a direct CLI argument, not via the environment. + const cliFlags = [HEAP_FLAG]; + if (!nodeOpts.includes('--stack-size')) cliFlags.push(STACK_FLAG); + try { - execFileSync(process.execPath, [HEAP_FLAG, ...process.argv.slice(1)], { + execFileSync(process.execPath, [...cliFlags, ...process.argv.slice(1)], { stdio: 'inherit', env: { ...process.env, NODE_OPTIONS: `${nodeOpts} ${HEAP_FLAG}`.trim() }, }); @@ -285,7 +293,29 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption console.warn = origWarn; console.error = origError; bar.stop(); - console.error(`\n Analysis failed: ${err.message}\n`); + + const msg = err.message || String(err); + console.error(`\n Analysis failed: ${msg}\n`); + + // Provide helpful guidance for known large-repo failure modes + if ( + msg.includes('Maximum call stack size exceeded') || + msg.includes('call stack') || + msg.includes('Map maximum size') || + msg.includes('Invalid array length') || + msg.includes('Invalid string length') || + msg.includes('allocation failed') || + msg.includes('heap out of memory') || + msg.includes('JavaScript heap') + ) { + console.error(' This error typically occurs on very large repositories.'); + console.error(' Suggestions:'); + console.error(' 1. Add large vendored/generated directories to .gitnexusignore'); + console.error(' 2. Increase Node.js heap: NODE_OPTIONS="--max-old-space-size=16384"'); + console.error(' 3. Increase stack size: NODE_OPTIONS="--stack-size=4096"'); + console.error(''); + } + process.exitCode = 1; return; } diff --git a/gitnexus/src/core/ingestion/model/resolve.ts b/gitnexus/src/core/ingestion/model/resolve.ts index 106630667..0fcf1e8b3 100644 --- a/gitnexus/src/core/ingestion/model/resolve.ts +++ b/gitnexus/src/core/ingestion/model/resolve.ts @@ -60,72 +60,137 @@ export function c3Linearize( ): string[] | null { if (cache.has(classId)) return cache.get(classId)!; - // Cycle detection: if we're already computing this class, the hierarchy is cyclic + // Iterative C3 linearization using an explicit work stack. The recursive + // version overflows the call stack on deep class hierarchies (10K+ + // levels in large Android/Java codebases). + // + // Strategy: maintain a stack of { classId, phase } frames. Each frame + // goes through two phases: + // ENTER (0) – check cache / cycle, push parent frames to compute first + // MERGE (1) – all parent linearizations are cached, merge them C3-style + const visiting = inProgress ?? new Set(); - if (visiting.has(classId)) { - cache.set(classId, null); - return null; - } - visiting.add(classId); - const directParents = parentMap.get(classId); - if (!directParents || directParents.length === 0) { - visiting.delete(classId); - cache.set(classId, []); - return []; - } + const ENTER = 0; + const MERGE = 1; + const stack: Array<{ id: string; phase: number }> = [{ id: classId, phase: ENTER }]; - // Compute linearization for each parent first - const parentLinearizations: string[][] = []; - for (const pid of directParents) { - const pLin = c3Linearize(pid, parentMap, cache, visiting); - if (pLin === null) { - visiting.delete(classId); - cache.set(classId, null); - return null; + while (stack.length > 0) { + const frame = stack[stack.length - 1]; + + if (frame.phase === ENTER) { + // ── ENTER phase ───────────────────────────────────────────── + if (cache.has(frame.id)) { + stack.pop(); + continue; + } + + if (visiting.has(frame.id)) { + // Cycle detected + cache.set(frame.id, null); + stack.pop(); + continue; + } + visiting.add(frame.id); + + const directParents = parentMap.get(frame.id); + if (!directParents || directParents.length === 0) { + visiting.delete(frame.id); + cache.set(frame.id, []); + stack.pop(); + continue; + } + + // Switch to MERGE phase and push parents that still need computing + frame.phase = MERGE; + let allParentsCached = true; + for (let i = directParents.length - 1; i >= 0; i--) { + const pid = directParents[i]; + if (!cache.has(pid)) { + stack.push({ id: pid, phase: ENTER }); + allParentsCached = false; + } + } + // If all parents are already cached, proceed directly to the MERGE + // phase below (frame.phase is already MERGE, frame is at stack top). + // Otherwise, loop back to process the newly-pushed parent frames first. + if (!allParentsCached) { + continue; + } } - parentLinearizations.push([pid, ...pLin]); - } - // Add the direct parents list as the final sequence - const sequences = [...parentLinearizations, [...directParents]]; - const result: string[] = []; + // ── MERGE phase ─────────────────────────────────────────────── + // directParents is guaranteed non-empty here — the ENTER phase already + // handles the empty-parents case and pops the frame before switching + // to MERGE. + stack.pop(); - while (sequences.some((s) => s.length > 0)) { - // Find a good head: one that doesn't appear in the tail of any other sequence - let head: string | null = null; - for (const seq of sequences) { - if (seq.length === 0) continue; - const candidate = seq[0]; - const inTail = sequences.some( - (other) => other.length > 1 && other.indexOf(candidate, 1) !== -1, - ); - if (!inTail) { - head = candidate; + const directParents = parentMap.get(frame.id)!; + + // Build parent linearizations from cache + const parentLinearizations: string[][] = []; + let failed = false; + for (const pid of directParents) { + const pLin = cache.get(pid); + if (pLin === undefined) { + // Should not happen if phases are ordered correctly, but guard anyway + failed = true; break; } + if (pLin === null) { + // Parent linearization failed (cycle or inconsistent) + failed = true; + break; + } + parentLinearizations.push([pid, ...pLin]); } - if (head === null) { - // Inconsistent hierarchy - visiting.delete(classId); - cache.set(classId, null); - return null; + if (failed) { + visiting.delete(frame.id); + cache.set(frame.id, null); + continue; } - result.push(head); + // Add the direct parents list as the final sequence + const sequences = [...parentLinearizations, [...directParents]]; + const result: string[] = []; - // Remove the chosen head from all sequences - for (const seq of sequences) { - if (seq.length > 0 && seq[0] === head) { - seq.shift(); + let inconsistent = false; + while (sequences.some((s) => s.length > 0)) { + // Find a good head: one that doesn't appear in the tail of any other sequence + let head: string | null = null; + for (const seq of sequences) { + if (seq.length === 0) continue; + const candidate = seq[0]; + const inTail = sequences.some( + (other) => other.length > 1 && other.indexOf(candidate, 1) !== -1, + ); + if (!inTail) { + head = candidate; + break; + } + } + + if (head === null) { + inconsistent = true; + break; + } + + result.push(head); + + // Remove the chosen head from all sequences + for (const seq of sequences) { + if (seq.length > 0 && seq[0] === head) { + seq.shift(); + } } } + + visiting.delete(frame.id); + cache.set(frame.id, inconsistent ? null : result); } - visiting.delete(classId); - cache.set(classId, result); - return result; + return cache.get(classId) ?? null; } // `gatherAncestors` is exported so mro-processor.ts can reuse the same diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 32dd32aec..0b0cdc499 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -762,7 +762,9 @@ const processBatch = ( } } } else { - regularFiles.push(...langFiles); + // Manual loop (not spread) — `push(...arr)` blows the stack on very + // large arrays when langFiles has tens of thousands of entries. + for (const f of langFiles) regularFiles.push(f); } // Process regular files for this language @@ -2194,7 +2196,7 @@ const processFileGroup = ( // Extract framework routes via provider detection (e.g., Laravel routes.php) if (provider.isRouteFile?.(file.path)) { const extractedRoutes = extractLaravelRoutes(tree, file.path); - result.routes.push(...extractedRoutes); + for (const r of extractedRoutes) result.routes.push(r); } // Extract ORM queries (Prisma, Supabase) diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index 067625edc..1c9fb32c3 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -1,5 +1,5 @@ import fs from 'fs/promises'; -import { createReadStream } from 'fs'; +import { createReadStream, createWriteStream } from 'fs'; import { createInterface } from 'readline'; import path from 'path'; import lbug from '@ladybugdb/core'; @@ -247,9 +247,12 @@ export const loadGraphToLbug = async ( } // Bulk COPY relationships — split by FROM→TO label pair (LadybugDB requires it) - // Stream-read the relation CSV line by line to avoid exceeding V8 max string length + // Stream-read the relation CSV line by line and write directly to per-pair + // temp files on disk. This avoids accumulating potentially millions of CSV + // lines in memory which could exceed V8 Map or array limits on large repos. let relHeader = ''; - const relsByPair = new Map(); + const relsByPairMeta = new Map(); + const pairWriteStreams = new Map(); let skippedRels = 0; let totalValidRels = 0; @@ -278,37 +281,60 @@ export const loadGraphToLbug = async ( return; } const pairKey = `${fromLabel}|${toLabel}`; - let list = relsByPair.get(pairKey); - if (!list) { - list = []; - relsByPair.set(pairKey, list); + let ws = pairWriteStreams.get(pairKey); + if (!ws) { + const pairCsvPath = path.join(csvDir, `rel_${fromLabel}_${toLabel}.csv`); + ws = createWriteStream(pairCsvPath, 'utf-8'); + ws.write(relHeader + '\n'); + pairWriteStreams.set(pairKey, ws); + relsByPairMeta.set(pairKey, { csvPath: pairCsvPath, rows: 0 }); } - list.push(line); + const ok = ws.write(line + '\n'); + relsByPairMeta.get(pairKey)!.rows++; totalValidRels++; + // Handle backpressure: pause reading when the write buffer is full, + // resume when the stream drains. Prevents unbounded memory growth + // on repos with millions of relationships. + if (!ok) { + rl.pause(); + ws.once('drain', () => rl.resume()); + } }); rl.on('close', resolve); - rl.on('error', reject); + rl.on('error', (err) => { + // Destroy all open write streams to avoid resource leaks + for (const ws of pairWriteStreams.values()) ws.destroy(); + reject(err); + }); }); + // Close all per-pair write streams before COPY + await Promise.all( + Array.from(pairWriteStreams.values()).map( + (ws) => + new Promise((resolve, reject) => + ws.end((err: Error | undefined) => (err ? reject(err) : resolve())), + ), + ), + ); + const insertedRels = totalValidRels; const warnings: string[] = []; if (insertedRels > 0) { - log(`Loading edges: ${insertedRels.toLocaleString()} across ${relsByPair.size} types`); + log(`Loading edges: ${insertedRels.toLocaleString()} across ${relsByPairMeta.size} types`); let pairIdx = 0; let failedPairEdges = 0; - const failedPairLines: string[] = []; + const failedPairCsvPaths = new Set(); - for (const [pairKey, lines] of relsByPair) { + for (const [pairKey, { csvPath: pairCsvPath, rows }] of relsByPairMeta) { pairIdx++; const [fromLabel, toLabel] = pairKey.split('|'); - const pairCsvPath = path.join(csvDir, `rel_${fromLabel}_${toLabel}.csv`); - await fs.writeFile(pairCsvPath, relHeader + '\n' + lines.join('\n'), 'utf-8'); const normalizedPath = normalizeCopyPath(pairCsvPath); const copyQuery = `COPY ${REL_TABLE_NAME} FROM "${normalizedPath}" (from="${fromLabel}", to="${toLabel}", HEADER=true, ESCAPE='"', DELIM=',', QUOTE='"', PARALLEL=false, auto_detect=false)`; - if (pairIdx % 5 === 0 || lines.length > 1000) { - log(`Loading edges: ${pairIdx}/${relsByPair.size} types (${fromLabel} -> ${toLabel})`); + if (pairIdx % 5 === 0 || rows > 1000) { + log(`Loading edges: ${pairIdx}/${relsByPairMeta.size} types (${fromLabel} -> ${toLabel})`); } try { @@ -322,21 +348,39 @@ export const loadGraphToLbug = async ( await conn.query(retryQuery); } catch (retryErr) { const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr); - warnings.push( - `${fromLabel}->${toLabel} (${lines.length} edges): ${retryMsg.slice(0, 80)}`, - ); - failedPairEdges += lines.length; - failedPairLines.push(...lines); + warnings.push(`${fromLabel}->${toLabel} (${rows} edges): ${retryMsg.slice(0, 80)}`); + failedPairEdges += rows; + failedPairCsvPaths.add(pairCsvPath); } } - try { - await fs.unlink(pairCsvPath); - } catch {} + // Only delete if not in failedPairCsvPaths (needed for fallback) + if (!failedPairCsvPaths.has(pairCsvPath)) { + try { + await fs.unlink(pairCsvPath); + } catch {} + } } - if (failedPairLines.length > 0) { + if (failedPairCsvPaths.size > 0) { log(`Inserting ${failedPairEdges} edges individually (missing schema pairs)`); - await fallbackRelationshipInserts([relHeader, ...failedPairLines], validTables, getNodeLabel); + // Read failed pair files and merge for fallback inserts + const allLines: string[] = [relHeader]; + for (const failedPath of failedPairCsvPaths) { + try { + const content = await fs.readFile(failedPath, 'utf-8'); + const lines = content.split('\n'); + // Skip header line (first) and empty lines + for (let i = 1; i < lines.length; i++) { + if (lines[i].trim()) allLines.push(lines[i]); + } + } catch {} + try { + await fs.unlink(failedPath); + } catch {} + } + if (allLines.length > 1) { + await fallbackRelationshipInserts(allLines, validTables, getNodeLabel); + } } } diff --git a/gitnexus/test/unit/mro-processor.test.ts b/gitnexus/test/unit/mro-processor.test.ts index 51b2791e0..465face1d 100644 --- a/gitnexus/test/unit/mro-processor.test.ts +++ b/gitnexus/test/unit/mro-processor.test.ts @@ -539,6 +539,30 @@ describe('computeMRO', () => { const result = computeMRO(graph); expect(result).toBeDefined(); }); + + it('handles very deep single-inheritance chain without stack overflow', () => { + // Chain of 2000 classes: C0 ← C1 ← C2 ← ... ← C1999 + // The iterative c3Linearize handles this without blowing the stack. + // (The recursive version overflows at ~1K–5K levels depending on platform.) + const graph = createKnowledgeGraph(); + const DEPTH = 2000; + for (let i = 0; i < DEPTH; i++) { + addClass(graph, `C${i}`, 'python'); + } + for (let i = 1; i < DEPTH; i++) { + addExtends(graph, `C${i}`, `C${i - 1}`); + } + // Add a method on the root so MRO produces an entry + addMethod(graph, 'C0', 'baseMethod'); + + const result = computeMRO(graph); + expect(result).toBeDefined(); + // The deepest class should have all ancestors in its MRO + const deepest = result.entries.find((e) => e.className === `C${DEPTH - 1}`); + if (deepest) { + expect(deepest.mro.length).toBe(DEPTH - 1); + } + }, 15_000); }); // ---- METHOD_IMPLEMENTS edges ----------------------------------------------- From 26ff700e37fe523a17fc8a91e72ec04a9c66164f Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:31:05 +0100 Subject: [PATCH 29/67] refactor(pipeline): DAG-based phase architecture + container-logic extraction to LanguageProvider (#809) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * refactor: move language-specific container node logic into LanguageProvider - Add resolveEnclosingOwner hook to LanguageProviderConfig - Add staticOwnerTypes to MethodExtractionConfig - Implement Ruby resolveEnclosingOwner (singleton_class → class/module) - Replace hardcoded STATIC_OWNER_TYPES with config.staticOwnerTypes - Move Ruby static types to rubyMethodConfig - Move Kotlin static types to kotlinMethodConfig - Remove Ruby singleton_class branch from findEnclosingClassInfo - Collapse seqFindEnclosingClassNode/seqFindRawEnclosingContainerNode into single provider-aware seqFindEnclosingOwnerNode - Update worker path to pass provider.resolveEnclosingOwner Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/bc9f9d4d-f749-4872-9ff2-17fc86e08787 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * test: add regression tests for config-driven staticOwnerTypes and resolveEnclosingOwner hook Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/bc9f9d4d-f749-4872-9ff2-17fc86e08787 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * refactor: implement DAG-based pipeline architecture with phase extraction Restructure the ingestion pipeline from a ~1800-line monolithic orchestrator into a DAG (Directed Acyclic Graph) of named phases with explicit dependencies. New files under pipeline-phases/: - types.ts: PipelinePhase, PipelineContext, PhaseResult contracts - runner.ts: DAG runner with topological sort validation - scan.ts, structure.ts, markdown.ts, cobol.ts: early phases - parse.ts + parse-impl.ts: chunked parse + resolve (the core) - routes.ts, tools.ts, orm.ts: post-parse enrichment phases - cross-file.ts + cross-file-impl.ts: cross-file binding propagation - mro.ts, communities.ts, processes.ts: graph analysis phases - index.ts: barrel export pipeline.ts reduced from ~1960 lines to ~184 lines: - DAG phase array declaration - runPipelineFromRepo as thin orchestrator - topologicalLevelSort retained for backward compat Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/136bf9c3-2f4f-449b-9fff-001332c8371c * test: add DAG runner unit tests, update ARCHITECTURE.md with phase DAG docs Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/136bf9c3-2f4f-449b-9fff-001332c8371c * fix: address code review - pass resolutionContext through parse output, fix worker URL path Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/136bf9c3-2f4f-449b-9fff-001332c8371c * fix: declare transitive parse dependency explicitly in mro/communities/processes phases Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/136bf9c3-2f4f-449b-9fff-001332c8371c * refactor: improve pipeline-phases clean code and folder structure - Extract synthesizeWildcardImportBindings to wildcard-synthesis.ts - Extract extractORMQueriesInline to orm-extraction.ts - Create shared constants.ts for AST_CACHE_CAP - Fix inline type import in orm.ts (use proper top-level import) - Add comprehensive JSDoc to getPhaseOutput explaining type safety - Move isDev to module level in cross-file.ts (consistency) - Improve module-level documentation across files - Organize barrel exports in index.ts with section comments Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/2bd6d4aa-6271-4009-8dd2-332ea8ec73ab Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * address review feedback: fix circular dep, allFetchCalls mutation, progress bugs, remove DAG naming, extract isDev, fix _item naming, fix O(n²) line calc Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/6cf53c9b-d55d-4c6f-bf3d-7bfb82d512b6 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * improve JSDoc on lineNumberAtOffset binary search Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/6cf53c9b-d55d-4c6f-bf3d-7bfb82d512b6 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * address review: filter deps in runner, move totalFiles to ctx, fix cycle JSDoc, centralize isDev, remove DAG naming Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b388424f-b939-4a94-97de-3855f9465564 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix doc consistency in graph-sort.ts module-level and function-level JSDoc Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b388424f-b939-4a94-97de-3855f9465564 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix(pipeline): wrap phase errors with phase name and emit terminal error progress event Restores phase diagnostics at CLI/MCP boundary. runPipeline now wraps phase.execute() in try/catch and rethrows with 'Phase failed: ...' preserving the original via { cause }. Also emits a terminal { phase: 'error' } progress event so subscribers see the failure before the rejection propagates. Handler errors during error reporting are swallowed to keep the original cause authoritative. Plan: docs/plans/2026-04-13-001-fix-pipeline-dag-refactor-review-findings-plan.md (U1) * fix(pipeline): move bindingAccumulator dispose into crossFile try/finally; make single-use crossFile.execute() now wraps its body in try/finally so the accumulator is released on both the happy path and when runCrossFileBindingPropagation throws. Dev-mode telemetry stays inside the try block before dispose (all three counters return 0 after dispose clears internal maps). BindingAccumulator becomes single-use: appendFile after dispose now throws 'BindingAccumulator: use after dispose' instead of silently re-animating via the old _disposed auto-clear. Docs updated; the only production construction site (parse-impl) always creates a fresh instance per run, so no caller relied on the re-use contract. Residual risk documented in crossFile module JSDoc: a future phase inserted between parse and crossFile that throws would still leak the accumulator. Any such phase must manage accumulator lifetime explicitly. Plan: docs/plans/2026-04-13-001-fix-pipeline-dag-refactor-review-findings-plan.md (U2) * docs(pipeline): explain why importCtx teardown is safe before crossFile Investigation (plan U3) confirms: `importCtx` (ImportResolutionContext) is a scratch workspace with no downstream consumer after parse. `resolutionContext` (returned to crossFile) is a distinct object that owns importMap / namedImportMap / packageMap / moduleAliasMap / model, and never closes over importCtx. cross-file-impl consumes only that ctx via processCalls. The two confusingly-similar "context" names were the root of the adversarial reviewer's concern — comment locks in the invariant so the next reader sees it. No behavioral change. Plan: docs/plans/2026-04-13-001-fix-pipeline-dag-refactor-review-findings-plan.md (U3) * refactor(pipeline): remove ctx.totalFiles side-channel; promote to ParseOutput totalFiles was a hidden mutable field on PipelineContext written by parse and read by mro/communities/processes — five reviewers flagged this as a violation of the immutable-context invariant. Removed from PipelineContext, which is now fully readonly, and made the implicit temporal dep explicit: mro/communities/processes now declare 'parse' as a dep and read totalFiles via getPhaseOutput(...). No behavior change. Topo-sort unchanged because parse was already a transitive dep through crossFile. Plan: docs/plans/2026-04-13-001-fix-pipeline-dag-refactor-review-findings-plan.md (U4) * feat(method-extractor): runtime staticOwnerTypes guard at factory chokepoint createMethodExtractor now rejects MethodExtractionConfigs that list companion_object / singleton_class / object_declaration in typeDeclarationNodes but omit the matching entry from staticOwnerTypes. Fails loudly at provider construction time instead of producing silent isStatic=false on the 50000th file analyzed. Opt-out convention preserved: an explicit `new Set()` (empty Set) signals intentional exclusion and passes the guard (memory obs #30588). All 13 existing language configs pass the guard; the new negative test fails without it. Test-first. Plan: docs/plans/2026-04-13-001-fix-pipeline-dag-refactor-review-findings-plan.md (U5) * fix(pipeline): wrap sequential-fallback in try/finally so cleanup survives throws The sequential-fallback block in runChunkedParseAndResolve now runs inside a try/finally that guarantees astCache.clear(), accumulator finalize, and enrichExportedTypeMap execute even if readFileContents or processCalls throws mid-fallback. Cleanup failures are caught inside the finally so they can't mask the original error. Accumulator disposal ownership remains with crossFile (U2) — U6 only adds astCache cleanup and preserves finalize ordering on the error path. Plan: docs/plans/2026-04-13-001-fix-pipeline-dag-refactor-review-findings-plan.md (U6) * test(pipeline): direct unit coverage for wildcard-synthesis and cross-file-impl Both modules previously had zero direct unit coverage — branches were exercised only through integration tests' happy paths. wildcard-synthesis.test.ts covers: Go graph-IMPORTS fallback, Python moduleAliasMap build, MAX_SYNTHETIC_BINDINGS_PER_FILE cap, dedup against existing namedImportMap entries, and empty-exportedSymbols early return. cross-file-impl.test.ts covers: gapRatio below threshold no-op, MAX_CROSS_FILE_REPROCESS cap, graph-only exportedTypeMap fallback, and empty namedImportMap short-circuit. Tests assert current behavior — any future regression flips them. Plan: docs/plans/2026-04-13-001-fix-pipeline-dag-refactor-review-findings-plan.md (U7) * test(pipeline): golden-file graph-parity regression guard on mini-repo fixture Pins the current post-P1/P2 graph output (57 symbols, 92 relationships, 4 processes, deterministic edge digest) so future silent refactors cannot drift behavior unnoticed. If any count changes or any edge rewires, the test fails with a readable diff listing what changed and a copy-pasteable UPDATE_GOLDEN=1 regen command. Edge digest keyed by symbolic (label, name, filePath) triples rather than raw generateId output — stays meaningful across id-encoding refactors while still catching real semantic rewiring. Plan: docs/plans/2026-04-13-001-fix-pipeline-dag-refactor-review-findings-plan.md (U8) * fix(pipeline): minimal cycle reporting + resolveEnclosingOwner loop safeguards U9: runner cycle detection now reports only the SCC members via DFS back-edge trace ('Cycle detected: A -> B -> C -> A') rather than everything with inDegree > 0 (which mixed cycle members with blocked dependents). Also emits the 'error' progress event for graph- validation failures, symmetric with U1's runtime-error path. U16: findEnclosingClassInfo now defends against language-provider hooks that return non-container nodes — visitedContainers Set breaks repeat-visit loops, MAX_ENCLOSING_WALK_ITERATIONS is belt-and-braces. Documented the hook contract invariant so future provider authors know the walk-continues-upward expectation. Plan: docs/plans/2026-04-13-001-fix-pipeline-dag-refactor-review-findings-plan.md (U9, U16) * refactor(pipeline): type hygiene, dead code cleanup, shared allPathSet, graph-sort naming Bundles plan units U10, U11, U12, U14, U15: U10 — Type hygiene: readonly ParseOutput arrays (allExtractedRoutes, allDecoratorRoutes, allToolDefs, allORMQueries, allPaths); removed redundant 'as string[] | undefined' cast in routes.ts and 'as URL' in parse-impl.ts; WorkerPool is now 'import type'. Readonly contract propagated into processORMQueries (only iterates). U11 — Dead code & shims: deleted constants.ts shim (AST_CACHE_CAP inlined into its sole real consumer cross-file-impl.ts; isDev consumers now import directly from ../utils/env.js). Removed internal utility re-exports from pipeline-phases/index.ts (no external consumers). Removed topologicalLevelSort re-export from pipeline.ts; updated topological-sort.test.ts to import from the canonical utils/graph-sort.js. Stripped 'Phase 3+4:' stale JSDoc from parse-impl.ts. U12 — Perf: StructureOutput now carries allPathSet (ReadonlySet) built once; cobol, markdown, and cross-file-impl consume the shared set instead of allocating their own. Parse forwards it via ParseOutput.allPathSet; processCobol/processMarkdown widened to ReadonlySet. U14 — graph-sort.ts: renamed local 'inDegree' to 'pendingImportsPerFile' with expanded JSDoc explaining the reverse- graph Kahn's formulation and warning future maintainers not to 'correct' it to standard in-degree semantics. Added self-edge test. U15 — Unconditional worker-fallback logging: removed isDev guard on the worker-pool-creation-failure console.warn so operators can diagnose perf degradations in production. No behavior change. U8 golden-file test confirms pipeline output is byte-identical. Plan: docs/plans/2026-04-13-001-fix-pipeline-dag-refactor-review-findings-plan.md (U10, U11, U12, U14, U15) * docs: fix ARCHITECTURE.md table integrity; bump AGENTS.md/CLAUDE.md to 1.3.0 U13 — documentation fixes: ARCHITECTURE.md: the prior insertion of the 'Pipeline Phase DAG' section orphaned 7 rows from the 'Where to change what' header. Moved those 7 rows back up under their header so the table reads contiguously; DAG section now follows the completed table. AGENTS.md + CLAUDE.md: bumped version 1.2.0 -> 1.3.0, updated Last reviewed to 2026-04-13, added matching Changelog row documenting the GitNexus index stats refresh after the DAG refactor. Stat bumps (symbols/relationships/execution flows) that were sitting uncommitted in the working tree are now landed under a proper changelog entry per each file's own documented schema. Plan: docs/plans/2026-04-13-001-fix-pipeline-dag-refactor-review-findings-plan.md (U13) * refactor(pipeline): drop spurious parse deps, true-readonly ParseOutput.exportedTypeMap, skip redundant wildcard synth - mro/communities/processes: switch redundant `parse` dep to `structure` — totalFiles originates in structure, so depending on parse for it was a spurious data dep that obscured the real DAG. - ParseOutput.exportedTypeMap: typed as truly ReadonlyMap<...,ReadonlyMap>>; graph→exports enrichment moved into parse-impl so the snapshot is fully populated at parse return. crossFile builds its own local mutable working copy for per-file re-resolution writes — no cast at the boundary. - parse-impl: hasSynthesized flag guards the unconditional final synthesizeWildcardImportBindings call when per-chunk/fallback synthesis already ran (graph-global + idempotent across chunks). - cross-file-impl: documented the intentional `phase: 'parsing'` progress label so telemetry bucketing stays consistent with the parse phase. - cross-file-impl test: replaced the now-moved fallback-enrichment assertion with a stronger one — crossFile must not mutate the parse-supplied map. Addresses PR #809 review pass 5 carry-overs. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> Co-authored-by: Gergo Magyar --- AGENTS.md | 7 +- ARCHITECTURE.md | 62 +- CLAUDE.md | 7 +- .../src/core/ingestion/binding-accumulator.ts | 54 +- .../src/core/ingestion/cobol-processor.ts | 2 +- .../src/core/ingestion/import-processor.ts | 3 +- .../src/core/ingestion/language-config.ts | 2 +- .../src/core/ingestion/language-provider.ts | 10 + gitnexus/src/core/ingestion/languages/ruby.ts | 15 + .../src/core/ingestion/markdown-processor.ts | 2 +- .../method-extractors/configs/jvm.ts | 1 + .../method-extractors/configs/ruby.ts | 1 + .../ingestion/method-extractors/generic.ts | 58 +- gitnexus/src/core/ingestion/method-types.ts | 4 + .../core/ingestion/model/semantic-model.ts | 2 +- .../src/core/ingestion/model/symbol-table.ts | 14 +- gitnexus/src/core/ingestion/mro-processor.ts | 2 +- .../src/core/ingestion/parsing-processor.ts | 79 +- .../core/ingestion/pipeline-phases/cobol.ts | 73 + .../ingestion/pipeline-phases/communities.ts | 82 + .../pipeline-phases/cross-file-impl.ts | 214 ++ .../ingestion/pipeline-phases/cross-file.ts | 91 + .../core/ingestion/pipeline-phases/index.ts | 27 + .../ingestion/pipeline-phases/markdown.ts | 58 + .../src/core/ingestion/pipeline-phases/mro.ts | 57 + .../pipeline-phases/orm-extraction.ts | 106 + .../src/core/ingestion/pipeline-phases/orm.ts | 100 + .../ingestion/pipeline-phases/parse-impl.ts | 592 +++++ .../core/ingestion/pipeline-phases/parse.ts | 92 + .../ingestion/pipeline-phases/processes.ts | 171 ++ .../core/ingestion/pipeline-phases/routes.ts | 301 +++ .../core/ingestion/pipeline-phases/runner.ts | 228 ++ .../core/ingestion/pipeline-phases/scan.ts | 60 + .../ingestion/pipeline-phases/structure.ts | 62 + .../core/ingestion/pipeline-phases/tools.ts | 105 + .../core/ingestion/pipeline-phases/types.ts | 101 + .../pipeline-phases/wildcard-synthesis.ts | 195 ++ gitnexus/src/core/ingestion/pipeline.ts | 2016 +---------------- .../src/core/ingestion/process-processor.ts | 3 +- .../src/core/ingestion/utils/ast-helpers.ts | 71 +- gitnexus/src/core/ingestion/utils/env.ts | 11 + .../src/core/ingestion/utils/graph-sort.ts | 109 + .../core/ingestion/workers/parse-worker.ts | 22 +- .../mini-repo/expected-graph.json | 29 + .../integration/pipeline-graph-golden.test.ts | 194 ++ .../test/unit/binding-accumulator.test.ts | 18 +- gitnexus/test/unit/cross-file-impl.test.ts | 206 ++ gitnexus/test/unit/cross-file.test.ts | 100 + gitnexus/test/unit/method-extraction.test.ts | 196 ++ .../test/unit/parse-impl-fallback.test.ts | 204 ++ gitnexus/test/unit/pipeline-runner.test.ts | 417 ++++ .../test/unit/resolve-enclosing-owner.test.ts | 286 +++ gitnexus/test/unit/symbol-resolver.test.ts | 4 +- gitnexus/test/unit/symbol-table.test.ts | 2 +- gitnexus/test/unit/topological-sort.test.ts | 12 +- gitnexus/test/unit/wildcard-synthesis.test.ts | 157 ++ 56 files changed, 5045 insertions(+), 2052 deletions(-) create mode 100644 gitnexus/src/core/ingestion/pipeline-phases/cobol.ts create mode 100644 gitnexus/src/core/ingestion/pipeline-phases/communities.ts create mode 100644 gitnexus/src/core/ingestion/pipeline-phases/cross-file-impl.ts create mode 100644 gitnexus/src/core/ingestion/pipeline-phases/cross-file.ts create mode 100644 gitnexus/src/core/ingestion/pipeline-phases/index.ts create mode 100644 gitnexus/src/core/ingestion/pipeline-phases/markdown.ts create mode 100644 gitnexus/src/core/ingestion/pipeline-phases/mro.ts create mode 100644 gitnexus/src/core/ingestion/pipeline-phases/orm-extraction.ts create mode 100644 gitnexus/src/core/ingestion/pipeline-phases/orm.ts create mode 100644 gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts create mode 100644 gitnexus/src/core/ingestion/pipeline-phases/parse.ts create mode 100644 gitnexus/src/core/ingestion/pipeline-phases/processes.ts create mode 100644 gitnexus/src/core/ingestion/pipeline-phases/routes.ts create mode 100644 gitnexus/src/core/ingestion/pipeline-phases/runner.ts create mode 100644 gitnexus/src/core/ingestion/pipeline-phases/scan.ts create mode 100644 gitnexus/src/core/ingestion/pipeline-phases/structure.ts create mode 100644 gitnexus/src/core/ingestion/pipeline-phases/tools.ts create mode 100644 gitnexus/src/core/ingestion/pipeline-phases/types.ts create mode 100644 gitnexus/src/core/ingestion/pipeline-phases/wildcard-synthesis.ts create mode 100644 gitnexus/src/core/ingestion/utils/env.ts create mode 100644 gitnexus/src/core/ingestion/utils/graph-sort.ts create mode 100644 gitnexus/test/fixtures/pipeline-golden/mini-repo/expected-graph.json create mode 100644 gitnexus/test/integration/pipeline-graph-golden.test.ts create mode 100644 gitnexus/test/unit/cross-file-impl.test.ts create mode 100644 gitnexus/test/unit/cross-file.test.ts create mode 100644 gitnexus/test/unit/parse-impl-fallback.test.ts create mode 100644 gitnexus/test/unit/pipeline-runner.test.ts create mode 100644 gitnexus/test/unit/resolve-enclosing-owner.test.ts create mode 100644 gitnexus/test/unit/wildcard-synthesis.test.ts diff --git a/AGENTS.md b/AGENTS.md index c9e2158c0..651657b02 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,10 +1,10 @@ - + -Last reviewed: 2026-03-24 +Last reviewed: 2026-04-13 **Project:** GitNexus · **Environment:** dev · **Maintainer:** repository maintainers (see GitHub) @@ -54,6 +54,7 @@ Generic “core standards” playbooks are often long and stack-specific. For th | Date | Version | Change | |------|---------|--------| +| 2026-04-13 | 1.3.0 | Updated GitNexus index stats after DAG refactor. | | 2026-03-24 | 1.2.0 | Fixed gitnexus:start block duplication (was inlined in Reference Docs bullet). | | 2026-03-23 | 1.1.0 | Updated agent instructions (sections, references, Cursor layout). | | 2026-03-22 | 1.0.0 | Added structured agent header and changelog. | @@ -63,7 +64,7 @@ Generic “core standards” playbooks are often long and stack-specific. For th # GitNexus — Code Intelligence -This project is indexed by GitNexus as **GitNexus** (3975 symbols, 10043 relationships, 245 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **GitNexus** (4325 symbols, 10556 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b153ff679..ac4f46aef 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -16,7 +16,7 @@ This repository is a **monorepo** with two main products: the **CLI / MCP packag 1. **Ingestion** (`gitnexus analyze`) - Entry: `gitnexus/src/cli/analyze.ts` → `runPipelineFromRepo` in `gitnexus/src/core/ingestion/pipeline.ts`. - - Walks the git working tree, parses supported languages via **Tree-sitter**, resolves imports/calls/inheritance, detects **communities** and **processes** (execution flows), and builds an in-memory **knowledge graph** (`gitnexus/src/core/graph/`). + - The pipeline is structured as a **DAG (Directed Acyclic Graph)** of named phases (see [Pipeline Phase DAG](#pipeline-phase-dag) below). - Output is loaded into **LadybugDB** under **`.gitnexus/`** at the repo root (`lbug/`, `meta.json`, etc.). Optional **FTS** indexes and **embeddings** attach to the same store. - The repo is registered in **`~/.gitnexus/registry.json`** so MCP can find it from any working directory. @@ -49,7 +49,7 @@ This repository is a **monorepo** with two main products: the **CLI / MCP packag | If you are changing… | Start in… | |----------------------|-----------| | CLI commands / flags | `gitnexus/src/cli/` (`index.ts`, per-command modules). | -| Parsing or graph construction | `gitnexus/src/core/ingestion/` (pipeline, processors, resolvers, type-extractors). | +| Parsing or graph construction | `gitnexus/src/core/ingestion/pipeline-phases/` (individual phase files), `pipeline.ts` (orchestrator). | | Graph schema / DB access | `gitnexus/src/core/lbug/` (`schema.ts`, `lbug-adapter.ts`), `gitnexus/src/mcp/core/lbug-adapter.ts` if MCP-specific. | | MCP protocol, tools, resources | `gitnexus/src/mcp/server.ts`, `tools.ts`, `resources.ts`. | | Search ranking | `gitnexus/src/core/search/` (BM25, hybrid fusion). | @@ -58,6 +58,64 @@ This repository is a **monorepo** with two main products: the **CLI / MCP packag | Web UI behavior | `gitnexus-web/src/` (components, workers, graph client). | | CI | `.github/workflows/*.yml`, `.github/actions/setup-gitnexus/`. | +## Pipeline Phase DAG + +The ingestion pipeline is a DAG of named phases. Each phase is defined in its own file under `gitnexus/src/core/ingestion/pipeline-phases/` with explicit dependencies, typed inputs, and typed outputs. + +``` +scan → structure → [markdown, cobol] → parse → [routes, tools, orm] + → crossFile → mro → communities → processes +``` + +### Phase files + +| Phase | File | Dependencies | What it does | +|-------|------|-------------|--------------| +| `scan` | `scan.ts` | (root) | Walk repo filesystem, collect paths + sizes | +| `structure` | `structure.ts` | `scan` | Build File/Folder nodes + CONTAINS edges | +| `markdown` | `markdown.ts` | `structure` | Extract headings and cross-links from .md/.mdx | +| `cobol` | `cobol.ts` | `structure` | Regex-based COBOL/JCL extraction | +| `parse` | `parse.ts` + `parse-impl.ts` | `structure`, `markdown`, `cobol` | Chunked tree-sitter parse, import/call/heritage resolution | +| `routes` | `routes.ts` | `parse` | Route registry (Next.js, Expo, PHP, decorator-based) | +| `tools` | `tools.ts` | `parse` | MCP/RPC tool detection | +| `orm` | `orm.ts` | `parse` | Prisma/Supabase ORM query edges | +| `crossFile` | `cross-file.ts` + `cross-file-impl.ts` | `parse`, `routes`, `tools`, `orm` | Cross-file type propagation in topological order | +| `mro` | `mro.ts` | `crossFile` | Method Resolution Order, METHOD_OVERRIDES edges | +| `communities` | `communities.ts` | `mro` | Leiden community detection | +| `processes` | `processes.ts` | `communities`, `routes`, `tools` | Execution flow detection, Route/Tool → Process links | + +### How to add a new phase + +1. Create a new file in `pipeline-phases/` (e.g. `my-phase.ts`) +2. Define a `PipelinePhase` object with `name`, `deps`, and `execute(ctx, deps)` +3. Export it from `pipeline-phases/index.ts` +4. Add it to the `buildPhaseList()` function in `pipeline.ts` + +```typescript +// pipeline-phases/my-phase.ts +import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js'; +import { getPhaseOutput } from './types.js'; +import type { ParseOutput } from './parse.js'; + +export interface MyPhaseOutput { /* ... */ } + +export const myPhase: PipelinePhase = { + name: 'myPhase', + deps: ['parse'], // runs after parse completes + async execute(ctx, deps) { + const { allPaths } = getPhaseOutput(deps, 'parse'); + // ... do work, write to ctx.graph ... + return { /* typed output */ }; + }, +}; +``` + +### DAG runner + +The runner (`pipeline-phases/runner.ts`) validates the DAG at startup (detects cycles and missing deps via topological sort), then executes phases in dependency order. Each phase receives: +- `ctx: PipelineContext` — shared graph, repoPath, progress callback +- `deps: Map` — outputs from all upstream phases + ## Known limitations ### Overloaded method resolution diff --git a/CLAUDE.md b/CLAUDE.md index fc0abde9a..8b6f74ab8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,10 +1,10 @@ - + -Last reviewed: 2026-03-24 +Last reviewed: 2026-04-13 **Project:** GitNexus · **Environment:** dev · **Maintainer:** repository maintainers (see GitHub) @@ -41,6 +41,7 @@ If always-on instructions grow, load deep conventions via conditional reads (e.g | Date | Version | Change | |------|---------|--------| +| 2026-04-13 | 1.3.0 | Updated GitNexus index stats after DAG refactor. | | 2026-03-24 | 1.2.0 | Removed duplicated gitnexus:start block and scope table; replaced with pointers to AGENTS.md. | | 2026-03-23 | 1.1.0 | Updated agent instructions to match AGENTS.md. | | 2026-03-22 | 1.0.0 | Added structured header and changelog. | @@ -52,7 +53,7 @@ If always-on instructions grow, load deep conventions via conditional reads (e.g GitNexus MCP rules are in the ` # GitNexus — Code Intelligence -This project is indexed by GitNexus as **GitNexus** (3975 symbols, 10043 relationships, 245 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **GitNexus** (4325 symbols, 10556 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/gitnexus/src/core/ingestion/binding-accumulator.ts b/gitnexus/src/core/ingestion/binding-accumulator.ts index 0f7199b2b..adea3a202 100644 --- a/gitnexus/src/core/ingestion/binding-accumulator.ts +++ b/gitnexus/src/core/ingestion/binding-accumulator.ts @@ -36,9 +36,12 @@ * or (b) post-process worker-path entries through a follow-up resolution * pass after the main-thread `SymbolTable` is complete. * - * **Lifecycle contract**: `append → finalize → consume → dispose`. See - * `finalize()` and `dispose()` for the state machine. Disposal is - * orthogonal to finalization: either order is legal. + * **Lifecycle contract**: single-use — `append* → finalize → consume → dispose`. + * After `dispose()` the accumulator is permanently dead: any mutating call + * (`appendFile`) throws, and read methods return empty/undefined as if the + * accumulator had never been appended to. The instance is not recyclable; + * construct a new one for a new pipeline run. Finalization and disposal are + * orthogonal state dimensions and may be invoked in either order. */ export interface BindingEntry { @@ -176,17 +179,16 @@ export class BindingAccumulator { '[BindingAccumulator] appendFile after finalize — no further appends allowed', ); } + // Single-use lifecycle: once disposed, the accumulator is dead. A + // post-dispose append almost always indicates a missed wiring step + // (the consumer is reading state that was supposed to be released), + // so convert the silent use-after-dispose into a loud failure. + if (this._disposed) { + throw new Error('BindingAccumulator: use after dispose'); + } if (entries.length === 0) { return; } - // Contract consistency: if this accumulator was previously disposed - // without being finalized, `dispose()` is documented to leave it - // "behaving like a fresh one" for subsequent appends. Clear the - // `_disposed` flag here so the `disposed` getter tracks the actual - // live state, not a stale signal from the prior lifecycle cycle. - if (this._disposed) { - this._disposed = false; - } // Note on the file-scope-only invariant: // The accumulator does NOT reject function-scope entries at this // boundary. The narrowing contract is enforced by the two production @@ -259,28 +261,30 @@ export class BindingAccumulator { /** * Release the accumulator's heap footprint. Clears both internal storage - * maps and resets `_totalBindings` to zero. Idempotent and orthogonal to - * `finalize()` — calling `dispose()` does not change the finalized state. + * maps and resets `_totalBindings` to zero. Idempotent — calling twice + * is a no-op. Orthogonal to `finalize()` — calling `dispose()` does not + * change the finalized state. * - * Post-dispose contract: all read methods return empty/undefined state - * matching a never-appended-to accumulator. Specifically: + * **Single-use lifecycle.** This is a one-way terminal transition: the + * accumulator is not recyclable. Any subsequent `appendFile` call throws + * (`'BindingAccumulator: use after dispose'`), regardless of whether + * `finalize()` was called first. Post-dispose reads do not throw — + * they return empty/undefined state matching a never-appended-to + * accumulator: * - `fileCount === 0` * - `totalBindings === 0` * - `files()` yields an empty iterator * - `getFile(x)` returns `undefined` for all `x` * - `fileScopeEntries(x)` returns `[]` for all `x` + * - `fileScopeGet(x, y)` returns `undefined` for all `x, y` * - `estimateMemoryBytes()` returns `0` * - * If `dispose()` is called **before** `finalize()`, subsequent `appendFile` - * calls succeed — the accumulator behaves like a fresh one. If called - * **after** `finalize()`, subsequent `appendFile` calls throw the existing - * "finalized" error. - * - * Lifecycle note: the pipeline disposes the accumulator after both Phase 9 - * consumers (`processCallsFromExtracted`, `processAssignmentsFromExtracted`) - * and the ExportedTypeMap enrichment loop have completed, so the heap is - * released before Phase 14 (`runCrossFileBindingPropagation`) and - * `runGraphAnalysisPhases` begin their long-running work. + * Lifecycle note: the pipeline disposes the accumulator inside the + * `finally` of the `crossFile` phase, which is scheduled after every + * other accumulator consumer (Phase 9 call/assignment processing and + * the ExportedTypeMap enrichment loop). The dispose call therefore + * runs once, on both the happy path and the throw path of the + * crossFile phase. */ dispose(): void { this._allByFile.clear(); diff --git a/gitnexus/src/core/ingestion/cobol-processor.ts b/gitnexus/src/core/ingestion/cobol-processor.ts index 1174b302d..2e0551770 100644 --- a/gitnexus/src/core/ingestion/cobol-processor.ts +++ b/gitnexus/src/core/ingestion/cobol-processor.ts @@ -92,7 +92,7 @@ function isCopybook(filePath: string): boolean { export const processCobol = ( graph: KnowledgeGraph, files: CobolFile[], - allPathSet: Set, + allPathSet: ReadonlySet, ): CobolProcessResult => { const result: CobolProcessResult = { programs: 0, diff --git a/gitnexus/src/core/ingestion/import-processor.ts b/gitnexus/src/core/ingestion/import-processor.ts index 3dff47094..b08482716 100644 --- a/gitnexus/src/core/ingestion/import-processor.ts +++ b/gitnexus/src/core/ingestion/import-processor.ts @@ -24,8 +24,7 @@ import type { } from './import-resolvers/types.js'; import type { NamedBinding } from './named-bindings/types.js'; import type { SyntaxNode } from './utils/ast-helpers.js'; - -const isDev = process.env.NODE_ENV === 'development'; +import { isDev } from './utils/env.js'; // Type: Map> // Stores all files that a given file imports from diff --git a/gitnexus/src/core/ingestion/language-config.ts b/gitnexus/src/core/ingestion/language-config.ts index 2d32e3efe..682d7b190 100644 --- a/gitnexus/src/core/ingestion/language-config.ts +++ b/gitnexus/src/core/ingestion/language-config.ts @@ -2,7 +2,7 @@ import fs from 'fs/promises'; import path from 'path'; import type { ImportConfigs } from './import-resolvers/types.js'; -const isDev = process.env.NODE_ENV === 'development'; +import { isDev } from './utils/env.js'; // ============================================================================ // LANGUAGE-SPECIFIC CONFIG TYPES diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 141ba59af..fae696be6 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -89,6 +89,16 @@ interface LanguageProviderConfig { projectConfig: unknown, ) => void; + // ── Enclosing owner resolution ───────────────────────────────── + /** Resolve a container node during enclosing-owner tree walks. + * Called when a CLASS_CONTAINER_TYPES node is found while walking up. + * - Return a different SyntaxNode to remap the container (e.g., Ruby + * singleton_class → enclosing class/module). + * - Return null to skip this container and keep walking up. + * - Omit (undefined) to use the container node as-is (default). + * Default: undefined (no remapping). */ + readonly resolveEnclosingOwner?: (node: SyntaxNode) => SyntaxNode | null; + // ── Enclosing function resolution ─────────────────────────────── /** Resolve the enclosing function name + label from an AST ancestor node * that is NOT a standard FUNCTION_NODE_TYPE. For languages where the diff --git a/gitnexus/src/core/ingestion/languages/ruby.ts b/gitnexus/src/core/ingestion/languages/ruby.ts index edd85ea6c..00b566068 100644 --- a/gitnexus/src/core/ingestion/languages/ruby.ts +++ b/gitnexus/src/core/ingestion/languages/ruby.ts @@ -108,6 +108,21 @@ export const rubyProvider = defineLanguage({ importResolver: resolveRubyImport, callRouter: routeRubyCall, importSemantics: 'wildcard', + resolveEnclosingOwner(node) { + // Ruby singleton_class (class << self) should resolve to the enclosing + // class or module for owner/container resolution (HAS_METHOD edges, class IDs). + if (node.type === 'singleton_class') { + let ancestor = node.parent; + while (ancestor) { + if (ancestor.type === 'class' || ancestor.type === 'module') { + return ancestor; + } + ancestor = ancestor.parent; + } + return null; // no enclosing class/module — skip + } + return node; // use as-is for all other container types + }, fieldExtractor: createFieldExtractor(rubyFieldConfig), methodExtractor: createMethodExtractor({ ...rubyMethodConfig, diff --git a/gitnexus/src/core/ingestion/markdown-processor.ts b/gitnexus/src/core/ingestion/markdown-processor.ts index 02a83be11..dc0e7fee4 100644 --- a/gitnexus/src/core/ingestion/markdown-processor.ts +++ b/gitnexus/src/core/ingestion/markdown-processor.ts @@ -23,7 +23,7 @@ interface MdFile { export const processMarkdown = ( graph: KnowledgeGraph, files: MdFile[], - allPathSet: Set, + allPathSet: ReadonlySet, ): { sections: number; links: number } => { let totalSections = 0; let totalLinks = 0; diff --git a/gitnexus/src/core/ingestion/method-extractors/configs/jvm.ts b/gitnexus/src/core/ingestion/method-extractors/configs/jvm.ts index 4eaab0a46..df7476ceb 100644 --- a/gitnexus/src/core/ingestion/method-extractors/configs/jvm.ts +++ b/gitnexus/src/core/ingestion/method-extractors/configs/jvm.ts @@ -273,6 +273,7 @@ export const kotlinMethodConfig: MethodExtractionConfig = { typeDeclarationNodes: ['class_declaration', 'object_declaration', 'companion_object'], methodNodeTypes: ['function_declaration'], bodyNodeTypes: ['class_body'], + staticOwnerTypes: new Set(['companion_object', 'object_declaration']), extractName(node) { for (let i = 0; i < node.namedChildCount; i++) { const child = node.namedChild(i); diff --git a/gitnexus/src/core/ingestion/method-extractors/configs/ruby.ts b/gitnexus/src/core/ingestion/method-extractors/configs/ruby.ts index 7f4c06fbf..679497ff1 100644 --- a/gitnexus/src/core/ingestion/method-extractors/configs/ruby.ts +++ b/gitnexus/src/core/ingestion/method-extractors/configs/ruby.ts @@ -219,6 +219,7 @@ export const rubyMethodConfig: MethodExtractionConfig = { typeDeclarationNodes: ['class', 'module', 'singleton_class'], methodNodeTypes: ['method', 'singleton_method'], bodyNodeTypes: ['body_statement'], + staticOwnerTypes: new Set(['singleton_class']), extractOwnerName(node) { // singleton_class (class << self) inherits the enclosing class/module name diff --git a/gitnexus/src/core/ingestion/method-extractors/generic.ts b/gitnexus/src/core/ingestion/method-extractors/generic.ts index 74675b9df..7e15f2458 100644 --- a/gitnexus/src/core/ingestion/method-extractors/generic.ts +++ b/gitnexus/src/core/ingestion/method-extractors/generic.ts @@ -16,13 +16,62 @@ import type { MethodInfo, } from '../method-types.js'; -/** Owner node types where member functions are effectively static (JVM/Ruby semantics). */ -const STATIC_OWNER_TYPES = new Set(['companion_object', 'object_declaration', 'singleton_class']); +/** + * Node types that imply static member semantics when they appear as the owner + * of a method (Kotlin companion objects, Kotlin top-level `object` declarations, + * Ruby `class << self` singleton classes). A config that lists any of these in + * `typeDeclarationNodes` MUST also include the same node type in + * `staticOwnerTypes` — otherwise methods inside these containers silently get + * `isStatic=false`, which is a correctness bug that previously only surfaced + * at analysis time on large repos. + * + * Opt-out: a config that sets `staticOwnerTypes: new Set()` (explicit empty + * set) signals "I handle static-ness entirely via isStatic()" and is exempt + * from the guard. + */ +const STATIC_IMPLYING_OWNER_TYPES: ReadonlySet = new Set([ + 'companion_object', + 'object_declaration', + 'singleton_class', +]); /** * Create a MethodExtractor from a declarative config. + * + * @throws {Error} if `typeDeclarationNodes` contains a static-implying owner + * type (companion_object / object_declaration / singleton_class) that is + * not covered by `staticOwnerTypes`. The guard fires once per language at + * provider construction to prevent silent `isStatic=false` regressions. See + * `STATIC_IMPLYING_OWNER_TYPES` for the exact opt-out convention. */ export function createMethodExtractor(config: MethodExtractionConfig): MethodExtractor { + // Runtime invariant: each static-implying container type declared in + // typeDeclarationNodes must be covered by staticOwnerTypes. An explicit + // empty Set is treated as intentional opt-out. + if (config.staticOwnerTypes === undefined) { + const missing = config.typeDeclarationNodes.filter((t) => STATIC_IMPLYING_OWNER_TYPES.has(t)); + if (missing.length > 0) { + throw new Error( + `[MethodExtractionConfig:${config.language}] typeDeclarationNodes includes static-implying owner type(s) ` + + `${JSON.stringify(missing)} but staticOwnerTypes is not set. Add ` + + `'staticOwnerTypes: new Set([${missing.map((t) => `'${t}'`).join(', ')}])' ` + + `to the config, or set 'staticOwnerTypes: new Set()' to opt out explicitly.`, + ); + } + } else { + const missing = config.typeDeclarationNodes.filter( + (t) => STATIC_IMPLYING_OWNER_TYPES.has(t) && !config.staticOwnerTypes!.has(t), + ); + // Explicit empty Set is the opt-out signal; don't second-guess it. + if (missing.length > 0 && config.staticOwnerTypes.size > 0) { + throw new Error( + `[MethodExtractionConfig:${config.language}] typeDeclarationNodes includes static-implying owner type(s) ` + + `${JSON.stringify(missing)} that are missing from staticOwnerTypes. ` + + `Either add them to staticOwnerTypes, or set 'staticOwnerTypes: new Set()' to opt out explicitly.`, + ); + } + } + const typeDeclarationSet = new Set(config.typeDeclarationNodes); const methodNodeSet = new Set(config.methodNodeTypes); const bodyNodeSet = new Set(config.bodyNodeTypes); @@ -184,8 +233,9 @@ function buildMethod( // Domain invariant: abstract methods cannot be final if (isAbstract) isFinal = false; - // companion_object / object_declaration members are effectively static on JVM - const isStatic = STATIC_OWNER_TYPES.has(ownerNode.type) || config.isStatic(node); + // Static-owner detection is config-driven: each language declares which + // container node types imply static (e.g. Ruby singleton_class, Kotlin companion_object). + const isStatic = (config.staticOwnerTypes?.has(ownerNode.type) ?? false) || config.isStatic(node); return { name, diff --git a/gitnexus/src/core/ingestion/method-types.ts b/gitnexus/src/core/ingestion/method-types.ts index 177929799..105b4f251 100644 --- a/gitnexus/src/core/ingestion/method-types.ts +++ b/gitnexus/src/core/ingestion/method-types.ts @@ -82,6 +82,10 @@ export interface MethodExtractionConfig { isAsync?: (node: SyntaxNode) => boolean; isPartial?: (node: SyntaxNode) => boolean; isConst?: (node: SyntaxNode) => boolean; + /** Owner node types where member functions are effectively static (e.g. + * Ruby singleton_class, Kotlin companion_object / object_declaration). + * When the ownerNode matches one of these types, isStatic is forced true. */ + staticOwnerTypes?: ReadonlySet; /** Resolve the owner name from a standalone method node (e.g. Go receiver type). */ extractOwnerName?: (node: SyntaxNode) => string | undefined; /** Extract a primary constructor from the owner node itself (e.g. C# 12 class Point(int x, int y)). */ diff --git a/gitnexus/src/core/ingestion/model/semantic-model.ts b/gitnexus/src/core/ingestion/model/semantic-model.ts index 9a822f8be..deee4ad93 100644 --- a/gitnexus/src/core/ingestion/model/semantic-model.ts +++ b/gitnexus/src/core/ingestion/model/semantic-model.ts @@ -7,7 +7,7 @@ * - A nested SymbolTable (file + callable name indexes) wrapped so * that `add()` fans out into the registries via the dispatch table * - * ## DAG direction + * ## Dependency direction * * gitnexus-shared (NodeLabel) — leaf * ↑ diff --git a/gitnexus/src/core/ingestion/model/symbol-table.ts b/gitnexus/src/core/ingestion/model/symbol-table.ts index 046998778..75abb9731 100644 --- a/gitnexus/src/core/ingestion/model/symbol-table.ts +++ b/gitnexus/src/core/ingestion/model/symbol-table.ts @@ -1,8 +1,8 @@ /** * Symbol Table — file-indexed + callable-name symbol storage. * - * This module is a PURE LEAF in the ingestion DAG. It owns two orthogonal - * O(1) indexes: + * This module is a PURE LEAF in the ingestion dependency hierarchy. It owns + * two orthogonal O(1) indexes: * * 1. fileIndex — Map> * for same-file lookups (Tier 1 resolution) @@ -10,12 +10,12 @@ * for name-keyed callable lookups (Tier 3 widen) * * SymbolTable deliberately knows NOTHING about the owner-scoped registries - * (types, methods, fields) that sit above it in the DAG. Those registries - * live in `model/` and depend on SymbolTable, not the other way around. - * {@link createSemanticModel} composes this pure SymbolTable with the + * (types, methods, fields) that sit above it in the dependency graph. Those + * registries live in `model/` and depend on SymbolTable, not the other way + * around. {@link createSemanticModel} composes this pure SymbolTable with the * registries and wraps `add()` to fan out registrations into both layers. * - * DAG direction (strictly enforced): + * Dependency direction (strictly enforced): * * gitnexus-shared (NodeLabel) — leaf type * ↑ @@ -31,7 +31,7 @@ * * No arrow ever points downward from this file. If you are tempted to * import from `./model/` here, you are going the wrong way — move the - * logic up the DAG instead. + * logic up the dependency chain instead. */ import type { NodeLabel } from 'gitnexus-shared'; diff --git a/gitnexus/src/core/ingestion/mro-processor.ts b/gitnexus/src/core/ingestion/mro-processor.ts index f73da20fc..c48cf99f3 100644 --- a/gitnexus/src/core/ingestion/mro-processor.ts +++ b/gitnexus/src/core/ingestion/mro-processor.ts @@ -1,7 +1,7 @@ /** * MRO (Method Resolution Order) Processor * - * Walks the inheritance DAG (EXTENDS/IMPLEMENTS edges), collects methods from + * Walks the inheritance graph (EXTENDS/IMPLEMENTS edges), collects methods from * each ancestor via HAS_METHOD edges, detects method-name collisions across * parents, and applies language-specific resolution rules to emit METHOD_OVERRIDES edges. * diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 6eba41869..a13d66818 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -146,18 +146,18 @@ const processParsingWithWorkers = async ( }); } - for (const _item of result.imports) allImports.push(_item); - for (const _item of result.calls) allCalls.push(_item); - for (const _item of result.assignments) allAssignments.push(_item); - for (const _item of result.heritage) allHeritage.push(_item); - for (const _item of result.routes) allRoutes.push(_item); - for (const _item of result.fetchCalls) allFetchCalls.push(_item); - for (const _item of result.decoratorRoutes) allDecoratorRoutes.push(_item); - for (const _item of result.toolDefs) allToolDefs.push(_item); - if (result.ormQueries) for (const _item of result.ormQueries) allORMQueries.push(_item); - for (const _item of result.constructorBindings) allConstructorBindings.push(_item); + for (const item of result.imports) allImports.push(item); + for (const item of result.calls) allCalls.push(item); + for (const item of result.assignments) allAssignments.push(item); + for (const item of result.heritage) allHeritage.push(item); + for (const item of result.routes) allRoutes.push(item); + for (const item of result.fetchCalls) allFetchCalls.push(item); + for (const item of result.decoratorRoutes) allDecoratorRoutes.push(item); + for (const item of result.toolDefs) allToolDefs.push(item); + if (result.ormQueries) for (const item of result.ormQueries) allORMQueries.push(item); + for (const item of result.constructorBindings) allConstructorBindings.push(item); if (result.fileScopeBindings) - for (const _item of result.fileScopeBindings) fileScopeBindingsByFile.push(_item); + for (const item of result.fileScopeBindings) fileScopeBindingsByFile.push(item); } // Merge and log skipped languages from workers @@ -203,10 +203,11 @@ const exportCache = new Map(); const cachedFindEnclosingClassInfo = ( node: SyntaxNode, filePath: string, + resolveEnclosingOwner?: (node: SyntaxNode) => SyntaxNode | null, ): EnclosingClassInfo | null => { const cached = classInfoCache.get(node); if (cached !== undefined) return cached; - const result = findEnclosingClassInfo(node, filePath); + const result = findEnclosingClassInfo(node, filePath, resolveEnclosingOwner); classInfoCache.set(node, result); return result; }; @@ -238,17 +239,26 @@ const seqMethodMapCache = new Map< { map: Map; groups: Map } >(); -function seqFindEnclosingClassNode(node: SyntaxNode): SyntaxNode | null { +/** Provider-aware enclosing container lookup. + * Walks up from `node` until a CLASS_CONTAINER_TYPES node is found. + * When `resolveEnclosingOwner` is provided, delegates language-specific + * container remapping (e.g., Ruby singleton_class → enclosing class). + * Without the hook, returns the first matching container directly (raw lookup). */ +function seqFindEnclosingOwnerNode( + node: SyntaxNode, + resolveEnclosingOwner?: (node: SyntaxNode) => SyntaxNode | null, +): SyntaxNode | null { let current = node.parent; while (current) { if (CLASS_CONTAINER_TYPES.has(current.type)) { - // Ruby singleton_class (class << self) has no name field, so owner/class - // resolution should skip it and return the enclosing class/module instead. - // A file-root `class << self` has no enclosing class/module, so this - // intentionally falls through to null rather than synthesizing an owner. - if (current.type === 'singleton_class') { - current = current.parent; - continue; + if (resolveEnclosingOwner) { + const resolved = resolveEnclosingOwner(current); + if (resolved === null) { + // Provider says skip this container — keep walking up. + current = current.parent; + continue; + } + return resolved; } return current; } @@ -257,18 +267,6 @@ function seqFindEnclosingClassNode(node: SyntaxNode): SyntaxNode | null { return null; } -/** Raw enclosing container lookup for extractor-only context. - * Unlike seqFindEnclosingClassNode(), this intentionally returns - * `singleton_class` so Ruby `class << self` methods preserve static context. */ -function seqFindRawEnclosingContainerNode(node: SyntaxNode): SyntaxNode | null { - let current = node.parent; - while (current) { - if (CLASS_CONTAINER_TYPES.has(current.type)) return current; - current = current.parent; - } - return null; -} - /** Minimal no-op SymbolTable stub for sequential extractor contexts. The real * SymbolTable is not fully populated yet at this stage, so use the stub for safety. * Implements the full {@link SymbolTableReader} surface so future extractor additions @@ -437,7 +435,11 @@ const processParsingSequential = async ( nodeLabel === 'Property' || nodeLabel === 'Function'; const enclosingClassInfo = needsOwner - ? cachedFindEnclosingClassInfo(nameNode || definitionNodeForRange, file.path) + ? cachedFindEnclosingClassInfo( + nameNode || definitionNodeForRange, + file.path, + provider.resolveEnclosingOwner, + ) : null; const enclosingClassId = enclosingClassInfo?.classId ?? null; @@ -463,9 +465,9 @@ const processParsingSequential = async ( if (provider.methodExtractor) { // Try class-based extraction (method inside a class/struct/trait body). - // Ruby `class << self` needs the singleton_class node for `isStatic`, - // while owner/class resolution still skips it elsewhere. - const methodOwnerNode = seqFindRawEnclosingContainerNode(definitionNode); + // Raw lookup (no resolveEnclosingOwner) so the method extractor sees + // the actual container node (e.g. singleton_class) for static detection. + const methodOwnerNode = seqFindEnclosingOwnerNode(definitionNode); if (methodOwnerNode) { // Cache extract() results per class node to avoid re-traversing the // same class body for every method it contains (O(N) -> O(1) per hit). @@ -595,7 +597,10 @@ const processParsingSequential = async ( if (nodeLabel === 'Property' && definitionNode) { // FieldExtractor is the single source of truth when available if (provider.fieldExtractor && typeEnv) { - const classNode = seqFindEnclosingClassNode(definitionNode); + const classNode = seqFindEnclosingOwnerNode( + definitionNode, + provider.resolveEnclosingOwner, + ); if (classNode) { const fieldMap = seqGetFieldInfo(classNode, provider, { typeEnv, diff --git a/gitnexus/src/core/ingestion/pipeline-phases/cobol.ts b/gitnexus/src/core/ingestion/pipeline-phases/cobol.ts new file mode 100644 index 000000000..cfe6b6ce2 --- /dev/null +++ b/gitnexus/src/core/ingestion/pipeline-phases/cobol.ts @@ -0,0 +1,73 @@ +/** + * Phase: cobol + * + * Processes COBOL and JCL files via regex extraction (no tree-sitter). + * + * @deps structure + * @reads scannedFiles, allPaths (from structure phase) + * @writes graph (COBOL program/paragraph/section nodes, JCL job/step nodes) + */ + +import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js'; +import { getPhaseOutput } from './types.js'; +import { processCobol, isCobolFile, isJclFile } from '../cobol-processor.js'; +import { readFileContents } from '../filesystem-walker.js'; +import type { StructureOutput } from './structure.js'; +import { isDev } from '../utils/env.js'; + +export interface CobolOutput { + programs: number; + paragraphs: number; + sections: number; +} + +export const cobolPhase: PipelinePhase = { + name: 'cobol', + deps: ['structure'], + + async execute( + ctx: PipelineContext, + deps: ReadonlyMap>, + ): Promise { + const { scannedFiles, allPathSet } = getPhaseOutput(deps, 'structure'); + + const cobolScanned = scannedFiles.filter((f) => isCobolFile(f.path) || isJclFile(f.path)); + + if (cobolScanned.length === 0) { + return { programs: 0, paragraphs: 0, sections: 0 }; + } + + const cobolContents = await readFileContents( + ctx.repoPath, + cobolScanned.map((f) => f.path), + ); + const cobolFiles = cobolScanned + .filter((f) => cobolContents.has(f.path)) + .map((f) => ({ path: f.path, content: cobolContents.get(f.path)! })); + const cobolResult = processCobol(ctx.graph, cobolFiles, allPathSet); + + if (isDev) { + console.log( + ` COBOL: ${cobolResult.programs} programs, ${cobolResult.paragraphs} paragraphs, ${cobolResult.sections} sections from ${cobolFiles.length} files`, + ); + if ( + cobolResult.execSqlBlocks > 0 || + cobolResult.execCicsBlocks > 0 || + cobolResult.entryPoints > 0 + ) { + console.log( + ` COBOL enriched: ${cobolResult.execSqlBlocks} SQL blocks, ${cobolResult.execCicsBlocks} CICS blocks, ${cobolResult.entryPoints} entry points, ${cobolResult.moves} moves, ${cobolResult.fileDeclarations} file declarations`, + ); + } + if (cobolResult.jclJobs > 0) { + console.log(` JCL: ${cobolResult.jclJobs} jobs, ${cobolResult.jclSteps} steps`); + } + } + + return { + programs: cobolResult.programs, + paragraphs: cobolResult.paragraphs, + sections: cobolResult.sections, + }; + }, +}; diff --git a/gitnexus/src/core/ingestion/pipeline-phases/communities.ts b/gitnexus/src/core/ingestion/pipeline-phases/communities.ts new file mode 100644 index 000000000..6a302b8b9 --- /dev/null +++ b/gitnexus/src/core/ingestion/pipeline-phases/communities.ts @@ -0,0 +1,82 @@ +/** + * Phase: communities + * + * Detects code communities via Leiden algorithm and creates + * Community nodes + MEMBER_OF edges. + * + * @deps mro + * @reads graph (all nodes and relationships) + * @writes graph (Community nodes, MEMBER_OF edges) + */ + +import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js'; +import { getPhaseOutput } from './types.js'; +import type { StructureOutput } from './structure.js'; +import { processCommunities, type CommunityDetectionResult } from '../community-processor.js'; +import { isDev } from '../utils/env.js'; + +export interface CommunitiesOutput { + communityResult: CommunityDetectionResult; +} + +export const communitiesPhase: PipelinePhase = { + name: 'communities', + deps: ['mro', 'structure'], + + async execute( + ctx: PipelineContext, + deps: ReadonlyMap>, + ): Promise { + const { totalFiles } = getPhaseOutput(deps, 'structure'); + + ctx.onProgress({ + phase: 'communities', + percent: 84, + message: 'Detecting code communities...', + stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: ctx.graph.nodeCount }, + }); + + const communityResult = await processCommunities(ctx.graph, (message, progress) => { + const communityProgress = 84 + progress * 0.09; + ctx.onProgress({ + phase: 'communities', + percent: Math.round(communityProgress), + message, + stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: ctx.graph.nodeCount }, + }); + }); + + if (isDev) { + console.log( + `🏘️ Community detection: ${communityResult.stats.totalCommunities} communities found (modularity: ${communityResult.stats.modularity.toFixed(3)})`, + ); + } + + communityResult.communities.forEach((comm) => { + ctx.graph.addNode({ + id: comm.id, + label: 'Community' as const, + properties: { + name: comm.label, + filePath: '', + heuristicLabel: comm.heuristicLabel, + cohesion: comm.cohesion, + symbolCount: comm.symbolCount, + }, + }); + }); + + communityResult.memberships.forEach((membership) => { + ctx.graph.addRelationship({ + id: `${membership.nodeId}_member_of_${membership.communityId}`, + type: 'MEMBER_OF', + sourceId: membership.nodeId, + targetId: membership.communityId, + confidence: 1.0, + reason: 'leiden-algorithm', + }); + }); + + return { communityResult }; + }, +}; diff --git a/gitnexus/src/core/ingestion/pipeline-phases/cross-file-impl.ts b/gitnexus/src/core/ingestion/pipeline-phases/cross-file-impl.ts new file mode 100644 index 000000000..334ff57df --- /dev/null +++ b/gitnexus/src/core/ingestion/pipeline-phases/cross-file-impl.ts @@ -0,0 +1,214 @@ +/** + * Cross-file binding propagation — extracted from pipeline.ts. + * + * Seeds downstream files with resolved type bindings from upstream exports. + * Files are processed in topological import order so upstream bindings + * are available when downstream files are re-resolved. + * + * @module + */ + +import { + processCalls, + buildImportedReturnTypes, + buildImportedRawReturnTypes, + type ExportedTypeMap, +} from '../call-processor.js'; +import type { createResolutionContext } from '../model/resolution-context.js'; +import { createASTCache } from '../ast-cache.js'; +import { type PipelineProgress, getLanguageFromFilename } from 'gitnexus-shared'; +import { readFileContents } from '../filesystem-walker.js'; +import { isLanguageAvailable } from '../../tree-sitter/parser-loader.js'; +import { topologicalLevelSort } from '../utils/graph-sort.js'; +import type { KnowledgeGraph } from '../../graph/types.js'; +import { isDev } from '../utils/env.js'; + +/** Max AST trees to keep in LRU cache for cross-file binding propagation. */ +const AST_CACHE_CAP = 50; + +/** Minimum percentage of files that must benefit from cross-file seeding. */ +const CROSS_FILE_SKIP_THRESHOLD = 0.03; +/** Hard cap on files re-processed during cross-file propagation. */ +const MAX_CROSS_FILE_REPROCESS = 2000; + +/** + * Cross-file binding propagation. + * Returns the number of files re-processed. + */ +export async function runCrossFileBindingPropagation( + graph: KnowledgeGraph, + ctx: ReturnType, + parseExportedTypeMap: ReadonlyMap>, + allPathSet: ReadonlySet, + totalFiles: number, + repoPath: string, + pipelineStart: number, + onProgress: (progress: PipelineProgress) => void, +): Promise { + if (parseExportedTypeMap.size === 0 || ctx.namedImportMap.size === 0) return 0; + + // Build a local mutable working copy. Per-file re-resolution below mutates + // this map (each `processCalls` writes that file's exports back into it so + // later iterations in the same level/loop can resolve transitive bindings). + // Owning a local copy here keeps `ParseOutput.exportedTypeMap` truly + // read-only at the phase boundary — no cast, no shared-mutable handoff. + const exportedTypeMap: ExportedTypeMap = new Map(); + for (const [fp, exports] of parseExportedTypeMap) { + exportedTypeMap.set(fp, new Map(exports)); + } + + const { levels, cycleCount } = topologicalLevelSort(ctx.importMap); + + if (isDev && cycleCount > 0) { + console.log(`🔄 ${cycleCount} files in import cycles (processed last in undefined order)`); + } + + let filesWithGaps = 0; + const gapThreshold = Math.max(1, Math.ceil(totalFiles * CROSS_FILE_SKIP_THRESHOLD)); + outer: for (const level of levels) { + for (const filePath of level) { + const imports = ctx.namedImportMap.get(filePath); + if (!imports) continue; + for (const [, binding] of imports) { + const upstream = exportedTypeMap.get(binding.sourcePath); + if (upstream?.has(binding.exportedName)) { + filesWithGaps++; + break; + } + const def = ctx.model.symbols.lookupExactFull(binding.sourcePath, binding.exportedName); + if (def?.returnType) { + filesWithGaps++; + break; + } + } + if (filesWithGaps >= gapThreshold) break outer; + } + } + + const gapRatio = totalFiles > 0 ? filesWithGaps / totalFiles : 0; + if (gapRatio < CROSS_FILE_SKIP_THRESHOLD && filesWithGaps < gapThreshold) { + if (isDev) { + console.log( + `⏭️ Cross-file re-resolution skipped (${filesWithGaps}/${totalFiles} files, ${(gapRatio * 100).toFixed(1)}% < ${CROSS_FILE_SKIP_THRESHOLD * 100}% threshold)`, + ); + } + return 0; + } + + // Intentionally reports `phase: 'parsing'` rather than a separate + // 'crossFile' phase: cross-file re-resolution is logically a continuation of + // the parsing/resolution work and is bucketed under "parsing" in any + // telemetry that groups events by phase name. Kept consistent with the + // upstream `parse` phase's progress events so the UI shows one continuous + // progress segment instead of a phase flicker. If a future change splits + // this out into its own phase, also rename `parse-impl.ts` per-chunk + // progress events accordingly. + onProgress({ + phase: 'parsing', + percent: 82, + message: `Cross-file type propagation (${filesWithGaps}+ files)...`, + stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount }, + }); + + let crossFileResolved = 0; + const crossFileStart = Date.now(); + const astCache = createASTCache(AST_CACHE_CAP); + + for (const level of levels) { + const levelCandidates: { + filePath: string; + seeded: Map; + importedReturns: ReadonlyMap; + importedRawReturns: ReadonlyMap; + }[] = []; + for (const filePath of level) { + if (crossFileResolved + levelCandidates.length >= MAX_CROSS_FILE_REPROCESS) break; + const imports = ctx.namedImportMap.get(filePath); + if (!imports) continue; + + const seeded = new Map(); + for (const [localName, binding] of imports) { + const upstream = exportedTypeMap.get(binding.sourcePath); + if (upstream) { + const type = upstream.get(binding.exportedName); + if (type) seeded.set(localName, type); + } + } + + const importedReturns = buildImportedReturnTypes( + filePath, + ctx.namedImportMap, + ctx.model.symbols, + ); + const importedRawReturns = buildImportedRawReturnTypes( + filePath, + ctx.namedImportMap, + ctx.model.symbols, + ); + if (seeded.size === 0 && importedReturns.size === 0) continue; + if (!allPathSet.has(filePath)) continue; + + const lang = getLanguageFromFilename(filePath); + if (!lang || !isLanguageAvailable(lang)) continue; + + levelCandidates.push({ filePath, seeded, importedReturns, importedRawReturns }); + } + + if (levelCandidates.length === 0) continue; + + const levelPaths = levelCandidates.map((c) => c.filePath); + const contentMap = await readFileContents(repoPath, levelPaths); + + for (const { filePath, seeded, importedReturns, importedRawReturns } of levelCandidates) { + const content = contentMap.get(filePath); + if (!content) continue; + + const reFile = [{ path: filePath, content }]; + const bindings = new Map>(); + if (seeded.size > 0) bindings.set(filePath, seeded); + + const importedReturnTypesMap = new Map>(); + if (importedReturns.size > 0) { + importedReturnTypesMap.set(filePath, importedReturns); + } + + const importedRawReturnTypesMap = new Map>(); + if (importedRawReturns.size > 0) { + importedRawReturnTypesMap.set(filePath, importedRawReturns); + } + + await processCalls( + graph, + reFile, + astCache, + ctx, + undefined, + exportedTypeMap, + bindings.size > 0 ? bindings : undefined, + importedReturnTypesMap.size > 0 ? importedReturnTypesMap : undefined, + importedRawReturnTypesMap.size > 0 ? importedRawReturnTypesMap : undefined, + ); + crossFileResolved++; + } + + if (crossFileResolved >= MAX_CROSS_FILE_REPROCESS) { + if (isDev) + console.log(`⚠️ Cross-file re-resolution capped at ${MAX_CROSS_FILE_REPROCESS} files`); + break; + } + } + + astCache.clear(); + + if (isDev) { + const elapsed = Date.now() - crossFileStart; + const totalElapsed = Date.now() - pipelineStart; + const reResolutionPct = totalElapsed > 0 ? ((elapsed / totalElapsed) * 100).toFixed(1) : '0'; + console.log( + `🔗 Cross-file re-resolution: ${crossFileResolved} candidates re-processed` + + ` in ${elapsed}ms (${reResolutionPct}% of total ingestion time so far)`, + ); + } + + return crossFileResolved; +} diff --git a/gitnexus/src/core/ingestion/pipeline-phases/cross-file.ts b/gitnexus/src/core/ingestion/pipeline-phases/cross-file.ts new file mode 100644 index 000000000..e1e907a0b --- /dev/null +++ b/gitnexus/src/core/ingestion/pipeline-phases/cross-file.ts @@ -0,0 +1,91 @@ +/** + * Phase: crossFile + * + * Cross-file binding propagation: seeds downstream files with resolved + * type bindings from upstream exports. Files are processed in topological + * import order so upstream bindings are available when downstream files + * are re-resolved. + * + * @deps parse, routes, tools, orm (waits for all post-parse phases) + * @reads exportedTypeMap, allPaths, totalFiles + * @writes graph (refined CALLS edges via re-resolution) + * + * **Accumulator ownership / residual risk.** This phase is the sole + * disposer of the `BindingAccumulator` produced by `parse`. The dispose + * call lives inside a `finally` block in `execute()` so that a throw + * inside `runCrossFileBindingPropagation` (or anywhere else in the body) + * still releases the accumulator's heap. The dependency declaration + * (`deps: ['parse', 'routes', 'tools', 'orm']`) plus the runner's + * topological scheduling guarantee that every other consumer of the + * accumulator has finished before this phase starts, so disposing here + * is correct. + * + * The residual risk is intentional and accepted: if a future phase is + * inserted between `parse` and `crossFile` that reads the accumulator + * and throws, `crossFile.execute()` never runs and the accumulator + * leaks. Any author inserting a new phase between `parse` and + * `crossFile` MUST either route the new phase's output through + * `crossFile` (so disposal still happens here) or take ownership of + * the accumulator's lifetime explicitly (its own try/finally that + * disposes on the failure path). Do not silently rely on the GC. + */ + +import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js'; +import { getPhaseOutput } from './types.js'; +import type { ParseOutput } from './parse.js'; +import { runCrossFileBindingPropagation } from './cross-file-impl.js'; +import { isDev } from '../utils/env.js'; + +export interface CrossFileOutput { + /** Number of files re-processed during cross-file propagation. */ + filesReprocessed: number; +} + +export const crossFilePhase: PipelinePhase = { + name: 'crossFile', + deps: ['parse', 'routes', 'tools', 'orm'], + + async execute( + ctx: PipelineContext, + deps: ReadonlyMap>, + ): Promise { + const { exportedTypeMap, allPathSet, totalFiles, bindingAccumulator, resolutionContext } = + getPhaseOutput(deps, 'parse'); + + try { + // Telemetry must run BEFORE dispose: totalBindings, fileCount, and + // estimateMemoryBytes() all return 0 once dispose() clears the + // internal maps. + if (isDev) { + if (bindingAccumulator.totalBindings > 0) { + const memKB = Math.round(bindingAccumulator.estimateMemoryBytes() / 1024); + console.log( + `📦 BindingAccumulator: ${bindingAccumulator.totalBindings} bindings across ${bindingAccumulator.fileCount} files (~${memKB} KB)`, + ); + } else if (totalFiles > 0) { + console.log( + `📦 BindingAccumulator: EMPTY — 0 bindings across 0 files despite ${totalFiles} parsed files. If the codebase has typed bindings, this indicates an upstream regression.`, + ); + } + } + + const filesReprocessed = await runCrossFileBindingPropagation( + ctx.graph, + resolutionContext, + exportedTypeMap, + allPathSet, + totalFiles, + ctx.repoPath, + ctx.pipelineStart, + ctx.onProgress, + ); + + return { filesReprocessed }; + } finally { + // Single dispose call site for the accumulator — runs on both the + // happy path and the throw path so the heap is always released + // before the runner moves on (or surfaces the error). + bindingAccumulator.dispose(); + } + }, +}; diff --git a/gitnexus/src/core/ingestion/pipeline-phases/index.ts b/gitnexus/src/core/ingestion/pipeline-phases/index.ts new file mode 100644 index 000000000..c05264de1 --- /dev/null +++ b/gitnexus/src/core/ingestion/pipeline-phases/index.ts @@ -0,0 +1,27 @@ +/** + * Pipeline Phases — barrel export. + * + * Exports all phases, the runner, types, and shared utilities + * for the ingestion pipeline. + */ + +// ── Phase exports (in dependency order) ──────────────────────────────────── + +export { scanPhase, type ScanOutput } from './scan.js'; +export { structurePhase, type StructureOutput } from './structure.js'; +export { markdownPhase, type MarkdownOutput } from './markdown.js'; +export { cobolPhase, type CobolOutput } from './cobol.js'; +export { parsePhase, type ParseOutput } from './parse.js'; +export { routesPhase, type RoutesOutput, type RouteEntry } from './routes.js'; +export { toolsPhase, type ToolsOutput, type ToolDef } from './tools.js'; +export { ormPhase, type ORMOutput } from './orm.js'; +export { crossFilePhase, type CrossFileOutput } from './cross-file.js'; +export { mroPhase, type MROOutput } from './mro.js'; +export { communitiesPhase, type CommunitiesOutput } from './communities.js'; +export { processesPhase, type ProcessesOutput } from './processes.js'; + +// ── Infrastructure ───────────────────────────────────────────────────────── + +export { runPipeline } from './runner.js'; +export type { PipelinePhase, PipelineContext, PhaseResult } from './types.js'; +export { getPhaseOutput } from './types.js'; diff --git a/gitnexus/src/core/ingestion/pipeline-phases/markdown.ts b/gitnexus/src/core/ingestion/pipeline-phases/markdown.ts new file mode 100644 index 000000000..6b3853b9d --- /dev/null +++ b/gitnexus/src/core/ingestion/pipeline-phases/markdown.ts @@ -0,0 +1,58 @@ +/** + * Phase: markdown + * + * Processes Markdown/MDX files to extract headings and cross-links. + * + * @deps structure + * @reads scannedFiles, allPaths (from structure phase) + * @writes graph (Markdown section nodes + cross-link edges) + */ + +import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js'; +import { getPhaseOutput } from './types.js'; +import { processMarkdown } from '../markdown-processor.js'; +import { readFileContents } from '../filesystem-walker.js'; +import type { StructureOutput } from './structure.js'; +import { isDev } from '../utils/env.js'; + +export interface MarkdownOutput { + /** Number of markdown sections extracted. */ + sections: number; + /** Number of cross-links created. */ + links: number; +} + +export const markdownPhase: PipelinePhase = { + name: 'markdown', + deps: ['structure'], + + async execute( + ctx: PipelineContext, + deps: ReadonlyMap>, + ): Promise { + const { scannedFiles, allPathSet } = getPhaseOutput(deps, 'structure'); + + const mdScanned = scannedFiles.filter((f) => f.path.endsWith('.md') || f.path.endsWith('.mdx')); + + if (mdScanned.length === 0) { + return { sections: 0, links: 0 }; + } + + const mdContents = await readFileContents( + ctx.repoPath, + mdScanned.map((f) => f.path), + ); + const mdFiles = mdScanned + .filter((f) => mdContents.has(f.path)) + .map((f) => ({ path: f.path, content: mdContents.get(f.path)! })); + const mdResult = processMarkdown(ctx.graph, mdFiles, allPathSet); + + if (isDev) { + console.log( + ` Markdown: ${mdResult.sections} sections, ${mdResult.links} cross-links from ${mdFiles.length} files`, + ); + } + + return { sections: mdResult.sections, links: mdResult.links }; + }, +}; diff --git a/gitnexus/src/core/ingestion/pipeline-phases/mro.ts b/gitnexus/src/core/ingestion/pipeline-phases/mro.ts new file mode 100644 index 000000000..372ae32b0 --- /dev/null +++ b/gitnexus/src/core/ingestion/pipeline-phases/mro.ts @@ -0,0 +1,57 @@ +/** + * Phase: mro + * + * Computes Method Resolution Order (MRO) and creates METHOD_OVERRIDES + * and METHOD_IMPLEMENTS edges. + * + * @deps crossFile + * @reads graph (all nodes and relationships) + * @writes graph (METHOD_OVERRIDES, METHOD_IMPLEMENTS edges) + */ + +import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js'; +import { getPhaseOutput } from './types.js'; +import type { StructureOutput } from './structure.js'; +import { computeMRO } from '../mro-processor.js'; +import { isDev } from '../utils/env.js'; + +export interface MROOutput { + entries: number; + ambiguityCount: number; + overrideEdges: number; + methodImplementsEdges: number; +} + +export const mroPhase: PipelinePhase = { + name: 'mro', + deps: ['crossFile', 'structure'], + + async execute( + ctx: PipelineContext, + deps: ReadonlyMap>, + ): Promise { + const { totalFiles } = getPhaseOutput(deps, 'structure'); + + ctx.onProgress({ + phase: 'enriching', + percent: 83, + message: 'Computing method resolution order...', + stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: ctx.graph.nodeCount }, + }); + + const mroResult = computeMRO(ctx.graph); + + if (isDev && mroResult.entries.length > 0) { + console.log( + `🔀 MRO: ${mroResult.entries.length} classes analyzed, ${mroResult.ambiguityCount} ambiguities, ${mroResult.overrideEdges} METHOD_OVERRIDES, ${mroResult.methodImplementsEdges} METHOD_IMPLEMENTS`, + ); + } + + return { + entries: mroResult.entries.length, + ambiguityCount: mroResult.ambiguityCount, + overrideEdges: mroResult.overrideEdges, + methodImplementsEdges: mroResult.methodImplementsEdges, + }; + }, +}; diff --git a/gitnexus/src/core/ingestion/pipeline-phases/orm-extraction.ts b/gitnexus/src/core/ingestion/pipeline-phases/orm-extraction.ts new file mode 100644 index 000000000..c6b46610e --- /dev/null +++ b/gitnexus/src/core/ingestion/pipeline-phases/orm-extraction.ts @@ -0,0 +1,106 @@ +/** + * Inline ORM query extraction (sequential fallback path). + * + * Extracts Prisma and Supabase query calls from source content using + * regex patterns. Used by the sequential parse path when workers are + * not available — the worker path extracts ORM queries via tree-sitter + * queries instead. + * + * @module + */ + +import type { ExtractedORMQuery } from '../workers/parse-worker.js'; + +// ── Regex patterns ───────────────────────────────────────────────────────── + +/** Matches Prisma client method calls: `prisma.user.findMany(...)` */ +const PRISMA_QUERY_RE = + /\bprisma\.(\w+)\.(findMany|findFirst|findUnique|findUniqueOrThrow|findFirstOrThrow|create|createMany|update|updateMany|delete|deleteMany|upsert|count|aggregate|groupBy)\s*\(/g; + +/** Matches Supabase client method calls: `supabase.from('users').select(...)` */ +const SUPABASE_QUERY_RE = + /\bsupabase\.from\s*\(\s*['"](\w+)['"]\s*\)\s*\.(select|insert|update|delete|upsert)\s*\(/g; + +// ── Extraction function ─────────────────────────────────────────────────── + +/** + * Extract ORM query calls from file content using regex. + * + * Fast-path: skips files that don't contain `prisma.` or `supabase.from`. + * Results are appended to the `out` array (push pattern avoids allocation). + * + * @param filePath Relative path of the source file + * @param content File content string + * @param out Output array to append extracted queries to + */ +export function extractORMQueriesInline( + filePath: string, + content: string, + out: ExtractedORMQuery[], +): void { + const hasPrisma = content.includes('prisma.'); + const hasSupabase = content.includes('supabase.from'); + if (!hasPrisma && !hasSupabase) return; + + // Pre-compute line number offsets to avoid O(n²) substring+split per match + const lineOffsets = buildLineOffsets(content); + + if (hasPrisma) { + PRISMA_QUERY_RE.lastIndex = 0; + let m; + while ((m = PRISMA_QUERY_RE.exec(content)) !== null) { + const model = m[1]; + if (model.startsWith('$')) continue; + out.push({ + filePath, + orm: 'prisma', + model, + method: m[2], + lineNumber: lineNumberAtOffset(lineOffsets, m.index), + }); + } + } + + if (hasSupabase) { + SUPABASE_QUERY_RE.lastIndex = 0; + let m; + while ((m = SUPABASE_QUERY_RE.exec(content)) !== null) { + out.push({ + filePath, + orm: 'supabase', + model: m[1], + method: m[2], + lineNumber: lineNumberAtOffset(lineOffsets, m.index), + }); + } + } +} + +// ── Line offset helpers ─────────────────────────────────────────────────── + +/** Build an array of byte offsets where each newline occurs (O(n) once). */ +function buildLineOffsets(content: string): number[] { + const offsets: number[] = []; + for (let i = 0; i < content.length; i++) { + if (content[i] === '\n') offsets.push(i); + } + return offsets; +} + +/** + * Binary search for 0-based line number at a given character offset. + * + * Returns the number of newlines that occur before `offset` in the content, + * which is the 0-based line number. When `offset` is beyond the last newline, + * returns `lineOffsets.length` (i.e., the last line index). + */ +function lineNumberAtOffset(lineOffsets: number[], offset: number): number { + let lo = 0; + let hi = lineOffsets.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (lineOffsets[mid] < offset) lo = mid + 1; + else hi = mid; + } + return lo; +} diff --git a/gitnexus/src/core/ingestion/pipeline-phases/orm.ts b/gitnexus/src/core/ingestion/pipeline-phases/orm.ts new file mode 100644 index 000000000..ebdac018a --- /dev/null +++ b/gitnexus/src/core/ingestion/pipeline-phases/orm.ts @@ -0,0 +1,100 @@ +/** + * Phase: orm + * + * Processes ORM queries (Prisma + Supabase) and creates QUERIES edges. + * + * @deps parse + * @reads allORMQueries (from parse) + * @writes graph (CodeElement nodes, QUERIES edges) + */ + +import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js'; +import { getPhaseOutput } from './types.js'; +import type { ParseOutput } from './parse.js'; +import { generateId } from '../../../lib/utils.js'; +import type { ExtractedORMQuery } from '../workers/parse-worker.js'; +import type { KnowledgeGraph } from '../../graph/types.js'; +import { isDev } from '../utils/env.js'; + +export interface ORMOutput { + edgesCreated: number; + modelCount: number; +} + +export const ormPhase: PipelinePhase = { + name: 'orm', + deps: ['parse'], + + async execute( + ctx: PipelineContext, + deps: ReadonlyMap>, + ): Promise { + const { allORMQueries } = getPhaseOutput(deps, 'parse'); + + if (allORMQueries.length === 0) { + return { edgesCreated: 0, modelCount: 0 }; + } + + return processORMQueries(ctx.graph, allORMQueries); + }, +}; + +function processORMQueries( + graph: KnowledgeGraph, + queries: readonly ExtractedORMQuery[], +): ORMOutput { + const modelNodes = new Map(); + const seenEdges = new Set(); + let edgesCreated = 0; + + for (const q of queries) { + const modelKey = `${q.orm}:${q.model}`; + let modelNodeId = modelNodes.get(modelKey); + if (!modelNodeId) { + const candidateIds = [ + generateId('Class', `${q.model}`), + generateId('Interface', `${q.model}`), + generateId('CodeElement', `${q.model}`), + ]; + const existing = candidateIds.find((id) => graph.getNode(id)); + if (existing) { + modelNodeId = existing; + } else { + modelNodeId = generateId('CodeElement', `${q.orm}:${q.model}`); + graph.addNode({ + id: modelNodeId, + label: 'CodeElement', + properties: { + name: q.model, + filePath: '', + description: `${q.orm} model/table: ${q.model}`, + }, + }); + } + modelNodes.set(modelKey, modelNodeId); + } + + const fileId = generateId('File', q.filePath); + const edgeKey = `${fileId}->${modelNodeId}:${q.method}`; + if (seenEdges.has(edgeKey)) continue; + seenEdges.add(edgeKey); + + graph.addRelationship({ + id: generateId('QUERIES', edgeKey), + sourceId: fileId, + targetId: modelNodeId, + type: 'QUERIES', + confidence: 0.9, + reason: `${q.orm}-${q.method}`, + }); + edgesCreated++; + } + + if (isDev) { + console.log( + `ORM dataflow: ${edgesCreated} QUERIES edges, ${modelNodes.size} models (${queries.length} total calls)`, + ); + } + + return { edgesCreated, modelCount: modelNodes.size }; +} diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts new file mode 100644 index 000000000..52c7a0d47 --- /dev/null +++ b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts @@ -0,0 +1,592 @@ +/** + * Parse implementation — chunked parse + resolve loop. + * + * This is the core parsing engine of the ingestion pipeline. It reads + * source files in byte-budget chunks (~20MB each), parses via worker + * pool (or sequential fallback), resolves imports/calls/heritage per + * chunk, and synthesizes wildcard import bindings. + * + * Consumed by the parse phase (`parse.ts`) — the phase file handles + * dependency wiring while the heavy implementation lives here. + * + * @module + */ + +import { + BindingAccumulator, + enrichExportedTypeMap, + type BindingEntry, +} from '../binding-accumulator.js'; +import { processParsing } from '../parsing-processor.js'; +import { + processImports, + processImportsFromExtracted, + buildImportResolutionContext, +} from '../import-processor.js'; +import { EMPTY_INDEX } from '../import-resolvers/utils.js'; +import { + processCalls, + processCallsFromExtracted, + processAssignmentsFromExtracted, + processRoutesFromExtracted, + seedCrossFileReceiverTypes, + buildExportedTypeMapFromGraph, + type ExportedTypeMap, +} from '../call-processor.js'; +import { buildHeritageMap } from '../model/heritage-map.js'; +import { + processHeritage, + processHeritageFromExtracted, + extractExtractedHeritageFromFiles, + getHeritageStrategyForLanguage, +} from '../heritage-processor.js'; +import { createResolutionContext } from '../model/resolution-context.js'; +import { createASTCache } from '../ast-cache.js'; +import { type PipelineProgress, getLanguageFromFilename } from 'gitnexus-shared'; +import { readFileContents } from '../filesystem-walker.js'; +import { isLanguageAvailable } from '../../tree-sitter/parser-loader.js'; +import { createWorkerPool } from '../workers/worker-pool.js'; +import type { WorkerPool } from '../workers/worker-pool.js'; +import type { + ExtractedAssignment, + ExtractedCall, + ExtractedDecoratorRoute, + ExtractedFetchCall, + ExtractedORMQuery, + ExtractedRoute, + ExtractedToolDef, + FileConstructorBindings, +} from '../workers/parse-worker.js'; +import type { ExtractedHeritage } from '../model/heritage-map.js'; +import type { KnowledgeGraph } from '../../graph/types.js'; +import type { PipelineOptions } from '../pipeline.js'; +import { extractFetchCallsFromFiles } from '../call-processor.js'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { isDev } from '../utils/env.js'; +import { synthesizeWildcardImportBindings, needsSynthesis } from './wildcard-synthesis.js'; +import { extractORMQueriesInline } from './orm-extraction.js'; + +// ── Constants ────────────────────────────────────────────────────────────── + +/** Max bytes of source content to load per parse chunk. */ +const CHUNK_BYTE_BUDGET = 20 * 1024 * 1024; // 20MB + +// ── Main parse + resolve function ────────────────────────────────────────── + +type ScannedFile = { path: string; size: number }; +type ProgressFn = (progress: PipelineProgress) => void; + +/** + * Chunked parse + resolve loop. + * + * Reads source in byte-budget chunks (~20MB each). For each chunk: + * 1. Parse via worker pool (or sequential fallback) + * 2. Resolve imports from extracted data + * 3. Synthesize wildcard import bindings (Go/Ruby/C++/Swift/Python) + * 4. Resolve heritage + routes per chunk; defer worker CALLS until all chunks + * have contributed heritage so interface-dispatch implementor map is complete + * 5. Collect TypeEnv bindings for cross-file propagation + */ +export async function runChunkedParseAndResolve( + graph: KnowledgeGraph, + scannedFiles: ScannedFile[], + allPaths: string[], + totalFiles: number, + repoPath: string, + pipelineStart: number, + onProgress: ProgressFn, + options?: PipelineOptions, +): Promise<{ + exportedTypeMap: ExportedTypeMap; + allFetchCalls: ExtractedFetchCall[]; + allExtractedRoutes: ExtractedRoute[]; + allDecoratorRoutes: ExtractedDecoratorRoute[]; + allToolDefs: ExtractedToolDef[]; + allORMQueries: ExtractedORMQuery[]; + bindingAccumulator: BindingAccumulator; + resolutionContext: ReturnType; +}> { + const ctx = createResolutionContext(); + const symbolTable = ctx.model.symbols; + + const parseableScanned = scannedFiles.filter((f) => { + const lang = getLanguageFromFilename(f.path); + return lang && isLanguageAvailable(lang); + }); + + // Warn about files skipped due to unavailable parsers + const skippedByLang = new Map(); + for (const f of scannedFiles) { + const lang = getLanguageFromFilename(f.path); + if (lang && !isLanguageAvailable(lang)) { + skippedByLang.set(lang, (skippedByLang.get(lang) || 0) + 1); + } + } + for (const [lang, count] of skippedByLang) { + console.warn( + `Skipping ${count} ${lang} file(s) — ${lang} parser not available (native binding may not have built). Try: npm rebuild tree-sitter-${lang}`, + ); + } + + const totalParseable = parseableScanned.length; + + if (totalParseable === 0) { + onProgress({ + phase: 'parsing', + percent: 82, + message: 'No parseable files found — skipping parsing phase', + stats: { filesProcessed: 0, totalFiles: 0, nodesCreated: graph.nodeCount }, + }); + } + + // Build byte-budget chunks + const chunks: string[][] = []; + let currentChunk: string[] = []; + let currentBytes = 0; + for (const file of parseableScanned) { + if (currentChunk.length > 0 && currentBytes + file.size > CHUNK_BYTE_BUDGET) { + chunks.push(currentChunk); + currentChunk = []; + currentBytes = 0; + } + currentChunk.push(file.path); + currentBytes += file.size; + } + if (currentChunk.length > 0) chunks.push(currentChunk); + + const numChunks = chunks.length; + + if (isDev) { + const totalMB = parseableScanned.reduce((s, f) => s + f.size, 0) / (1024 * 1024); + console.log( + `📂 Scan: ${totalFiles} paths, ${totalParseable} parseable (${totalMB.toFixed(0)}MB), ${numChunks} chunks @ ${CHUNK_BYTE_BUDGET / (1024 * 1024)}MB budget`, + ); + } + + onProgress({ + phase: 'parsing', + percent: 20, + message: `Parsing ${totalParseable} files in ${numChunks} chunk${numChunks !== 1 ? 's' : ''}...`, + stats: { filesProcessed: 0, totalFiles: totalParseable, nodesCreated: graph.nodeCount }, + }); + + // Don't spawn workers for tiny repos — overhead exceeds benefit + const MIN_FILES_FOR_WORKERS = 15; + const MIN_BYTES_FOR_WORKERS = 512 * 1024; + const totalBytes = parseableScanned.reduce((s, f) => s + f.size, 0); + + // Create worker pool once, reuse across chunks + let workerPool: WorkerPool | undefined; + if ( + !options?.skipWorkers && + (totalParseable >= MIN_FILES_FOR_WORKERS || totalBytes >= MIN_BYTES_FOR_WORKERS) + ) { + try { + let workerUrl = new URL('../workers/parse-worker.js', import.meta.url); + // When running under vitest, import.meta.url points to src/ where no .js exists. + // Fall back to the compiled dist/ worker so the pool can spawn real worker threads. + const thisDir = fileURLToPath(new URL('.', import.meta.url)); + if (!fs.existsSync(fileURLToPath(workerUrl))) { + const distWorker = path.resolve( + thisDir, + '..', + '..', + '..', + '..', + 'dist', + 'core', + 'ingestion', + 'workers', + 'parse-worker.js', + ); + if (fs.existsSync(distWorker)) { + workerUrl = pathToFileURL(distWorker); + } + } + workerPool = createWorkerPool(workerUrl); + } catch (err) { + console.warn( + 'Worker pool creation failed, using sequential fallback:', + (err as Error).message, + ); + } + } + + let filesParsedSoFar = 0; + + // AST cache sized for one chunk (sequential fallback uses it for import/call/heritage) + const maxChunkFiles = chunks.reduce((max, c) => Math.max(max, c.length), 0); + let astCache = createASTCache(maxChunkFiles); + + // Build import resolution context once — suffix index, file lists, resolve cache. + const importCtx = buildImportResolutionContext(allPaths); + const allPathObjects = allPaths.map((p) => ({ path: p })); + + const sequentialChunkPaths: string[][] = []; + const chunkNeedsSynthesis = chunks.map((paths) => + paths.some((p) => { + const lang = getLanguageFromFilename(p); + return lang != null && needsSynthesis(lang); + }), + ); + const exportedTypeMap: ExportedTypeMap = new Map(); + const bindingAccumulator = new BindingAccumulator(); + // Tracks whether per-chunk or fallback wildcard-binding synthesis already + // ran, so the unconditional final call below can be skipped when redundant. + // synthesizeWildcardImportBindings is graph-global; once any chunk runs it + // after parsing wildcard files, later non-wildcard chunks add no work for + // it, and later wildcard chunks re-run it themselves. + let hasSynthesized = false; + const allFetchCalls: ExtractedFetchCall[] = []; + const allExtractedRoutes: ExtractedRoute[] = []; + const allDecoratorRoutes: ExtractedDecoratorRoute[] = []; + const allToolDefs: ExtractedToolDef[] = []; + const allORMQueries: ExtractedORMQuery[] = []; + const deferredWorkerCalls: ExtractedCall[] = []; + const deferredWorkerHeritage: ExtractedHeritage[] = []; + const deferredConstructorBindings: FileConstructorBindings[] = []; + const deferredAssignments: ExtractedAssignment[] = []; + + try { + for (let chunkIdx = 0; chunkIdx < numChunks; chunkIdx++) { + const chunkPaths = chunks[chunkIdx]; + + const chunkContents = await readFileContents(repoPath, chunkPaths); + const chunkFiles = chunkPaths + .filter((p) => chunkContents.has(p)) + .map((p) => ({ path: p, content: chunkContents.get(p)! })); + + const chunkWorkerData = await processParsing( + graph, + chunkFiles, + symbolTable, + astCache, + (current, _total, filePath) => { + const globalCurrent = filesParsedSoFar + current; + const parsingProgress = 20 + (globalCurrent / totalParseable) * 62; + onProgress({ + phase: 'parsing', + percent: Math.round(parsingProgress), + message: `Parsing chunk ${chunkIdx + 1}/${numChunks}...`, + detail: filePath, + stats: { + filesProcessed: globalCurrent, + totalFiles: totalParseable, + nodesCreated: graph.nodeCount, + }, + }); + }, + workerPool, + ); + + const chunkBasePercent = 20 + (filesParsedSoFar / totalParseable) * 62; + + if (chunkWorkerData) { + await processImportsFromExtracted( + graph, + allPathObjects, + chunkWorkerData.imports, + ctx, + (current, total) => { + onProgress({ + phase: 'parsing', + percent: Math.round(chunkBasePercent), + message: `Resolving imports (chunk ${chunkIdx + 1}/${numChunks})...`, + detail: `${current}/${total} files`, + stats: { + filesProcessed: filesParsedSoFar, + totalFiles: totalParseable, + nodesCreated: graph.nodeCount, + }, + }); + }, + repoPath, + importCtx, + ); + if (chunkNeedsSynthesis[chunkIdx]) { + synthesizeWildcardImportBindings(graph, ctx); + hasSynthesized = true; + } + if (exportedTypeMap.size > 0 && ctx.namedImportMap.size > 0) { + const { enrichedCount } = seedCrossFileReceiverTypes( + chunkWorkerData.calls, + ctx.namedImportMap, + exportedTypeMap, + ); + if (isDev && enrichedCount > 0) { + console.log( + `🔗 E1: Seeded ${enrichedCount} cross-file receiver types (chunk ${chunkIdx + 1})`, + ); + } + } + for (const item of chunkWorkerData.calls) deferredWorkerCalls.push(item); + for (const item of chunkWorkerData.heritage) deferredWorkerHeritage.push(item); + for (const item of chunkWorkerData.constructorBindings) + deferredConstructorBindings.push(item); + if (chunkWorkerData.assignments?.length) { + for (const item of chunkWorkerData.assignments) deferredAssignments.push(item); + } + + await Promise.all([ + processHeritageFromExtracted(graph, chunkWorkerData.heritage, ctx, (current, total) => { + onProgress({ + phase: 'parsing', + percent: Math.round(chunkBasePercent), + message: `Resolving heritage (chunk ${chunkIdx + 1}/${numChunks})...`, + detail: `${current}/${total} records`, + stats: { + filesProcessed: filesParsedSoFar, + totalFiles: totalParseable, + nodesCreated: graph.nodeCount, + }, + }); + }), + processRoutesFromExtracted(graph, chunkWorkerData.routes ?? [], ctx, (current, total) => { + onProgress({ + phase: 'parsing', + percent: Math.round(chunkBasePercent), + message: `Resolving routes (chunk ${chunkIdx + 1}/${numChunks})...`, + detail: `${current}/${total} routes`, + stats: { + filesProcessed: filesParsedSoFar, + totalFiles: totalParseable, + nodesCreated: graph.nodeCount, + }, + }); + }), + ]); + + if (chunkWorkerData.fileScopeBindings?.length) { + for (const { filePath, bindings } of chunkWorkerData.fileScopeBindings) { + if (typeof filePath !== 'string' || filePath.length === 0) continue; + if (!Array.isArray(bindings)) continue; + const entries: BindingEntry[] = []; + for (const tuple of bindings) { + if (!Array.isArray(tuple) || tuple.length !== 2) continue; + const [varName, typeName] = tuple; + if (typeof varName !== 'string' || typeof typeName !== 'string') continue; + entries.push({ scope: '', varName, typeName }); + } + if (entries.length > 0) { + bindingAccumulator.appendFile(filePath, entries); + } + } + } + if (chunkWorkerData.fetchCalls?.length) { + for (const item of chunkWorkerData.fetchCalls) allFetchCalls.push(item); + } + if (chunkWorkerData.routes?.length) { + for (const item of chunkWorkerData.routes) allExtractedRoutes.push(item); + } + if (chunkWorkerData.decoratorRoutes?.length) { + for (const item of chunkWorkerData.decoratorRoutes) allDecoratorRoutes.push(item); + } + if (chunkWorkerData.toolDefs?.length) { + for (const item of chunkWorkerData.toolDefs) allToolDefs.push(item); + } + if (chunkWorkerData.ormQueries?.length) { + for (const item of chunkWorkerData.ormQueries) allORMQueries.push(item); + } + } else { + await processImports(graph, chunkFiles, astCache, ctx, undefined, repoPath, allPaths); + sequentialChunkPaths.push(chunkPaths); + } + + filesParsedSoFar += chunkFiles.length; + astCache.clear(); + } + + const fullWorkerHeritageMap = + deferredWorkerHeritage.length > 0 + ? buildHeritageMap(deferredWorkerHeritage, ctx, getHeritageStrategyForLanguage) + : undefined; + + if (deferredWorkerCalls.length > 0) { + await processCallsFromExtracted( + graph, + deferredWorkerCalls, + ctx, + (current, total) => { + onProgress({ + phase: 'parsing', + percent: 82, + message: 'Resolving calls (all chunks)...', + detail: `${current}/${total} files`, + stats: { + filesProcessed: filesParsedSoFar, + totalFiles: totalParseable, + nodesCreated: graph.nodeCount, + }, + }); + }, + deferredConstructorBindings.length > 0 ? deferredConstructorBindings : undefined, + fullWorkerHeritageMap, + bindingAccumulator, + ); + } + + if (deferredAssignments.length > 0) { + processAssignmentsFromExtracted( + graph, + deferredAssignments, + ctx, + deferredConstructorBindings.length > 0 ? deferredConstructorBindings : undefined, + bindingAccumulator, + ); + } + } finally { + await workerPool?.terminate(); + } + + // Sequential fallback chunks. + // + // U6: wrap the fallback loop and the finalize/enrich steps in a try/finally + // so cleanup still runs on a mid-fallback throw. The `finally` guarantees: + // 1. `astCache.clear()` releases any tree-sitter trees held by the most + // recently allocated per-chunk cache, mirroring the per-chunk + // `astCache.clear()` calls on the happy path. + // 2. `bindingAccumulator.finalize()` runs before `crossFile` disposes the + // accumulator downstream — callers that inspect partial TypeEnv state + // (or consume it via `enrichExportedTypeMap` on a partial recovery) + // still see a finalized accumulator. + // 3. `enrichExportedTypeMap` runs so any bindings already accumulated + // are propagated into `exportedTypeMap` even if the fallback aborted. + // + // Disposal of the accumulator remains with `crossFile` (owned by U2). We do + // NOT call `bindingAccumulator.dispose()` here. + try { + if (sequentialChunkPaths.length > 0) { + synthesizeWildcardImportBindings(graph, ctx); + hasSynthesized = true; + } + const allSequentialHeritage: ExtractedHeritage[] = []; + const cachedSequentialChunkFiles: Array> = []; + for (const chunkPaths of sequentialChunkPaths) { + const chunkContents = await readFileContents(repoPath, chunkPaths); + const chunkFiles = chunkPaths + .filter((p) => chunkContents.has(p)) + .map((p) => ({ path: p, content: chunkContents.get(p)! })); + cachedSequentialChunkFiles.push(chunkFiles); + astCache = createASTCache(chunkFiles.length); + const sequentialHeritage = await extractExtractedHeritageFromFiles(chunkFiles, astCache); + for (const h of sequentialHeritage) allSequentialHeritage.push(h); + astCache.clear(); + } + const sequentialHeritageMap = + allSequentialHeritage.length > 0 + ? buildHeritageMap(allSequentialHeritage, ctx, getHeritageStrategyForLanguage) + : undefined; + + for (let chunkIdx = 0; chunkIdx < sequentialChunkPaths.length; chunkIdx++) { + const chunkFiles = cachedSequentialChunkFiles[chunkIdx]; + astCache = createASTCache(chunkFiles.length); + const rubyHeritage = await processCalls( + graph, + chunkFiles, + astCache, + ctx, + undefined, + exportedTypeMap, + undefined, + undefined, + undefined, + sequentialHeritageMap, + bindingAccumulator, + ); + await processHeritage(graph, chunkFiles, astCache, ctx); + if (rubyHeritage.length > 0) { + await processHeritageFromExtracted(graph, rubyHeritage, ctx); + } + const chunkFetchCalls = await extractFetchCallsFromFiles(chunkFiles, astCache); + if (chunkFetchCalls.length > 0) { + for (const item of chunkFetchCalls) allFetchCalls.push(item); + } + for (const f of chunkFiles) { + extractORMQueriesInline(f.path, f.content, allORMQueries); + } + astCache.clear(); + cachedSequentialChunkFiles[chunkIdx] = []; + } + + // Log resolution cache stats + if (isDev) { + const rcStats = ctx.getStats(); + const total = rcStats.cacheHits + rcStats.cacheMisses; + const hitRate = total > 0 ? ((rcStats.cacheHits / total) * 100).toFixed(1) : '0'; + console.log( + `🔍 Resolution cache: ${rcStats.cacheHits} hits, ${rcStats.cacheMisses} misses (${hitRate}% hit rate)`, + ); + } + } finally { + // Clearing an already-empty cache is a no-op, so this is idempotent-safe + // on the happy path where every per-chunk block already cleared astCache. + astCache.clear(); + + // Run finalize + enrichment inside try/catch so a cleanup failure never + // masks the original fallback error. finalize must precede crossFile's + // dispose (U2) and enrichExportedTypeMap depends on finalized bindings. + try { + bindingAccumulator.finalize(); + const enriched = enrichExportedTypeMap(bindingAccumulator, graph, exportedTypeMap); + if (isDev && enriched > 0) { + console.log( + `🔗 Worker TypeEnv enrichment: ${enriched} fixpoint-inferred exports added to ExportedTypeMap`, + ); + } + } catch (enrichErr) { + if (isDev) { + console.warn( + 'Post-fallback finalize/enrich failed during cleanup:', + (enrichErr as Error).message, + ); + } + } + } + + if (!hasSynthesized) { + const synthesized = synthesizeWildcardImportBindings(graph, ctx); + if (isDev && synthesized > 0) { + console.log( + `🔗 Synthesized ${synthesized} additional wildcard import bindings (Go/Ruby/C++/Swift/Python)`, + ); + } + } + + // Worker-path enrichment: if exportedTypeMap is empty (e.g. the worker pool + // built TypeEnv inside workers without access to SymbolTable), reconstruct + // the map from graph nodes + SymbolTable here in the main thread before + // handing the (now read-only) map to downstream phases. Doing it here means + // crossFile receives a fully-populated map and never needs to mutate it for + // initial-graph enrichment. + if (exportedTypeMap.size === 0 && graph.nodeCount > 0) { + const graphExports = buildExportedTypeMapFromGraph(graph, ctx.model.symbols); + for (const [fp, exports] of graphExports) exportedTypeMap.set(fp, exports); + } + + allPathObjects.length = 0; + // Safe to reset importCtx caches here: `importCtx` (ImportResolutionContext) + // is a scratch workspace used only during import path resolution. The + // `resolutionContext` (`ctx`) returned below is a distinct object — it owns + // the fully-populated, post-parse `importMap` / `namedImportMap` / + // `packageMap` / `moduleAliasMap` / `model`, and never references + // `importCtx`. Cross-file re-resolution in cross-file-impl.ts consumes only + // `ctx` (via `processCalls`), so clearing the suffix index / resolveCache / + // normalizedFileList here cannot lose import matches downstream. + importCtx.resolveCache.clear(); + importCtx.index = EMPTY_INDEX; + importCtx.normalizedFileList = []; + + return { + exportedTypeMap, + allFetchCalls, + allExtractedRoutes, + allDecoratorRoutes, + allToolDefs, + allORMQueries, + bindingAccumulator, + resolutionContext: ctx, + }; +} diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse.ts new file mode 100644 index 000000000..19ccec409 --- /dev/null +++ b/gitnexus/src/core/ingestion/pipeline-phases/parse.ts @@ -0,0 +1,92 @@ +/** + * Phase: parse + * + * Chunked parse + resolve loop: reads source in byte-budget chunks, + * parses via worker pool (or sequential fallback), resolves imports, + * heritage, and calls, synthesizes wildcard bindings. + * + * This phase encapsulates the entire `runChunkedParseAndResolve` function + * from the original pipeline. The chunk loop is a memory optimization + * internal to this phase, not a phase boundary. + * + * @deps structure, markdown, cobol + * @reads scannedFiles, allPaths, totalFiles (from structure) + * @writes graph (Symbol nodes, IMPORTS/CALLS/EXTENDS/IMPLEMENTS/ACCESSES edges) + * @output exportedTypeMap, allFetchCalls, allExtractedRoutes, allDecoratorRoutes, + * allToolDefs, allORMQueries, bindingAccumulator + */ + +import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js'; +import { getPhaseOutput } from './types.js'; +import type { StructureOutput } from './structure.js'; +import type { BindingAccumulator } from '../binding-accumulator.js'; +import type { + ExtractedFetchCall, + ExtractedRoute, + ExtractedDecoratorRoute, + ExtractedToolDef, + ExtractedORMQuery, +} from '../workers/parse-worker.js'; +import type { createResolutionContext } from '../model/resolution-context.js'; +import { runChunkedParseAndResolve } from './parse-impl.js'; + +export interface ParseOutput { + /** + * Read-only snapshot of exported type bindings keyed by file path. + * + * Fully populated by `parse` (sequential path via `enrichExportedTypeMap` + * and worker path via `buildExportedTypeMapFromGraph` in the main thread). + * Downstream phases — including `crossFile` — receive it as a true + * `ReadonlyMap`; `crossFile` builds its own mutable working copy locally + * for per-file re-resolution writes, so this snapshot is never mutated + * after parse returns. + */ + readonly exportedTypeMap: ReadonlyMap>; + readonly allFetchCalls: readonly ExtractedFetchCall[]; + readonly allExtractedRoutes: readonly ExtractedRoute[]; + readonly allDecoratorRoutes: readonly ExtractedDecoratorRoute[]; + readonly allToolDefs: readonly ExtractedToolDef[]; + readonly allORMQueries: readonly ExtractedORMQuery[]; + bindingAccumulator: BindingAccumulator; + /** Resolution context from the parse phase — carries importMap, namedImportMap, etc. */ + resolutionContext: ReturnType; + /** Pass-through: all file paths for downstream phases. */ + readonly allPaths: readonly string[]; + /** Pass-through: shared `allPathSet` from structure (built once, not per-phase). */ + readonly allPathSet: ReadonlySet; + /** Pass-through: total file count for progress reporting. */ + totalFiles: number; +} + +export const parsePhase: PipelinePhase = { + name: 'parse', + deps: ['structure', 'markdown', 'cobol'], + + async execute( + ctx: PipelineContext, + deps: ReadonlyMap>, + ): Promise { + const { scannedFiles, allPaths, allPathSet, totalFiles } = getPhaseOutput( + deps, + 'structure', + ); + + const result = await runChunkedParseAndResolve( + ctx.graph, + scannedFiles, + allPaths, + totalFiles, + ctx.repoPath, + ctx.pipelineStart, + ctx.onProgress, + ctx.options, + ); + + return { + ...result, + allPaths, + allPathSet, + totalFiles, + }; + }, +}; diff --git a/gitnexus/src/core/ingestion/pipeline-phases/processes.ts b/gitnexus/src/core/ingestion/pipeline-phases/processes.ts new file mode 100644 index 000000000..a525443d1 --- /dev/null +++ b/gitnexus/src/core/ingestion/pipeline-phases/processes.ts @@ -0,0 +1,171 @@ +/** + * Phase: processes + * + * Detects execution flows (processes) and creates Process nodes + + * STEP_IN_PROCESS edges. Also links Route/Tool nodes to processes. + * + * @deps communities, routes, tools + * @reads graph (all nodes and relationships), communityResult, routeRegistry, toolDefs + * @writes graph (Process nodes, STEP_IN_PROCESS edges, ENTRY_POINT_OF edges) + */ + +import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js'; +import { getPhaseOutput } from './types.js'; +import type { CommunitiesOutput } from './communities.js'; +import type { RoutesOutput } from './routes.js'; +import type { ToolsOutput } from './tools.js'; +import type { StructureOutput } from './structure.js'; +import { processProcesses, type ProcessDetectionResult } from '../process-processor.js'; +import { generateId } from '../../../lib/utils.js'; +import { isDev } from '../utils/env.js'; + +export interface ProcessesOutput { + processResult: ProcessDetectionResult; +} + +export const processesPhase: PipelinePhase = { + name: 'processes', + // `structure` supplies `totalFiles` (progress counter) without the spurious + // structural data dependency on `parse`. + deps: ['communities', 'routes', 'tools', 'structure'], + + async execute( + ctx: PipelineContext, + deps: ReadonlyMap>, + ): Promise { + const { totalFiles } = getPhaseOutput(deps, 'structure'); + const { communityResult } = getPhaseOutput(deps, 'communities'); + const { routeRegistry } = getPhaseOutput(deps, 'routes'); + const { toolDefs } = getPhaseOutput(deps, 'tools'); + + ctx.onProgress({ + phase: 'processes', + percent: 94, + message: 'Detecting execution flows...', + stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: ctx.graph.nodeCount }, + }); + + let symbolCount = 0; + ctx.graph.forEachNode((n) => { + if (n.label !== 'File') symbolCount++; + }); + const dynamicMaxProcesses = Math.max(20, Math.min(300, Math.round(symbolCount / 10))); + + const processResult = await processProcesses( + ctx.graph, + communityResult.memberships, + (message, progress) => { + const processProgress = 94 + progress * 0.05; + ctx.onProgress({ + phase: 'processes', + percent: Math.round(processProgress), + message, + stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: ctx.graph.nodeCount }, + }); + }, + { maxProcesses: dynamicMaxProcesses, minSteps: 3 }, + ); + + if (isDev) { + console.log( + `🔄 Process detection: ${processResult.stats.totalProcesses} processes found (${processResult.stats.crossCommunityCount} cross-community)`, + ); + } + + processResult.processes.forEach((proc) => { + ctx.graph.addNode({ + id: proc.id, + label: 'Process' as const, + properties: { + name: proc.label, + filePath: '', + heuristicLabel: proc.heuristicLabel, + processType: proc.processType, + stepCount: proc.stepCount, + communities: proc.communities, + entryPointId: proc.entryPointId, + terminalId: proc.terminalId, + }, + }); + }); + + processResult.steps.forEach((step) => { + ctx.graph.addRelationship({ + id: `${step.nodeId}_step_${step.step}_${step.processId}`, + type: 'STEP_IN_PROCESS', + sourceId: step.nodeId, + targetId: step.processId, + confidence: 1.0, + reason: 'trace-detection', + step: step.step, + }); + }); + + // Link Route and Tool nodes to Processes + if (routeRegistry.size > 0 || toolDefs.length > 0) { + const routesByFile = new Map(); + for (const [url, entry] of routeRegistry) { + let list = routesByFile.get(entry.filePath); + if (!list) { + list = []; + routesByFile.set(entry.filePath, list); + } + list.push(url); + } + const toolsByFile = new Map(); + for (const td of toolDefs) { + let list = toolsByFile.get(td.filePath); + if (!list) { + list = []; + toolsByFile.set(td.filePath, list); + } + list.push(td.name); + } + + let linked = 0; + for (const proc of processResult.processes) { + if (!proc.entryPointId) continue; + const entryNode = ctx.graph.getNode(proc.entryPointId); + if (!entryNode) continue; + const entryFile = entryNode.properties.filePath; + if (!entryFile) continue; + + const routeURLs = routesByFile.get(entryFile); + if (routeURLs) { + for (const routeURL of routeURLs) { + const routeNodeId = generateId('Route', routeURL); + ctx.graph.addRelationship({ + id: generateId('ENTRY_POINT_OF', `${routeNodeId}->${proc.id}`), + sourceId: routeNodeId, + targetId: proc.id, + type: 'ENTRY_POINT_OF', + confidence: 0.85, + reason: 'route-handler-entry-point', + }); + linked++; + } + } + const toolNames = toolsByFile.get(entryFile); + if (toolNames) { + for (const toolName of toolNames) { + const toolNodeId = generateId('Tool', toolName); + ctx.graph.addRelationship({ + id: generateId('ENTRY_POINT_OF', `${toolNodeId}->${proc.id}`), + sourceId: toolNodeId, + targetId: proc.id, + type: 'ENTRY_POINT_OF', + confidence: 0.85, + reason: 'tool-handler-entry-point', + }); + linked++; + } + } + } + if (isDev && linked > 0) { + console.log(`🔗 Linked ${linked} Route/Tool nodes to execution flows`); + } + } + + return { processResult }; + }, +}; diff --git a/gitnexus/src/core/ingestion/pipeline-phases/routes.ts b/gitnexus/src/core/ingestion/pipeline-phases/routes.ts new file mode 100644 index 000000000..cd0a65f9d --- /dev/null +++ b/gitnexus/src/core/ingestion/pipeline-phases/routes.ts @@ -0,0 +1,301 @@ +/** + * Phase: routes + * + * Builds the route registry (Next.js, Expo, PHP, Laravel, decorator-based) + * and creates Route graph nodes + HANDLES_ROUTE edges. + * Also links middleware, processes fetch() calls, and scans HTML templates. + * + * @deps parse + * @reads allPaths, allExtractedRoutes, allDecoratorRoutes, allFetchCalls + * @writes graph (Route nodes, HANDLES_ROUTE, FETCHES_FROM edges) + * @output routeRegistry, handlerContents + */ + +import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js'; +import { getPhaseOutput } from './types.js'; +import type { ParseOutput } from './parse.js'; +import { nextjsFileToRouteURL, normalizeFetchURL } from '../route-extractors/nextjs.js'; +import { expoFileToRouteURL } from '../route-extractors/expo.js'; +import { phpFileToRouteURL } from '../route-extractors/php.js'; +import { + extractResponseShapes, + extractPHPResponseShapes, +} from '../route-extractors/response-shapes.js'; +import { + extractMiddlewareChain, + extractNextjsMiddlewareConfig, + compileMatcher, + compiledMatcherMatchesRoute, +} from '../route-extractors/middleware.js'; +import { processNextjsFetchRoutes } from '../call-processor.js'; +import { generateId } from '../../../lib/utils.js'; +import { readFileContents } from '../filesystem-walker.js'; +import { isDev } from '../utils/env.js'; + +const EXPO_NAV_PATTERNS = [ + /router\.(push|replace|navigate)\(\s*['"`]([^'"`]+)['"`]/g, + /]*href=\s*['"`]([^'"`]+)['"`]/g, +]; + +export interface RouteEntry { + filePath: string; + source: string; +} + +export interface RoutesOutput { + routeRegistry: Map; +} + +export const routesPhase: PipelinePhase = { + name: 'routes', + deps: ['parse'], + + async execute( + ctx: PipelineContext, + deps: ReadonlyMap>, + ): Promise { + const { + allPaths, + allFetchCalls: parseFetchCalls, + allExtractedRoutes, + allDecoratorRoutes, + } = getPhaseOutput(deps, 'parse'); + + // Local copy — routes phase must not mutate upstream ParseOutput + const allFetchCalls = [...parseFetchCalls]; + + const routeRegistry = new Map(); + + // Detect Expo Router app/ roots vs Next.js app/ roots (monorepo-safe) + const expoAppRoots = new Set(); + const nextjsAppRoots = new Set(); + const expoAppPaths = new Set(); + for (const p of allPaths) { + const norm = p.replace(/\\/g, '/'); + const appIdx = norm.lastIndexOf('app/'); + if (appIdx < 0) continue; + const root = norm.slice(0, appIdx + 4); + if (/\/_layout\.(tsx?|jsx?)$/.test(norm)) expoAppRoots.add(root); + if (/\/page\.(tsx?|jsx?)$/.test(norm)) nextjsAppRoots.add(root); + } + for (const root of nextjsAppRoots) expoAppRoots.delete(root); + if (expoAppRoots.size > 0) { + for (const p of allPaths) { + const norm = p.replace(/\\/g, '/'); + const appIdx = norm.lastIndexOf('app/'); + if (appIdx >= 0 && expoAppRoots.has(norm.slice(0, appIdx + 4))) expoAppPaths.add(p); + } + } + + for (const p of allPaths) { + if (expoAppPaths.has(p)) { + const expoURL = expoFileToRouteURL(p); + if (expoURL && !routeRegistry.has(expoURL)) { + routeRegistry.set(expoURL, { filePath: p, source: 'expo-filesystem-route' }); + continue; + } + } + const nextjsURL = nextjsFileToRouteURL(p); + if (nextjsURL && !routeRegistry.has(nextjsURL)) { + routeRegistry.set(nextjsURL, { filePath: p, source: 'nextjs-filesystem-route' }); + continue; + } + if (p.endsWith('.php')) { + const phpURL = phpFileToRouteURL(p); + if (phpURL && !routeRegistry.has(phpURL)) { + routeRegistry.set(phpURL, { filePath: p, source: 'php-file-route' }); + } + } + } + + const ensureSlash = (path: string) => (path.startsWith('/') ? path : '/' + path); + let duplicateRoutes = 0; + const addRoute = (url: string, entry: RouteEntry) => { + if (routeRegistry.has(url)) { + duplicateRoutes++; + return; + } + routeRegistry.set(url, entry); + }; + for (const route of allExtractedRoutes) { + if (!route.routePath) continue; + addRoute(ensureSlash(route.routePath), { + filePath: route.filePath, + source: 'framework-route', + }); + } + for (const dr of allDecoratorRoutes) { + addRoute(ensureSlash(dr.routePath), { + filePath: dr.filePath, + source: `decorator-${dr.decoratorName}`, + }); + } + + let handlerContents: Map | undefined; + if (routeRegistry.size > 0) { + const handlerPaths = [...routeRegistry.values()].map((e) => e.filePath); + handlerContents = await readFileContents(ctx.repoPath, handlerPaths); + + for (const [routeURL, entry] of routeRegistry) { + const { filePath: handlerPath, source: routeSource } = entry; + const content = handlerContents.get(handlerPath); + + const { responseKeys, errorKeys } = content + ? handlerPath.endsWith('.php') + ? extractPHPResponseShapes(content) + : extractResponseShapes(content) + : { responseKeys: undefined, errorKeys: undefined }; + + const mwResult = content ? extractMiddlewareChain(content) : undefined; + const middleware = mwResult?.chain; + + const routeNodeId = generateId('Route', routeURL); + ctx.graph.addNode({ + id: routeNodeId, + label: 'Route', + properties: { + name: routeURL, + filePath: handlerPath, + ...(responseKeys ? { responseKeys } : {}), + ...(errorKeys ? { errorKeys } : {}), + ...(middleware && middleware.length > 0 ? { middleware } : {}), + }, + }); + + const handlerFileId = generateId('File', handlerPath); + ctx.graph.addRelationship({ + id: generateId('HANDLES_ROUTE', `${handlerFileId}->${routeNodeId}`), + sourceId: handlerFileId, + targetId: routeNodeId, + type: 'HANDLES_ROUTE', + confidence: 1.0, + reason: routeSource, + }); + } + + if (isDev) { + console.log( + `🗺️ Route registry: ${routeRegistry.size} routes${duplicateRoutes > 0 ? ` (${duplicateRoutes} duplicate URLs skipped)` : ''}`, + ); + } + } + + // ── Link Next.js project-level middleware.ts to routes ── + if (routeRegistry.size > 0) { + const middlewareCandidates = allPaths.filter( + (p) => + p === 'middleware.ts' || + p === 'middleware.js' || + p === 'middleware.tsx' || + p === 'middleware.jsx' || + p === 'src/middleware.ts' || + p === 'src/middleware.js' || + p === 'src/middleware.tsx' || + p === 'src/middleware.jsx', + ); + if (middlewareCandidates.length > 0) { + const mwContents = await readFileContents(ctx.repoPath, middlewareCandidates); + for (const [mwPath, mwContent] of mwContents) { + const config = extractNextjsMiddlewareConfig(mwContent); + if (!config) continue; + const mwLabel = + config.wrappedFunctions.length > 0 ? config.wrappedFunctions : [config.exportedName]; + + const compiled = config.matchers + .map(compileMatcher) + .filter((m): m is NonNullable => m !== null); + + let linkedCount = 0; + for (const [routeURL] of routeRegistry) { + const matches = + compiled.length === 0 || + compiled.some((cm) => compiledMatcherMatchesRoute(cm, routeURL)); + if (!matches) continue; + + const routeNodeId = generateId('Route', routeURL); + const existing = ctx.graph.getNode(routeNodeId); + if (!existing) continue; + + const currentMw = existing.properties.middleware ?? []; + existing.properties.middleware = [ + ...mwLabel, + ...currentMw.filter((m) => !mwLabel.includes(m)), + ]; + linkedCount++; + } + if (isDev && linkedCount > 0) { + console.log( + `🛡️ Linked ${mwPath} middleware [${mwLabel.join(', ')}] to ${linkedCount} routes`, + ); + } + } + } + } + + // Scan HTML/template files for form action and AJAX url patterns + const htmlCandidates = allPaths.filter( + (p) => + p.endsWith('.html') || + p.endsWith('.htm') || + p.endsWith('.ejs') || + p.endsWith('.hbs') || + p.endsWith('.blade.php'), + ); + if (htmlCandidates.length > 0 && routeRegistry.size > 0) { + const htmlContents = await readFileContents(ctx.repoPath, htmlCandidates); + const htmlPatterns = [/action=["']([^"']+)["']/g, /url:\s*["']([^"']+)["']/g]; + for (const [filePath, content] of htmlContents) { + for (const pattern of htmlPatterns) { + pattern.lastIndex = 0; + let match; + while ((match = pattern.exec(content)) !== null) { + const normalized = normalizeFetchURL(match[1]); + if (normalized) { + allFetchCalls.push({ filePath, fetchURL: normalized, lineNumber: 0 }); + } + } + } + } + } + + // ── Extract Expo Router navigation patterns ── + if (expoAppPaths.size > 0 && routeRegistry.size > 0) { + const unreadExpoPaths = [...expoAppPaths].filter((p) => !handlerContents?.has(p)); + const extraContents = + unreadExpoPaths.length > 0 + ? await readFileContents(ctx.repoPath, unreadExpoPaths) + : new Map(); + const allExpoContents = new Map([...(handlerContents ?? new Map()), ...extraContents]); + for (const [filePath, content] of allExpoContents) { + if (!expoAppPaths.has(filePath)) continue; + for (const pattern of EXPO_NAV_PATTERNS) { + pattern.lastIndex = 0; + let match; + while ((match = pattern.exec(content)) !== null) { + const url = match[2] ?? match[1]; + if (url && url.startsWith('/')) { + allFetchCalls.push({ filePath, fetchURL: url, lineNumber: 0 }); + } + } + } + } + } + + if (routeRegistry.size > 0 && allFetchCalls.length > 0) { + const routeURLToFile = new Map(); + for (const [url, entry] of routeRegistry) routeURLToFile.set(url, entry.filePath); + + const consumerPaths = [...new Set(allFetchCalls.map((c) => c.filePath))]; + const consumerContents = await readFileContents(ctx.repoPath, consumerPaths); + + processNextjsFetchRoutes(ctx.graph, allFetchCalls, routeURLToFile, consumerContents); + if (isDev) { + console.log( + `🔗 Processed ${allFetchCalls.length} fetch() calls against ${routeRegistry.size} routes`, + ); + } + } + + return { routeRegistry }; + }, +}; diff --git a/gitnexus/src/core/ingestion/pipeline-phases/runner.ts b/gitnexus/src/core/ingestion/pipeline-phases/runner.ts new file mode 100644 index 000000000..89543e049 --- /dev/null +++ b/gitnexus/src/core/ingestion/pipeline-phases/runner.ts @@ -0,0 +1,228 @@ +/** + * Pipeline Phase Runner + * + * Executes pipeline phases in dependency order using Kahn's topological sort. + * Each phase receives typed outputs from its upstream dependencies. + * + * The runner is intentionally simple: + * - No dynamic phase loading + * - No plugin system + * - Static phase graph, compile-time type safety + * - Sequential execution (parallel support is architecturally possible + * but most phases have linear dependencies) + */ + +import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js'; +import { isDev } from '../utils/env.js'; + +/** + * Validate that the phases form a valid dependency graph (no cycles, all deps present). + * Returns phases in topological execution order. + */ +function topologicalSort(phases: readonly PipelinePhase[]): PipelinePhase[] { + const phaseMap = new Map(); + for (const phase of phases) { + if (phaseMap.has(phase.name)) { + throw new Error(`Duplicate phase name: '${phase.name}'`); + } + phaseMap.set(phase.name, phase); + } + + // Validate all deps exist + for (const phase of phases) { + for (const dep of phase.deps) { + if (!phaseMap.has(dep)) { + throw new Error(`Phase '${phase.name}' depends on '${dep}', which is not registered`); + } + } + } + + // Kahn's algorithm + const inDegree = new Map(); + const reverseDeps = new Map(); + + for (const phase of phases) { + inDegree.set(phase.name, phase.deps.length); + for (const dep of phase.deps) { + let rev = reverseDeps.get(dep); + if (!rev) { + rev = []; + reverseDeps.set(dep, rev); + } + rev.push(phase.name); + } + } + + const sorted: PipelinePhase[] = []; + const queue = [...inDegree.entries()].filter(([, d]) => d === 0).map(([name]) => name); + + while (queue.length > 0) { + const name = queue.shift()!; + sorted.push(phaseMap.get(name)!); + + for (const dependent of reverseDeps.get(name) ?? []) { + const newDeg = (inDegree.get(dependent) ?? 1) - 1; + inDegree.set(dependent, newDeg); + if (newDeg === 0) queue.push(dependent); + } + } + + if (sorted.length !== phases.length) { + const remaining = new Set( + [...inDegree.entries()].filter(([, d]) => d > 0).map(([name]) => name), + ); + const cyclePath = findCyclePath(remaining, phaseMap); + const dependentsBlocked = remaining.size - new Set(cyclePath).size; + let message = `Cycle detected in pipeline phases: ${cyclePath.join(' -> ')}`; + if (dependentsBlocked > 0) { + message += ` (and ${dependentsBlocked} transitive dependent${dependentsBlocked === 1 ? '' : 's'} blocked)`; + } + throw new Error(message); + } + + return sorted; +} + +/** + * Find a concrete cycle path among the phases that Kahn's algorithm could not drain. + * + * Kahn's leftovers include both true cycle members AND phases transitively dependent + * on them. To produce an actionable error message, we DFS over the leftovers (using + * each leftover's `deps` as edges) until we hit a back-edge — that closes the cycle. + * The returned list is the cycle in order with the entry node repeated at the end: + * `[A, B, C, A]` for `A -> B -> C -> A`. + * + * Falls back to the raw remaining set (sorted) if no back-edge is found, which + * should be unreachable but keeps the error informative. + */ +function findCyclePath( + remaining: ReadonlySet, + phaseMap: ReadonlyMap, +): string[] { + for (const start of remaining) { + const stack: string[] = []; + const onStack = new Set(); + const visited = new Set(); + + const dfs = (name: string): string[] | null => { + stack.push(name); + onStack.add(name); + visited.add(name); + + const phase = phaseMap.get(name); + if (phase) { + for (const dep of phase.deps) { + if (!remaining.has(dep)) continue; // dep already drained — not part of cycle + if (onStack.has(dep)) { + // Back-edge — slice from the first occurrence of `dep` and close the loop. + const cycleStart = stack.indexOf(dep); + return [...stack.slice(cycleStart), dep]; + } + if (!visited.has(dep)) { + const found = dfs(dep); + if (found) return found; + } + } + } + + stack.pop(); + onStack.delete(name); + return null; + }; + + const cycle = dfs(start); + if (cycle) return cycle; + } + // Unreachable in practice (Kahn proved a cycle exists), but stay defensive. + return [...remaining].sort(); +} + +/** + * Execute a set of pipeline phases in dependency order. + * + * @param phases All phases to execute (order doesn't matter — sorted internally) + * @param ctx Shared pipeline context + * @returns Map of phase name → PhaseResult (all completed phases) + */ +export async function runPipeline( + phases: readonly PipelinePhase[], + ctx: PipelineContext, +): Promise>> { + let sorted: PipelinePhase[]; + try { + sorted = topologicalSort(phases); + } catch (err) { + // Emit a terminal 'error' progress event for graph-validation failures + // (cycle detected, duplicate phase, missing dep) so CLI/MCP consumers see + // the failure before the rejection propagates. Symmetric with the + // per-phase error path below. Best-effort: a throwing handler must not + // mask the underlying validation error. + const message = err instanceof Error ? err.message : String(err); + try { + ctx.onProgress({ + phase: 'error', + percent: 100, + message: 'Pipeline graph validation failed', + detail: message, + }); + } catch { + // Swallow handler errors — preserving the original cause is more important. + } + throw err; + } + const results = new Map>(); + + for (const phase of sorted) { + const start = Date.now(); + + if (isDev) { + console.log(`▶ Phase: ${phase.name}`); + } + + // Only expose declared dependencies — prevents hidden coupling to undeclared phases. + const declaredDeps = new Map>(); + for (const depName of phase.deps) { + const depResult = results.get(depName); + if (depResult) declaredDeps.set(depName, depResult); + } + + let output: unknown; + try { + output = await phase.execute(ctx, declaredDeps); + } catch (err) { + const originalMessage = err instanceof Error ? err.message : String(err); + const wrapped = new Error(`Phase '${phase.name}' failed: ${originalMessage}`, { + cause: err, + }); + + // Emit a terminal 'error' progress event so CLI/MCP consumers see the failure + // before the rejection propagates. Best-effort: a throwing handler must not + // mask the underlying phase error. + try { + ctx.onProgress({ + phase: 'error', + percent: 100, + message: `Phase '${phase.name}' failed`, + detail: originalMessage, + }); + } catch { + // Swallow handler errors — preserving the original cause is more important. + } + + throw wrapped; + } + const durationMs = Date.now() - start; + + results.set(phase.name, { + phaseName: phase.name, + output, + durationMs, + }); + + if (isDev) { + console.log(`✓ Phase: ${phase.name} (${durationMs}ms)`); + } + } + + return results; +} diff --git a/gitnexus/src/core/ingestion/pipeline-phases/scan.ts b/gitnexus/src/core/ingestion/pipeline-phases/scan.ts new file mode 100644 index 000000000..5a1353267 --- /dev/null +++ b/gitnexus/src/core/ingestion/pipeline-phases/scan.ts @@ -0,0 +1,60 @@ +/** + * Phase: scan + * + * Walks the repository filesystem and collects file paths + sizes. + * Does NOT read file contents — that happens in downstream phases. + * + * @deps (none — this is the pipeline root) + * @reads repoPath (filesystem) + * @writes graph (nothing yet — just returns scanned paths) + * @output ScannedFile[], allPaths[], totalFiles + */ + +import type { PipelinePhase, PipelineContext } from './types.js'; +import { walkRepositoryPaths } from '../filesystem-walker.js'; + +export interface ScanOutput { + scannedFiles: { path: string; size: number }[]; + allPaths: string[]; + totalFiles: number; +} + +export const scanPhase: PipelinePhase = { + name: 'scan', + deps: [], + + async execute(ctx: PipelineContext): Promise { + ctx.onProgress({ + phase: 'extracting', + percent: 0, + message: 'Scanning repository...', + }); + + const scannedFiles = await walkRepositoryPaths(ctx.repoPath, (current, total, filePath) => { + const scanProgress = Math.round((current / total) * 15); + ctx.onProgress({ + phase: 'extracting', + percent: scanProgress, + message: 'Scanning repository...', + detail: filePath, + stats: { + filesProcessed: current, + totalFiles: total, + nodesCreated: ctx.graph.nodeCount, + }, + }); + }); + + const totalFiles = scannedFiles.length; + const allPaths = scannedFiles.map((f) => f.path); + + ctx.onProgress({ + phase: 'extracting', + percent: 15, + message: 'Repository scanned successfully', + stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: ctx.graph.nodeCount }, + }); + + return { scannedFiles, allPaths, totalFiles }; + }, +}; diff --git a/gitnexus/src/core/ingestion/pipeline-phases/structure.ts b/gitnexus/src/core/ingestion/pipeline-phases/structure.ts new file mode 100644 index 000000000..35ca10c1e --- /dev/null +++ b/gitnexus/src/core/ingestion/pipeline-phases/structure.ts @@ -0,0 +1,62 @@ +/** + * Phase: structure + * + * Builds File and Folder nodes in the graph from scanned paths. + * + * @deps scan + * @reads allPaths (from scan phase) + * @writes graph (File, Folder nodes + CONTAINS edges) + */ + +import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js'; +import { getPhaseOutput } from './types.js'; +import { processStructure } from '../structure-processor.js'; +import type { ScanOutput } from './scan.js'; + +/** Structure phase produces no additional data — it writes directly to the graph. */ +export interface StructureOutput { + /** Pass-through from scan for downstream phases. */ + scannedFiles: { path: string; size: number }[]; + allPaths: string[]; + /** + * Materialized once here and shared across all downstream consumers + * (cobol, markdown, cross-file propagation). Avoids the previous + * per-phase `new Set(allPaths)` allocations on multi-thousand-file repos. + */ + allPathSet: ReadonlySet; + totalFiles: number; +} + +export const structurePhase: PipelinePhase = { + name: 'structure', + deps: ['scan'], + + async execute( + ctx: PipelineContext, + deps: ReadonlyMap>, + ): Promise { + const { scannedFiles, allPaths, totalFiles } = getPhaseOutput(deps, 'scan'); + + ctx.onProgress({ + phase: 'structure', + percent: 15, + message: 'Analyzing project structure...', + stats: { filesProcessed: 0, totalFiles, nodesCreated: ctx.graph.nodeCount }, + }); + + processStructure(ctx.graph, allPaths); + + ctx.onProgress({ + phase: 'structure', + percent: 20, + message: 'Project structure analyzed', + stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: ctx.graph.nodeCount }, + }); + + // Build the set once here so cobol, markdown, and cross-file propagation + // can all reuse it instead of re-materializing `new Set(allPaths)` each. + const allPathSet: ReadonlySet = new Set(allPaths); + + return { scannedFiles, allPaths, allPathSet, totalFiles }; + }, +}; diff --git a/gitnexus/src/core/ingestion/pipeline-phases/tools.ts b/gitnexus/src/core/ingestion/pipeline-phases/tools.ts new file mode 100644 index 000000000..5c6baf86d --- /dev/null +++ b/gitnexus/src/core/ingestion/pipeline-phases/tools.ts @@ -0,0 +1,105 @@ +/** + * Phase: tools + * + * Detects MCP/RPC tool definitions and creates Tool graph nodes. + * + * @deps parse + * @reads allToolDefs (from parse), allPaths + * @writes graph (Tool nodes, HANDLES_TOOL edges) + * @output toolDefs array + */ + +import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js'; +import { getPhaseOutput } from './types.js'; +import type { ParseOutput } from './parse.js'; +import { generateId } from '../../../lib/utils.js'; +import { readFileContents } from '../filesystem-walker.js'; +import { isDev } from '../utils/env.js'; + +export interface ToolDef { + name: string; + filePath: string; + description: string; +} + +export interface ToolsOutput { + toolDefs: ToolDef[]; +} + +export const toolsPhase: PipelinePhase = { + name: 'tools', + deps: ['parse'], + + async execute( + ctx: PipelineContext, + deps: ReadonlyMap>, + ): Promise { + const { allToolDefs, allPaths } = getPhaseOutput(deps, 'parse'); + + const toolDefs: ToolDef[] = []; + const seenToolNames = new Set(); + + for (const td of allToolDefs) { + if (seenToolNames.has(td.toolName)) continue; + seenToolNames.add(td.toolName); + toolDefs.push({ name: td.toolName, filePath: td.filePath, description: td.description }); + } + + // TS tool definition arrays — require inputSchema nearby + const toolCandidatePaths = allPaths.filter( + (p) => + (p.endsWith('.ts') || p.endsWith('.js')) && + p.toLowerCase().includes('tool') && + !p.includes('node_modules') && + !p.includes('test') && + !p.includes('__'), + ); + if (toolCandidatePaths.length > 0) { + const toolContents = await readFileContents(ctx.repoPath, toolCandidatePaths); + for (const [filePath, content] of toolContents) { + if (!content.includes('inputSchema')) continue; + const toolPattern = + /name:\s*['"](\w+)['"]\s*,\s*\n?\s*description:\s*[`'"]([\s\S]*?)[`'"]/g; + let match; + while ((match = toolPattern.exec(content)) !== null) { + const name = match[1]; + if (seenToolNames.has(name)) continue; + seenToolNames.add(name); + toolDefs.push({ + name, + filePath, + description: match[2].slice(0, 200).replace(/\n/g, ' ').trim(), + }); + } + } + } + + // Create Tool nodes and HANDLES_TOOL edges + if (toolDefs.length > 0) { + for (const td of toolDefs) { + const toolNodeId = generateId('Tool', td.name); + ctx.graph.addNode({ + id: toolNodeId, + label: 'Tool', + properties: { name: td.name, filePath: td.filePath, description: td.description }, + }); + + const handlerFileId = generateId('File', td.filePath); + ctx.graph.addRelationship({ + id: generateId('HANDLES_TOOL', `${handlerFileId}->${toolNodeId}`), + sourceId: handlerFileId, + targetId: toolNodeId, + type: 'HANDLES_TOOL', + confidence: 1.0, + reason: 'tool-definition', + }); + } + + if (isDev) { + console.log(`🔧 Tool registry: ${toolDefs.length} tools detected`); + } + } + + return { toolDefs }; + }, +}; diff --git a/gitnexus/src/core/ingestion/pipeline-phases/types.ts b/gitnexus/src/core/ingestion/pipeline-phases/types.ts new file mode 100644 index 000000000..17786ff4c --- /dev/null +++ b/gitnexus/src/core/ingestion/pipeline-phases/types.ts @@ -0,0 +1,101 @@ +/** + * Pipeline Phase — Type definitions. + * + * Each phase is a named node in the dependency graph with typed inputs and outputs. + * The runner resolves dependencies via topological sort and passes + * typed results from upstream phases as inputs to downstream phases. + * + * Design goals: + * - Explicit data flow between phases via typed outputs + * - The knowledge graph is a shared mutable accumulator — phases add nodes/edges + * and may read prior phases' contributions. This is intentional: the graph is + * the pipeline's primary output, not an inter-phase communication channel. + * - Compile-time exhaustiveness (adding a phase = type error until wired) + * - Each phase is independently testable with mocked inputs + */ + +import type { KnowledgeGraph } from '../../graph/types.js'; +import type { PipelineProgress } from 'gitnexus-shared'; +import type { PipelineOptions } from '../pipeline.js'; + +// ── Shared context ───────────────────────────────────────────────────────── + +/** Immutable context available to every phase. */ +export interface PipelineContext { + /** Absolute path to the repository root. */ + readonly repoPath: string; + /** Mutable knowledge graph — the single shared accumulator. */ + readonly graph: KnowledgeGraph; + /** Progress callback for UI updates. */ + readonly onProgress: (progress: PipelineProgress) => void; + /** Pipeline options (skipGraphPhases, skipWorkers, etc.). */ + readonly options?: PipelineOptions; + /** Pipeline start timestamp (for elapsed-time logging). */ + readonly pipelineStart: number; +} + +// ── Phase result wrapper ─────────────────────────────────────────────────── + +/** Wraps a phase's output with timing metadata. */ +export interface PhaseResult { + /** Phase name (matches the phase's `name` field). */ + readonly phaseName: string; + /** The typed output of the phase. */ + readonly output: T; + /** Wall-clock duration in milliseconds. */ + readonly durationMs: number; +} + +// ── Phase definition ─────────────────────────────────────────────────────── + +/** + * A single phase in the ingestion pipeline. + * + * @typeParam TDeps - Tuple of dependency phase output types + * @typeParam TOutput - This phase's output type + */ +export interface PipelinePhase { + /** Unique name for logging and result lookup. */ + readonly name: string; + + /** + * Names of phases this phase depends on. + * The runner guarantees these have completed before execute() is called. + */ + readonly deps: readonly string[]; + + /** + * Execute the phase. + * + * @param ctx Shared pipeline context (graph, repoPath, progress, options) + * @param deps Map of dependency name → PhaseResult (typed outputs from upstream phases) + * @returns The phase's typed output + */ + execute(ctx: PipelineContext, deps: ReadonlyMap>): Promise; +} + +/** + * Helper to extract the typed output of a dependency phase. + * + * Type safety note: This uses an `as T` cast because the runner stores + * heterogeneous phase outputs in a single `Map>`. + * The cast is safe as long as callers use the correct output type for the + * named phase. Mismatches will surface as runtime type errors, not compile-time + * errors — this is an intentional trade-off for a static phase graph without + * a dynamic type registry. + * + * @param deps The resolved dependency map from the runner + * @param phaseName The name of the upstream phase whose output you need + * @returns The typed output of the phase + * @throws If the phase is not found in the dependency map + */ +export function getPhaseOutput( + deps: ReadonlyMap>, + phaseName: string, +): T { + const result = deps.get(phaseName); + if (!result) { + throw new Error(`Phase '${phaseName}' not found in resolved dependencies`); + } + return result.output as T; +} diff --git a/gitnexus/src/core/ingestion/pipeline-phases/wildcard-synthesis.ts b/gitnexus/src/core/ingestion/pipeline-phases/wildcard-synthesis.ts new file mode 100644 index 000000000..c2c0f986d --- /dev/null +++ b/gitnexus/src/core/ingestion/pipeline-phases/wildcard-synthesis.ts @@ -0,0 +1,195 @@ +/** + * Wildcard import binding synthesis. + * + * Languages with whole-module import semantics (Go, Ruby, C/C++, Swift) + * import all exported symbols from a file, not specific named symbols. + * After parsing, we know which symbols each file exports (via graph + * `isExported`), so we can expand IMPORTS edges into per-symbol bindings + * that the cross-file propagation phase can use for type resolution. + * + * Also builds Python module-alias maps for namespace-import languages + * (`import models` → `models.User()` resolves to `models.py:User`). + * + * @module + */ + +import type { KnowledgeGraph } from '../../graph/types.js'; +import type { createResolutionContext } from '../model/resolution-context.js'; +import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared'; +import { providers, getProviderForFile } from '../languages/index.js'; + +// ── Constants ────────────────────────────────────────────────────────────── + +/** Node labels that represent top-level importable symbols. */ +const IMPORTABLE_SYMBOL_LABELS = new Set([ + 'Function', + 'Class', + 'Interface', + 'Struct', + 'Enum', + 'Trait', + 'TypeAlias', + 'Const', + 'Static', + 'Record', + 'Union', + 'Typedef', + 'Macro', +]); + +/** Max synthetic bindings per importing file — prevents memory bloat + * for C/C++ files that include many large headers. */ +const MAX_SYNTHETIC_BINDINGS_PER_FILE = 1000; + +/** Languages with whole-module import semantics (derived from providers at module load). */ +const WILDCARD_LANGUAGES = new Set( + Object.values(providers) + .filter((p) => p.importSemantics === 'wildcard') + .map((p) => p.id), +); + +/** Languages that need binding synthesis before call resolution. */ +const SYNTHESIS_LANGUAGES = new Set( + Object.values(providers) + .filter((p) => p.importSemantics !== 'named') + .map((p) => p.id), +); + +/** Check if a language uses wildcard (whole-module) import semantics. */ +export function isWildcardImportLanguage(lang: SupportedLanguages): boolean { + return WILDCARD_LANGUAGES.has(lang); +} + +/** Check if a language needs synthesis before call resolution. + * True for wildcard-import languages AND namespace-import languages (Python). */ +export function needsSynthesis(lang: SupportedLanguages): boolean { + return SYNTHESIS_LANGUAGES.has(lang); +} + +// ── Main synthesis function ──────────────────────────────────────────────── + +/** + * Synthesize namedImportMap entries for languages with whole-module imports. + * + * For each file that imports another file via wildcard semantics: + * 1. Look up all exported symbols from the imported file (via graph nodes) + * 2. Create synthetic named bindings: `{ name → { sourcePath, exportedName } }` + * 3. Build Python module-alias maps for namespace-import languages + * + * @param graph The knowledge graph with parsed symbol nodes + * @param ctx Resolution context with importMap and namedImportMap + * @returns Number of synthetic bindings created + */ +export function synthesizeWildcardImportBindings( + graph: KnowledgeGraph, + ctx: ReturnType, +): number { + // Build exported symbols index from graph nodes (single pass) + const exportedSymbolsByFile = new Map(); + graph.forEachNode((node) => { + if (!node.properties?.isExported) return; + if (!IMPORTABLE_SYMBOL_LABELS.has(node.label)) return; + const fp = node.properties.filePath; + const name = node.properties.name; + if (!fp || !name) return; + let symbols = exportedSymbolsByFile.get(fp); + if (!symbols) { + symbols = []; + exportedSymbolsByFile.set(fp, symbols); + } + symbols.push({ name, filePath: fp }); + }); + + if (exportedSymbolsByFile.size === 0) return 0; + + // Collect graph-level IMPORTS edges for wildcard languages missing from ctx.importMap + const FILE_PREFIX = 'File:'; + const graphImports = new Map>(); + graph.forEachRelationship((rel) => { + if (rel.type !== 'IMPORTS') return; + if (!rel.sourceId.startsWith(FILE_PREFIX) || !rel.targetId.startsWith(FILE_PREFIX)) return; + const srcFile = rel.sourceId.slice(FILE_PREFIX.length); + const tgtFile = rel.targetId.slice(FILE_PREFIX.length); + const lang = getLanguageFromFilename(srcFile); + if (!lang || !isWildcardImportLanguage(lang)) return; + if (ctx.importMap.get(srcFile)?.has(tgtFile)) return; + let set = graphImports.get(srcFile); + if (!set) { + set = new Set(); + graphImports.set(srcFile, set); + } + set.add(tgtFile); + }); + + let totalSynthesized = 0; + + const synthesizeForFile = (filePath: string, importedFiles: Iterable) => { + let fileBindings = ctx.namedImportMap.get(filePath); + let fileCount = fileBindings?.size ?? 0; + + for (const importedFile of importedFiles) { + const exportedSymbols = exportedSymbolsByFile.get(importedFile); + if (!exportedSymbols) continue; + + for (const sym of exportedSymbols) { + if (fileCount >= MAX_SYNTHETIC_BINDINGS_PER_FILE) return; + if (fileBindings?.has(sym.name)) continue; + + if (!fileBindings) { + fileBindings = new Map(); + ctx.namedImportMap.set(filePath, fileBindings); + } + fileBindings.set(sym.name, { + sourcePath: importedFile, + exportedName: sym.name, + }); + fileCount++; + totalSynthesized++; + } + } + }; + + // Synthesize from ctx.importMap (Ruby, C/C++, Swift file-based imports) + for (const [filePath, importedFiles] of ctx.importMap) { + const lang = getLanguageFromFilename(filePath); + if (!lang || !isWildcardImportLanguage(lang)) continue; + synthesizeForFile(filePath, importedFiles); + } + + // Synthesize from graph IMPORTS edges (Go and other wildcard-import languages) + for (const [filePath, importedFiles] of graphImports) { + synthesizeForFile(filePath, importedFiles); + } + + // Build Python module-alias maps for namespace-import languages. + // `import models` in app.py → moduleAliasMap['app.py']['models'] = 'models.py' + // Enables `models.User()` to resolve without ambiguous symbol expansion. + for (const [filePath, importedFiles] of ctx.importMap) { + const provider = getProviderForFile(filePath); + if (!provider || provider.importSemantics !== 'namespace') continue; + buildPythonModuleAliasForFile(ctx, filePath, importedFiles); + } + + return totalSynthesized; +} + +/** Build module alias entries for namespace-import files (e.g. Python). */ +function buildPythonModuleAliasForFile( + ctx: ReturnType, + callerFile: string, + importedFiles: Iterable, +): void { + let aliasMap = ctx.moduleAliasMap.get(callerFile); + for (const importedFile of importedFiles) { + const lastSlash = importedFile.lastIndexOf('/'); + const base = lastSlash >= 0 ? importedFile.slice(lastSlash + 1) : importedFile; + const dot = base.lastIndexOf('.'); + const stem = dot >= 0 ? base.slice(0, dot) : base; + if (!stem) continue; + if (!aliasMap) { + aliasMap = new Map(); + ctx.moduleAliasMap.set(callerFile, aliasMap); + } + aliasMap.set(stem, importedFile); + } +} diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index c3776d50a..c0cfa2992 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -1,494 +1,42 @@ +/** + * Pipeline orchestrator — dependency-ordered ingestion pipeline. + * + * The pipeline is composed of named phases with explicit dependencies. + * Each phase is defined in its own file under `pipeline-phases/`. + * The runner in `pipeline-phases/runner.ts` executes phases in + * topological order, passing typed outputs from upstream phases as + * inputs to downstream phases. + * + * To add a new phase: + * 1. Create a new file in `pipeline-phases/` following the pattern + * 2. Export it from `pipeline-phases/index.ts` + * 3. Add it to the `ALL_PHASES` array below + * + * See ARCHITECTURE.md for the full phase dependency diagram. + */ + import { createKnowledgeGraph } from '../graph/graph.js'; -import { - BindingAccumulator, - enrichExportedTypeMap, - type BindingEntry, -} from './binding-accumulator.js'; -import { processStructure } from './structure-processor.js'; -import { processMarkdown } from './markdown-processor.js'; -import { processCobol, isCobolFile, isJclFile } from './cobol-processor.js'; -import { processParsing } from './parsing-processor.js'; -import { - processImports, - processImportsFromExtracted, - buildImportResolutionContext, -} from './import-processor.js'; -import { EMPTY_INDEX } from './import-resolvers/utils.js'; -import { - processCalls, - processCallsFromExtracted, - processAssignmentsFromExtracted, - processRoutesFromExtracted, - processNextjsFetchRoutes, - extractFetchCallsFromFiles, - seedCrossFileReceiverTypes, - buildImportedReturnTypes, - buildImportedRawReturnTypes, - type ExportedTypeMap, - buildExportedTypeMapFromGraph, -} from './call-processor.js'; -import { buildHeritageMap } from './model/heritage-map.js'; -import { nextjsFileToRouteURL, normalizeFetchURL } from './route-extractors/nextjs.js'; -import { expoFileToRouteURL } from './route-extractors/expo.js'; -import { phpFileToRouteURL } from './route-extractors/php.js'; -import { - extractResponseShapes, - extractPHPResponseShapes, -} from './route-extractors/response-shapes.js'; -import { - extractMiddlewareChain, - extractNextjsMiddlewareConfig, - compileMatcher, - compiledMatcherMatchesRoute, -} from './route-extractors/middleware.js'; -import { generateId } from '../../lib/utils.js'; -import type { - ExtractedAssignment, - ExtractedCall, - ExtractedDecoratorRoute, - ExtractedFetchCall, - ExtractedORMQuery, - ExtractedRoute, - ExtractedToolDef, - FileConstructorBindings, -} from './workers/parse-worker.js'; -import type { ExtractedHeritage } from './model/heritage-map.js'; -import { - processHeritage, - processHeritageFromExtracted, - extractExtractedHeritageFromFiles, - getHeritageStrategyForLanguage, -} from './heritage-processor.js'; -import { computeMRO } from './mro-processor.js'; -import { processCommunities } from './community-processor.js'; -import { processProcesses } from './process-processor.js'; -import { createResolutionContext } from './model/resolution-context.js'; -import { createASTCache } from './ast-cache.js'; -import { type PipelineProgress, getLanguageFromFilename } from 'gitnexus-shared'; +import { type PipelineProgress } from 'gitnexus-shared'; import { PipelineResult } from '../../types/pipeline.js'; -import { walkRepositoryPaths, readFileContents } from './filesystem-walker.js'; -import { isLanguageAvailable } from '../tree-sitter/parser-loader.js'; -import { SupportedLanguages } from 'gitnexus-shared'; -import { providers, getProviderForFile } from './languages/index.js'; -import { createWorkerPool, WorkerPool } from './workers/worker-pool.js'; -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; - -const isDev = process.env.NODE_ENV === 'development'; - -const EXPO_NAV_PATTERNS = [ - /router\.(push|replace|navigate)\(\s*['"`]([^'"`]+)['"`]/g, - /]*href=\s*['"`]([^'"`]+)['"`]/g, -]; - -/** A group of files with no mutual dependencies, safe to process in parallel. */ -type IndependentFileGroup = readonly string[]; - -/** Kahn's algorithm: returns files grouped by topological level. - * Files in the same level have no mutual dependencies — safe to process in parallel. - * Files in cycles are returned as a final group (no cross-cycle propagation). */ -export function topologicalLevelSort(importMap: ReadonlyMap>): { - levels: readonly IndependentFileGroup[]; - cycleCount: number; -} { - // Build in-degree map and reverse dependency map - const inDegree = new Map(); - const reverseDeps = new Map(); - - for (const [file, deps] of importMap) { - if (!inDegree.has(file)) inDegree.set(file, 0); - for (const dep of deps) { - if (!inDegree.has(dep)) inDegree.set(dep, 0); - // file imports dep, so dep must be processed before file - // In Kahn's terms: dep → file (dep is a prerequisite of file) - inDegree.set(file, (inDegree.get(file) ?? 0) + 1); - let rev = reverseDeps.get(dep); - if (!rev) { - rev = []; - reverseDeps.set(dep, rev); - } - rev.push(file); - } - } - - // BFS from zero-in-degree nodes, grouping by level - const levels: string[][] = []; - let currentLevel = [...inDegree.entries()].filter(([, d]) => d === 0).map(([f]) => f); - - while (currentLevel.length > 0) { - levels.push(currentLevel); - const nextLevel: string[] = []; - for (const file of currentLevel) { - for (const dependent of reverseDeps.get(file) ?? []) { - const newDeg = (inDegree.get(dependent) ?? 1) - 1; - inDegree.set(dependent, newDeg); - if (newDeg === 0) nextLevel.push(dependent); - } - } - currentLevel = nextLevel; - } - - // Files still with positive in-degree are in cycles — add as final group - const cycleFiles = [...inDegree.entries()].filter(([, d]) => d > 0).map(([f]) => f); - if (cycleFiles.length > 0) { - levels.push(cycleFiles); - } - - return { levels, cycleCount: cycleFiles.length }; -} - -/** Max bytes of source content to load per parse chunk. Each chunk's source + - * parsed ASTs + extracted records + worker serialization overhead all live in - * memory simultaneously, so this must be conservative. 20MB source ≈ 200-400MB - * peak working memory per chunk after parse expansion. */ -const CHUNK_BYTE_BUDGET = 20 * 1024 * 1024; // 20MB - -/** Max AST trees to keep in LRU cache */ -const AST_CACHE_CAP = 50; - -/** Minimum percentage of files that must benefit from cross-file seeding to justify the re-resolution pass. */ -const CROSS_FILE_SKIP_THRESHOLD = 0.03; -/** Hard cap on files re-processed during cross-file propagation. */ -const MAX_CROSS_FILE_REPROCESS = 2000; - -/** Node labels that represent top-level importable symbols. - * Excludes Method, Property, Constructor (accessed via receiver, not directly imported), - * and structural labels (File, Folder, Package, Module, Project, etc.). */ -const IMPORTABLE_SYMBOL_LABELS = new Set([ - 'Function', - 'Class', - 'Interface', - 'Struct', - 'Enum', - 'Trait', - 'TypeAlias', - 'Const', - 'Static', - 'Record', - 'Union', - 'Typedef', - 'Macro', -]); - -/** Max synthetic bindings per importing file — prevents memory bloat for - * C/C++ files that include many large headers. */ -const MAX_SYNTHETIC_BINDINGS_PER_FILE = 1000; - -/** Pre-computed language sets derived from providers at module load. */ -const WILDCARD_LANGUAGES = new Set( - Object.values(providers) - .filter((p) => p.importSemantics === 'wildcard') - .map((p) => p.id), -); -const SYNTHESIS_LANGUAGES = new Set( - Object.values(providers) - .filter((p) => p.importSemantics !== 'named') - .map((p) => p.id), -); - -/** Check if a language uses wildcard (whole-module) import semantics. - * Derived from LanguageProvider.importSemantics — no hardcoded set needed. */ -function isWildcardImportLanguage(lang: SupportedLanguages): boolean { - return WILDCARD_LANGUAGES.has(lang); -} - -/** Check if a language needs synthesis before call resolution. - * True for wildcard-import languages AND namespace-import languages (Python). */ -function needsSynthesis(lang: SupportedLanguages): boolean { - return SYNTHESIS_LANGUAGES.has(lang); -} - -/** Synthesize namedImportMap entries for languages with whole-module imports. - * These languages (Go, Ruby, C/C++, Swift, Python) import all exported symbols from a - * file, not specific named symbols. After parsing, we know which symbols each file - * exports (via graph isExported), so we can expand ImportMap edges into per-symbol - * bindings that Phase 14 can use for cross-file type propagation. */ -function synthesizeWildcardImportBindings( - graph: ReturnType, - ctx: ReturnType, -): number { - // Pre-compute exported symbols per file from graph (single pass) - const exportedSymbolsByFile = new Map(); - graph.forEachNode((node) => { - if (!node.properties?.isExported) return; - if (!IMPORTABLE_SYMBOL_LABELS.has(node.label)) return; - const fp = node.properties.filePath; - const name = node.properties.name; - if (!fp || !name) return; - let symbols = exportedSymbolsByFile.get(fp); - if (!symbols) { - symbols = []; - exportedSymbolsByFile.set(fp, symbols); - } - symbols.push({ name, filePath: fp }); - }); - - if (exportedSymbolsByFile.size === 0) return 0; - - // Build a merged import map: ctx.importMap has file-based imports (Ruby, C/C++), - // but Go/C# package imports use graph IMPORTS edges + PackageMap instead. - // Collect graph-level IMPORTS edges for wildcard languages missing from ctx.importMap. - const FILE_PREFIX = 'File:'; - const graphImports = new Map>(); - graph.forEachRelationship((rel) => { - if (rel.type !== 'IMPORTS') return; - if (!rel.sourceId.startsWith(FILE_PREFIX) || !rel.targetId.startsWith(FILE_PREFIX)) return; - const srcFile = rel.sourceId.slice(FILE_PREFIX.length); - const tgtFile = rel.targetId.slice(FILE_PREFIX.length); - const lang = getLanguageFromFilename(srcFile); - if (!lang || !isWildcardImportLanguage(lang)) return; - // Only add if not already in ctx.importMap (avoid duplicates) - if (ctx.importMap.get(srcFile)?.has(tgtFile)) return; - let set = graphImports.get(srcFile); - if (!set) { - set = new Set(); - graphImports.set(srcFile, set); - } - set.add(tgtFile); - }); - - let totalSynthesized = 0; - - // Helper: synthesize bindings for a file given its imported files - const synthesizeForFile = (filePath: string, importedFiles: Iterable) => { - let fileBindings = ctx.namedImportMap.get(filePath); - let fileCount = fileBindings?.size ?? 0; - - for (const importedFile of importedFiles) { - const exportedSymbols = exportedSymbolsByFile.get(importedFile); - if (!exportedSymbols) continue; - - for (const sym of exportedSymbols) { - if (fileCount >= MAX_SYNTHETIC_BINDINGS_PER_FILE) return; - if (fileBindings?.has(sym.name)) continue; - - if (!fileBindings) { - fileBindings = new Map(); - ctx.namedImportMap.set(filePath, fileBindings); - } - fileBindings.set(sym.name, { - sourcePath: importedFile, - exportedName: sym.name, - }); - fileCount++; - totalSynthesized++; - } - } - }; - - // Process files from ctx.importMap (Ruby, C/C++, Swift file-based imports) - for (const [filePath, importedFiles] of ctx.importMap) { - const lang = getLanguageFromFilename(filePath); - if (!lang || !isWildcardImportLanguage(lang)) continue; - synthesizeForFile(filePath, importedFiles); - } - - // Process files from graph IMPORTS edges (Go and other wildcard-import languages) - for (const [filePath, importedFiles] of graphImports) { - synthesizeForFile(filePath, importedFiles); - } - - // Build module alias map for Python namespace imports. - // `import models` in app.py → ctx.moduleAliasMap['app.py']['models'] = 'models.py' - // Enables `models.User()` to resolve to models.py:User without ambiguous symbol expansion. - const buildPythonModuleAliasForFile = (callerFile: string, importedFiles: Iterable) => { - let aliasMap = ctx.moduleAliasMap.get(callerFile); - for (const importedFile of importedFiles) { - // Derive the module alias from the imported filename stem (e.g. "models.py" → "models") - const lastSlash = importedFile.lastIndexOf('/'); - const base = lastSlash >= 0 ? importedFile.slice(lastSlash + 1) : importedFile; - const dot = base.lastIndexOf('.'); - const stem = dot >= 0 ? base.slice(0, dot) : base; - if (!stem) continue; - if (!aliasMap) { - aliasMap = new Map(); - ctx.moduleAliasMap.set(callerFile, aliasMap); - } - aliasMap.set(stem, importedFile); - } - }; - - for (const [filePath, importedFiles] of ctx.importMap) { - const provider = getProviderForFile(filePath); - if (!provider || provider.importSemantics !== 'namespace') continue; - buildPythonModuleAliasForFile(filePath, importedFiles); - } - - return totalSynthesized; -} - -/** Phase 14: Cross-file binding propagation. - * Seeds downstream files with resolved type bindings from upstream exports. - * Files are processed in topological import order so upstream bindings are - * available when downstream files are re-resolved. */ -async function runCrossFileBindingPropagation( - graph: ReturnType, - ctx: ReturnType, - exportedTypeMap: ExportedTypeMap, - allPaths: string[], - totalFiles: number, - repoPath: string, - pipelineStart: number, - onProgress: (progress: PipelineProgress) => void, -): Promise { - // For the worker path, buildTypeEnv runs inside workers without SymbolTable, - // so exported bindings must be collected from graph + SymbolTable in main thread. - if (exportedTypeMap.size === 0 && graph.nodeCount > 0) { - const graphExports = buildExportedTypeMapFromGraph(graph, ctx.model.symbols); - for (const [fp, exports] of graphExports) exportedTypeMap.set(fp, exports); - } - - if (exportedTypeMap.size === 0 || ctx.namedImportMap.size === 0) return; - - const allPathSet = new Set(allPaths); - const { levels, cycleCount } = topologicalLevelSort(ctx.importMap); - - // Cycle diagnostic: only log when actual cycles detected (cycleCount from Kahn's BFS) - if (isDev && cycleCount > 0) { - console.log(`🔄 ${cycleCount} files in import cycles (skipped for cross-file propagation)`); - } - - // Quick count of files with cross-file binding gaps (early exit once threshold exceeded) - let filesWithGaps = 0; - const gapThreshold = Math.max(1, Math.ceil(totalFiles * CROSS_FILE_SKIP_THRESHOLD)); - outer: for (const level of levels) { - for (const filePath of level) { - const imports = ctx.namedImportMap.get(filePath); - if (!imports) continue; - for (const [, binding] of imports) { - const upstream = exportedTypeMap.get(binding.sourcePath); - if (upstream?.has(binding.exportedName)) { - filesWithGaps++; - break; - } - const def = ctx.model.symbols.lookupExactFull(binding.sourcePath, binding.exportedName); - if (def?.returnType) { - filesWithGaps++; - break; - } - } - if (filesWithGaps >= gapThreshold) break outer; - } - } - - const gapRatio = totalFiles > 0 ? filesWithGaps / totalFiles : 0; - if (gapRatio < CROSS_FILE_SKIP_THRESHOLD && filesWithGaps < gapThreshold) { - if (isDev) { - console.log( - `⏭️ Cross-file re-resolution skipped (${filesWithGaps}/${totalFiles} files, ${(gapRatio * 100).toFixed(1)}% < ${CROSS_FILE_SKIP_THRESHOLD * 100}% threshold)`, - ); - } - return; - } - - onProgress({ - phase: 'parsing', - percent: 82, - message: `Cross-file type propagation (${filesWithGaps}+ files)...`, - stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount }, - }); - - let crossFileResolved = 0; - const crossFileStart = Date.now(); - const astCache = createASTCache(AST_CACHE_CAP); - - for (const level of levels) { - const levelCandidates: { - filePath: string; - seeded: Map; - importedReturns: ReadonlyMap; - importedRawReturns: ReadonlyMap; - }[] = []; - for (const filePath of level) { - if (crossFileResolved + levelCandidates.length >= MAX_CROSS_FILE_REPROCESS) break; - const imports = ctx.namedImportMap.get(filePath); - if (!imports) continue; - - const seeded = new Map(); - for (const [localName, binding] of imports) { - const upstream = exportedTypeMap.get(binding.sourcePath); - if (upstream) { - const type = upstream.get(binding.exportedName); - if (type) seeded.set(localName, type); - } - } - - const importedReturns = buildImportedReturnTypes( - filePath, - ctx.namedImportMap, - ctx.model.symbols, - ); - const importedRawReturns = buildImportedRawReturnTypes( - filePath, - ctx.namedImportMap, - ctx.model.symbols, - ); - if (seeded.size === 0 && importedReturns.size === 0) continue; - if (!allPathSet.has(filePath)) continue; - - const lang = getLanguageFromFilename(filePath); - if (!lang || !isLanguageAvailable(lang)) continue; - - levelCandidates.push({ filePath, seeded, importedReturns, importedRawReturns }); - } - - if (levelCandidates.length === 0) continue; - - const levelPaths = levelCandidates.map((c) => c.filePath); - const contentMap = await readFileContents(repoPath, levelPaths); - - for (const { filePath, seeded, importedReturns, importedRawReturns } of levelCandidates) { - const content = contentMap.get(filePath); - if (!content) continue; - - const reFile = [{ path: filePath, content }]; - const bindings = new Map>(); - if (seeded.size > 0) bindings.set(filePath, seeded); - - const importedReturnTypesMap = new Map>(); - if (importedReturns.size > 0) { - importedReturnTypesMap.set(filePath, importedReturns); - } - - const importedRawReturnTypesMap = new Map>(); - if (importedRawReturns.size > 0) { - importedRawReturnTypesMap.set(filePath, importedRawReturns); - } - - await processCalls( - graph, - reFile, - astCache, - ctx, - undefined, - exportedTypeMap, - bindings.size > 0 ? bindings : undefined, - importedReturnTypesMap.size > 0 ? importedReturnTypesMap : undefined, - importedRawReturnTypesMap.size > 0 ? importedRawReturnTypesMap : undefined, - ); - crossFileResolved++; - } - - if (crossFileResolved >= MAX_CROSS_FILE_REPROCESS) { - if (isDev) - console.log(`⚠️ Cross-file re-resolution capped at ${MAX_CROSS_FILE_REPROCESS} files`); - break; - } - } - - astCache.clear(); - - if (isDev) { - const elapsed = Date.now() - crossFileStart; - const totalElapsed = Date.now() - pipelineStart; - const reResolutionPct = totalElapsed > 0 ? ((elapsed / totalElapsed) * 100).toFixed(1) : '0'; - console.log( - `🔗 Cross-file re-resolution: ${crossFileResolved} candidates re-processed` + - ` in ${elapsed}ms (${reResolutionPct}% of total ingestion time so far)`, - ); - } -} +import { + runPipeline, + getPhaseOutput, + scanPhase, + structurePhase, + markdownPhase, + cobolPhase, + parsePhase, + routesPhase, + toolsPhase, + ormPhase, + crossFilePhase, + mroPhase, + communitiesPhase, + processesPhase, + type PipelinePhase, + type CommunitiesOutput, + type ProcessesOutput, +} from './pipeline-phases/index.js'; export interface PipelineOptions { /** Skip MRO, community detection, and process extraction for faster test runs. */ @@ -497,897 +45,37 @@ export interface PipelineOptions { skipWorkers?: boolean; } -// ── Extracted pipeline phases ────────────────────────────────────────────── -// Each function represents a natural phase boundary in the ingestion pipeline. -// Data flow is explicit through parameters and return values. - -type ProgressFn = (progress: PipelineProgress) => void; -type ScannedFile = { path: string; size: number }; +// ── Phase registry ───────────────────────────────────────────────────────── /** - * Phase 1+2: Scan repository paths, build file/folder structure, process markdown. + * All pipeline phases with their dependency relationships. * - * @reads repoPath (filesystem) - * @writes graph (File, Folder nodes + CONTAINS edges; Markdown sections + cross-links) + * Phase dependency graph: + * + * scan → structure → [markdown, cobol] → parse → [routes, tools, orm] + * → crossFile → mro → communities → processes + * + * To add a new phase: create a file in pipeline-phases/, export the phase + * object, and add it to the appropriate position in this array. */ -async function runScanAndStructure( - repoPath: string, - graph: ReturnType, - onProgress: ProgressFn, -): Promise<{ scannedFiles: ScannedFile[]; allPaths: string[]; totalFiles: number }> { - // ── Phase 1: Scan paths only (no content read) ───────────────────── - onProgress({ - phase: 'extracting', - percent: 0, - message: 'Scanning repository...', - }); +function buildPhaseList(options?: PipelineOptions): PipelinePhase[] { + const phases: PipelinePhase[] = [ + scanPhase, + structurePhase, + markdownPhase, + cobolPhase, + parsePhase, + routesPhase, + toolsPhase, + ormPhase, + crossFilePhase, + ]; - const scannedFiles = await walkRepositoryPaths(repoPath, (current, total, filePath) => { - const scanProgress = Math.round((current / total) * 15); - onProgress({ - phase: 'extracting', - percent: scanProgress, - message: 'Scanning repository...', - detail: filePath, - stats: { filesProcessed: current, totalFiles: total, nodesCreated: graph.nodeCount }, - }); - }); - - const totalFiles = scannedFiles.length; - - onProgress({ - phase: 'extracting', - percent: 15, - message: 'Repository scanned successfully', - stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount }, - }); - - // ── Phase 2: Structure (paths only — no content needed) ──────────── - onProgress({ - phase: 'structure', - percent: 15, - message: 'Analyzing project structure...', - stats: { filesProcessed: 0, totalFiles, nodesCreated: graph.nodeCount }, - }); - - const allPaths = scannedFiles.map((f) => f.path); - processStructure(graph, allPaths); - - onProgress({ - phase: 'structure', - percent: 20, - message: 'Project structure analyzed', - stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount }, - }); - - // ── Custom (non-tree-sitter) processors ───────────────────────────── - // Each custom processor follows the pattern in markdown-processor.ts: - // 1. Export a process function: (graph, files, allPathSet) => result - // 2. Export a file detection function: (path) => boolean - // 3. Filter files by extension, write nodes/edges directly to graph - // To add a new language: create a new processor file, import it here, - // and add a filter-read-call-log block following the pattern below. - - // ── Phase 2.5: Markdown processing (headings + cross-links) ──────── - const mdScanned = scannedFiles.filter((f) => f.path.endsWith('.md') || f.path.endsWith('.mdx')); - if (mdScanned.length > 0) { - const mdContents = await readFileContents( - repoPath, - mdScanned.map((f) => f.path), - ); - const mdFiles = mdScanned - .filter((f) => mdContents.has(f.path)) - .map((f) => ({ path: f.path, content: mdContents.get(f.path)! })); - const allPathSet = new Set(allPaths); - const mdResult = processMarkdown(graph, mdFiles, allPathSet); - if (isDev) { - console.log( - ` Markdown: ${mdResult.sections} sections, ${mdResult.links} cross-links from ${mdFiles.length} files`, - ); - } + if (!options?.skipGraphPhases) { + phases.push(mroPhase, communitiesPhase, processesPhase); } - // ── Phase 2.6: COBOL processing (regex extraction, no tree-sitter) ── - const cobolScanned = scannedFiles.filter((f) => isCobolFile(f.path) || isJclFile(f.path)); - if (cobolScanned.length > 0) { - const cobolContents = await readFileContents( - repoPath, - cobolScanned.map((f) => f.path), - ); - const cobolFiles = cobolScanned - .filter((f) => cobolContents.has(f.path)) - .map((f) => ({ path: f.path, content: cobolContents.get(f.path)! })); - const allPathSet = new Set(allPaths); - const cobolResult = processCobol(graph, cobolFiles, allPathSet); - if (isDev) { - console.log( - ` COBOL: ${cobolResult.programs} programs, ${cobolResult.paragraphs} paragraphs, ${cobolResult.sections} sections from ${cobolFiles.length} files`, - ); - if ( - cobolResult.execSqlBlocks > 0 || - cobolResult.execCicsBlocks > 0 || - cobolResult.entryPoints > 0 - ) { - console.log( - ` COBOL enriched: ${cobolResult.execSqlBlocks} SQL blocks, ${cobolResult.execCicsBlocks} CICS blocks, ${cobolResult.entryPoints} entry points, ${cobolResult.moves} moves, ${cobolResult.fileDeclarations} file declarations`, - ); - } - if (cobolResult.jclJobs > 0) { - console.log(` JCL: ${cobolResult.jclJobs} jobs, ${cobolResult.jclSteps} steps`); - } - } - } - - return { scannedFiles, allPaths, totalFiles }; -} - -/** - * Phase 3+4: Chunked parse + resolve loop. - * - * Reads source in byte-budget chunks (~20MB each). For each chunk: - * 1. Parse via worker pool (or sequential fallback) - * 2. Resolve imports from extracted data - * 3. Synthesize wildcard import bindings (Go/Ruby/C++/Swift/Python) - * 4. Resolve heritage + routes per chunk; defer worker CALLS until all chunks - * have contributed heritage so interface-dispatch implementor map is complete - * 5. Collect TypeEnv bindings for cross-file propagation - * - * State accumulated across chunks: symbolTable, importMap, namedImportMap, - * moduleAliasMap (all via ResolutionContext), exportedTypeMap, workerTypeEnvBindings. - * - * @reads graph (structure nodes from Phase 1+2) - * @reads allPaths (from scan phase) - * @writes graph (Symbol nodes, IMPORTS/CALLS/EXTENDS/IMPLEMENTS/ACCESSES edges) - * @writes ctx.symbolTable, ctx.importMap, ctx.namedImportMap, ctx.moduleAliasMap - * - * Follow-up from PR review: MethodExtractor (FieldExtractor parity) and optional - * METHOD_IMPLEMENTS graph edges to make dispatch queryable without an in-memory map. - */ -async function runChunkedParseAndResolve( - graph: ReturnType, - ctx: ReturnType, - scannedFiles: ScannedFile[], - allPaths: string[], - totalFiles: number, - repoPath: string, - pipelineStart: number, - onProgress: ProgressFn, - options?: PipelineOptions, -): Promise<{ - exportedTypeMap: ExportedTypeMap; - allFetchCalls: ExtractedFetchCall[]; - allExtractedRoutes: ExtractedRoute[]; - allDecoratorRoutes: ExtractedDecoratorRoute[]; - allToolDefs: ExtractedToolDef[]; - allORMQueries: ExtractedORMQuery[]; - bindingAccumulator: BindingAccumulator; -}> { - const symbolTable = ctx.model.symbols; - - const parseableScanned = scannedFiles.filter((f) => { - const lang = getLanguageFromFilename(f.path); - return lang && isLanguageAvailable(lang); - }); - - // Warn about files skipped due to unavailable parsers - const skippedByLang = new Map(); - for (const f of scannedFiles) { - const lang = getLanguageFromFilename(f.path); - if (lang && !isLanguageAvailable(lang)) { - skippedByLang.set(lang, (skippedByLang.get(lang) || 0) + 1); - } - } - for (const [lang, count] of skippedByLang) { - console.warn( - `Skipping ${count} ${lang} file(s) — ${lang} parser not available (native binding may not have built). Try: npm rebuild tree-sitter-${lang}`, - ); - } - - const totalParseable = parseableScanned.length; - - if (totalParseable === 0) { - onProgress({ - phase: 'parsing', - percent: 82, - message: 'No parseable files found — skipping parsing phase', - stats: { filesProcessed: 0, totalFiles: 0, nodesCreated: graph.nodeCount }, - }); - } - - // Build byte-budget chunks - const chunks: string[][] = []; - let currentChunk: string[] = []; - let currentBytes = 0; - for (const file of parseableScanned) { - if (currentChunk.length > 0 && currentBytes + file.size > CHUNK_BYTE_BUDGET) { - chunks.push(currentChunk); - currentChunk = []; - currentBytes = 0; - } - currentChunk.push(file.path); - currentBytes += file.size; - } - if (currentChunk.length > 0) chunks.push(currentChunk); - - const numChunks = chunks.length; - - if (isDev) { - const totalMB = parseableScanned.reduce((s, f) => s + f.size, 0) / (1024 * 1024); - console.log( - `📂 Scan: ${totalFiles} paths, ${totalParseable} parseable (${totalMB.toFixed(0)}MB), ${numChunks} chunks @ ${CHUNK_BYTE_BUDGET / (1024 * 1024)}MB budget`, - ); - } - - onProgress({ - phase: 'parsing', - percent: 20, - message: `Parsing ${totalParseable} files in ${numChunks} chunk${numChunks !== 1 ? 's' : ''}...`, - stats: { filesProcessed: 0, totalFiles: totalParseable, nodesCreated: graph.nodeCount }, - }); - - // Don't spawn workers for tiny repos — overhead exceeds benefit - const MIN_FILES_FOR_WORKERS = 15; - const MIN_BYTES_FOR_WORKERS = 512 * 1024; - const totalBytes = parseableScanned.reduce((s, f) => s + f.size, 0); - - // Create worker pool once, reuse across chunks - let workerPool: WorkerPool | undefined; - if ( - !options?.skipWorkers && - (totalParseable >= MIN_FILES_FOR_WORKERS || totalBytes >= MIN_BYTES_FOR_WORKERS) - ) { - try { - let workerUrl = new URL('./workers/parse-worker.js', import.meta.url); - // When running under vitest, import.meta.url points to src/ where no .js exists. - // Fall back to the compiled dist/ worker so the pool can spawn real worker threads. - const thisDir = fileURLToPath(new URL('.', import.meta.url)); - if (!fs.existsSync(fileURLToPath(workerUrl))) { - const distWorker = path.resolve( - thisDir, - '..', - '..', - '..', - 'dist', - 'core', - 'ingestion', - 'workers', - 'parse-worker.js', - ); - if (fs.existsSync(distWorker)) { - workerUrl = pathToFileURL(distWorker) as URL; - } - } - workerPool = createWorkerPool(workerUrl); - } catch (err) { - if (isDev) - console.warn( - 'Worker pool creation failed, using sequential fallback:', - (err as Error).message, - ); - } - } - - let filesParsedSoFar = 0; - - // AST cache sized for one chunk (sequential fallback uses it for import/call/heritage) - const maxChunkFiles = chunks.reduce((max, c) => Math.max(max, c.length), 0); - let astCache = createASTCache(maxChunkFiles); - - // Build import resolution context once — suffix index, file lists, resolve cache. - // Reused across all chunks to avoid rebuilding O(files × path_depth) structures. - const importCtx = buildImportResolutionContext(allPaths); - const allPathObjects = allPaths.map((p) => ({ path: p })); - - // Worker path: parse + imports + heritage per chunk; buffer extracted calls and - // run processCallsFromExtracted once after all chunks so interface-dispatch uses a - // complete implementor map (heritage from every chunk). Costs peak RAM for buffered - // call rows vs streaming resolution per chunk. - const sequentialChunkPaths: string[][] = []; - // Pre-compute which chunks need synthesis — O(1) lookup per chunk. - const chunkNeedsSynthesis = chunks.map((paths) => - paths.some((p) => { - const lang = getLanguageFromFilename(p); - return lang != null && needsSynthesis(lang); - }), - ); - // Phase 14: Collect exported type bindings for cross-file propagation - const exportedTypeMap: ExportedTypeMap = new Map(); - // Accumulate file-scope TypeEnv bindings from workers (closes worker/sequential quality gap) - const bindingAccumulator = new BindingAccumulator(); - // Accumulate fetch() calls from workers for Next.js route matching - const allFetchCalls: ExtractedFetchCall[] = []; - // Accumulate framework-extracted routes (Laravel, etc.) for Route node creation - const allExtractedRoutes: ExtractedRoute[] = []; - // Accumulate decorator-based routes (@Get, @Post, @app.route, etc.) - const allDecoratorRoutes: ExtractedDecoratorRoute[] = []; - // Accumulate MCP/RPC tool definitions (@mcp.tool(), @app.tool(), etc.) - const allToolDefs: ExtractedToolDef[] = []; - const allORMQueries: ExtractedORMQuery[] = []; - const deferredWorkerCalls: ExtractedCall[] = []; - const deferredWorkerHeritage: ExtractedHeritage[] = []; - const deferredConstructorBindings: FileConstructorBindings[] = []; - const deferredAssignments: ExtractedAssignment[] = []; - - try { - for (let chunkIdx = 0; chunkIdx < numChunks; chunkIdx++) { - const chunkPaths = chunks[chunkIdx]; - - // Read content for this chunk only - const chunkContents = await readFileContents(repoPath, chunkPaths); - const chunkFiles = chunkPaths - .filter((p) => chunkContents.has(p)) - .map((p) => ({ path: p, content: chunkContents.get(p)! })); - - // Parse this chunk (workers or sequential fallback) - const chunkWorkerData = await processParsing( - graph, - chunkFiles, - symbolTable, - astCache, - (current, _total, filePath) => { - const globalCurrent = filesParsedSoFar + current; - const parsingProgress = 20 + (globalCurrent / totalParseable) * 62; - onProgress({ - phase: 'parsing', - percent: Math.round(parsingProgress), - message: `Parsing chunk ${chunkIdx + 1}/${numChunks}...`, - detail: filePath, - stats: { - filesProcessed: globalCurrent, - totalFiles: totalParseable, - nodesCreated: graph.nodeCount, - }, - }); - }, - workerPool, - ); - - const chunkBasePercent = 20 + (filesParsedSoFar / totalParseable) * 62; - - if (chunkWorkerData) { - // Imports - await processImportsFromExtracted( - graph, - allPathObjects, - chunkWorkerData.imports, - ctx, - (current, total) => { - onProgress({ - phase: 'parsing', - percent: Math.round(chunkBasePercent), - message: `Resolving imports (chunk ${chunkIdx + 1}/${numChunks})...`, - detail: `${current}/${total} files`, - stats: { - filesProcessed: filesParsedSoFar, - totalFiles: totalParseable, - nodesCreated: graph.nodeCount, - }, - }); - }, - repoPath, - importCtx, - ); - // ── Wildcard-import synthesis (Ruby / C/C++ / Swift / Go) + Python module aliases ─ - // Synthesize namedImportMap entries for wildcard-import languages and build - // moduleAliasMap for Python namespace imports. Must run after imports are resolved - // (importMap is populated) but BEFORE call resolution. - if (chunkNeedsSynthesis[chunkIdx]) synthesizeWildcardImportBindings(graph, ctx); - // Phase 14 E1: Seed cross-file receiver types from ExportedTypeMap - // before call resolution — eliminates re-parse for single-hop imported receivers. - // NOTE: In the worker path, exportedTypeMap is empty during chunk processing - // (populated later in runCrossFileBindingPropagation). This block is latent — - // it activates only if incremental export collection is added per-chunk. - if (exportedTypeMap.size > 0 && ctx.namedImportMap.size > 0) { - const { enrichedCount } = seedCrossFileReceiverTypes( - chunkWorkerData.calls, - ctx.namedImportMap, - exportedTypeMap, - ); - if (isDev && enrichedCount > 0) { - console.log( - `🔗 E1: Seeded ${enrichedCount} cross-file receiver types (chunk ${chunkIdx + 1})`, - ); - } - } - for (const _item of chunkWorkerData.calls) deferredWorkerCalls.push(_item); - for (const _item of chunkWorkerData.heritage) deferredWorkerHeritage.push(_item); - for (const _item of chunkWorkerData.constructorBindings) - deferredConstructorBindings.push(_item); - if (chunkWorkerData.assignments?.length) { - for (const _item of chunkWorkerData.assignments) deferredAssignments.push(_item); - } - - // Heritage + Routes — calls deferred until all chunks have contributed heritage - // (complete implementor map for interface dispatch). - await Promise.all([ - processHeritageFromExtracted(graph, chunkWorkerData.heritage, ctx, (current, total) => { - onProgress({ - phase: 'parsing', - percent: Math.round(chunkBasePercent), - message: `Resolving heritage (chunk ${chunkIdx + 1}/${numChunks})...`, - detail: `${current}/${total} records`, - stats: { - filesProcessed: filesParsedSoFar, - totalFiles: totalParseable, - nodesCreated: graph.nodeCount, - }, - }); - }), - processRoutesFromExtracted(graph, chunkWorkerData.routes ?? [], ctx, (current, total) => { - onProgress({ - phase: 'parsing', - percent: Math.round(chunkBasePercent), - message: `Resolving routes (chunk ${chunkIdx + 1}/${numChunks})...`, - detail: `${current}/${total} routes`, - stats: { - filesProcessed: filesParsedSoFar, - totalFiles: totalParseable, - nodesCreated: graph.nodeCount, - }, - }); - }), - ]); - // Collect file-scope bindings into BindingAccumulator. The worker - // IPC payload carries only file-scope entries (`scope = ''` - // hardcoded here). See the FileScopeBindings JSDoc in - // parse-worker.ts for the rationale and Phase 9 reversion path. - // - // Defensive validation at the IPC boundary: silently skip entries - // with non-string varName/typeName. If a future worker regression - // (or a Phase 9 reversion mistake that emits 3-tuples into the - // 2-tuple consumer) produces malformed data, logging is better - // than silently writing `undefined` into the enrichment map. - if (chunkWorkerData.fileScopeBindings?.length) { - for (const { filePath, bindings } of chunkWorkerData.fileScopeBindings) { - if (typeof filePath !== 'string' || filePath.length === 0) continue; - if (!Array.isArray(bindings)) continue; - const entries: BindingEntry[] = []; - for (const tuple of bindings) { - if (!Array.isArray(tuple) || tuple.length !== 2) continue; - const [varName, typeName] = tuple; - if (typeof varName !== 'string' || typeof typeName !== 'string') continue; - entries.push({ scope: '', varName, typeName }); - } - if (entries.length > 0) { - bindingAccumulator.appendFile(filePath, entries); - } - } - } - // Collect fetch() calls for Next.js route matching - if (chunkWorkerData.fetchCalls?.length) { - for (const _item of chunkWorkerData.fetchCalls) allFetchCalls.push(_item); - } - if (chunkWorkerData.routes?.length) { - for (const _item of chunkWorkerData.routes) allExtractedRoutes.push(_item); - } - if (chunkWorkerData.decoratorRoutes?.length) { - for (const _item of chunkWorkerData.decoratorRoutes) allDecoratorRoutes.push(_item); - } - if (chunkWorkerData.toolDefs?.length) { - for (const _item of chunkWorkerData.toolDefs) allToolDefs.push(_item); - } - if (chunkWorkerData.ormQueries?.length) { - for (const _item of chunkWorkerData.ormQueries) allORMQueries.push(_item); - } - } else { - await processImports(graph, chunkFiles, astCache, ctx, undefined, repoPath, allPaths); - sequentialChunkPaths.push(chunkPaths); - } - - filesParsedSoFar += chunkFiles.length; - - // Clear AST cache between chunks to free memory - astCache.clear(); - // chunkContents + chunkFiles + chunkWorkerData go out of scope → GC reclaims - } - - // Build unified HeritageMap (parent lookup + implementor index) after all chunks. - const fullWorkerHeritageMap = - deferredWorkerHeritage.length > 0 - ? buildHeritageMap(deferredWorkerHeritage, ctx, getHeritageStrategyForLanguage) - : undefined; - - if (deferredWorkerCalls.length > 0) { - await processCallsFromExtracted( - graph, - deferredWorkerCalls, - ctx, - (current, total) => { - onProgress({ - phase: 'parsing', - percent: 82, - message: 'Resolving calls (all chunks)...', - detail: `${current}/${total} files`, - stats: { - filesProcessed: filesParsedSoFar, - totalFiles: totalParseable, - nodesCreated: graph.nodeCount, - }, - }); - }, - deferredConstructorBindings.length > 0 ? deferredConstructorBindings : undefined, - fullWorkerHeritageMap, - // Phase 9: pass the accumulator so processCallsFromExtracted can fall back - // to file-scope TypeEnv bindings when the SymbolTable lacks a return type - // for a cross-file callee (e.g. var x = getUser() → x: User). - // - // Lifecycle ordering: the accumulator is populated but NOT yet finalized - // at this seam. finalize() is called later (after the sequential-path - // processCalls which also appends via typeEnv.flush()). Moving finalize() - // before this call would break sequential-path repos. Pre-finalize reads - // are safe because finalize() is a write-lock-only operation with no side - // effects on stored data. All worker-path appendFile calls complete in the - // chunk loop above, so every worker-contributed binding is available via - // fileScopeGet(). - bindingAccumulator, - ); - } - - if (deferredAssignments.length > 0) { - processAssignmentsFromExtracted( - graph, - deferredAssignments, - ctx, - deferredConstructorBindings.length > 0 ? deferredConstructorBindings : undefined, - bindingAccumulator, // Phase 9 fallback parity with processCallsFromExtracted (R3) - ); - } - } finally { - await workerPool?.terminate(); - } - - // Sequential fallback chunks: re-read source for call/heritage resolution - // Synthesize wildcard import bindings once after ALL imports are processed, - // before any call resolution — same rationale as the worker-path inline synthesis. - if (sequentialChunkPaths.length > 0) synthesizeWildcardImportBindings(graph, ctx); - // Pass 1: Extract heritage from all sequential chunks. - // Heritage must be fully accumulated BEFORE call resolution so the HeritageMap - // has the complete ancestor chain and implementor index (same constraint as - // the worker path). - // - // File contents are read once here and cached for Pass 2 to avoid a 2× I/O - // cost on the sequential path (ASTs are intentionally NOT cached — rebuilding - // them in Pass 2 keeps peak memory bounded to one chunk at a time). - const allSequentialHeritage: ExtractedHeritage[] = []; - const cachedSequentialChunkFiles: Array> = []; - for (const chunkPaths of sequentialChunkPaths) { - const chunkContents = await readFileContents(repoPath, chunkPaths); - const chunkFiles = chunkPaths - .filter((p) => chunkContents.has(p)) - .map((p) => ({ path: p, content: chunkContents.get(p)! })); - cachedSequentialChunkFiles.push(chunkFiles); - astCache = createASTCache(chunkFiles.length); - const sequentialHeritage = await extractExtractedHeritageFromFiles(chunkFiles, astCache); - // Manual loop (not spread) — `push(...arr)` blows the stack on very large - // arrays, see #650. Pay the explicit iteration cost for safety. - for (const h of sequentialHeritage) allSequentialHeritage.push(h); - astCache.clear(); - } - // Build unified HeritageMap from all sequential heritage (parent lookup + implementor index). - const sequentialHeritageMap = - allSequentialHeritage.length > 0 - ? buildHeritageMap(allSequentialHeritage, ctx, getHeritageStrategyForLanguage) - : undefined; - - // Pass 2: Process calls, heritage edges, fetch calls, and ORM queries per chunk. - // Reuse the file contents cached in Pass 1 instead of re-reading from disk. - for (let chunkIdx = 0; chunkIdx < sequentialChunkPaths.length; chunkIdx++) { - const chunkFiles = cachedSequentialChunkFiles[chunkIdx]; - astCache = createASTCache(chunkFiles.length); - const rubyHeritage = await processCalls( - graph, - chunkFiles, - astCache, - ctx, - undefined, - exportedTypeMap, - undefined, - undefined, - undefined, - sequentialHeritageMap, - bindingAccumulator, - ); - await processHeritage(graph, chunkFiles, astCache, ctx); - if (rubyHeritage.length > 0) { - await processHeritageFromExtracted(graph, rubyHeritage, ctx); - } - // Extract fetch() calls for Next.js route matching (sequential path) - const chunkFetchCalls = await extractFetchCallsFromFiles(chunkFiles, astCache); - if (chunkFetchCalls.length > 0) { - for (const _item of chunkFetchCalls) allFetchCalls.push(_item); - } - // Extract ORM queries (sequential path) - for (const f of chunkFiles) { - extractORMQueriesInline(f.path, f.content, allORMQueries); - } - astCache.clear(); - // Release cached chunk content as soon as Pass 2 finishes with it so the - // Pass-1 content map drains incrementally rather than being held for the - // full duration of Pass 2. - cachedSequentialChunkFiles[chunkIdx] = []; - } - - // Log resolution cache stats - if (isDev) { - const rcStats = ctx.getStats(); - const total = rcStats.cacheHits + rcStats.cacheMisses; - const hitRate = total > 0 ? ((rcStats.cacheHits / total) * 100).toFixed(1) : '0'; - console.log( - `🔍 Resolution cache: ${rcStats.cacheHits} hits, ${rcStats.cacheMisses} misses (${hitRate}% hit rate)`, - ); - } - - // ── Finalize the accumulator before the read phase begins. All worker-path - // appends (line ~934) and sequential-path flushes (via `processCalls` → - // `typeEnv.flush()` earlier in this function) have completed by here, - // so the finalize-write-lock is correct at this seam. Making the - // lifecycle contract explicit — `append → finalize → consume → dispose`. - // Previously `finalize()` was called much later in `runPipelineFromRepo` - // after the enrichment loop had already read the mutable accumulator. - bindingAccumulator.finalize(); - - // ── Worker path quality enrichment: merge file-scope bindings into ExportedTypeMap ── - // Counterpart to `collectExportedBindings()` in call-processor.ts which - // handles the sequential path (main thread, full SymbolTable access). - // This call handles the worker path via the accumulator. Both sites - // populate the same `exportedTypeMap` with subtly different export-check - // semantics — sequential uses SymbolTable + graph lookup, `enrichExportedTypeMap` - // uses a three-candidate-ID graph lookup. They must stay in sync until - // Phase 9 unifies them. If you edit one, check the other. - // - // The enrichment loop itself lives in `binding-accumulator.ts` so tests - // can exercise the real production code instead of reimplementing it. - const enriched = enrichExportedTypeMap(bindingAccumulator, graph, exportedTypeMap); - if (isDev && enriched > 0) { - console.log( - `🔗 Worker TypeEnv enrichment: ${enriched} fixpoint-inferred exports added to ExportedTypeMap`, - ); - } - - // ── Final synthesis pass for whole-module-import languages ── - // Per-chunk synthesis (above) already ran incrementally. This final pass ensures - // any remaining files whose imports were not covered inline are also synthesized, - // and that Phase 14 type propagation has complete namedImportMap data. - const synthesized = synthesizeWildcardImportBindings(graph, ctx); - if (isDev && synthesized > 0) { - console.log( - `🔗 Synthesized ${synthesized} additional wildcard import bindings (Go/Ruby/C++/Swift/Python)`, - ); - } - - // Free import resolution context — suffix index + resolve cache no longer needed - // (allPathObjects and importCtx hold ~94MB+ for large repos) - allPathObjects.length = 0; - importCtx.resolveCache.clear(); - importCtx.index = EMPTY_INDEX; // Release suffix index memory (~30MB for large repos) - importCtx.normalizedFileList = []; - - return { - exportedTypeMap, - allFetchCalls, - allExtractedRoutes, - allDecoratorRoutes, - allToolDefs, - allORMQueries, - bindingAccumulator, - }; -} - -/** - * Post-parse graph analysis: MRO, community detection, process extraction. - * - * @reads graph (all nodes and relationships from parse + resolve phases) - * @writes graph (Community nodes, Process nodes, MEMBER_OF edges, STEP_IN_PROCESS edges, METHOD_OVERRIDES edges) - */ -async function runGraphAnalysisPhases( - graph: ReturnType, - totalFiles: number, - onProgress: ProgressFn, - routeRegistry?: Map, - toolDefs?: { name: string; filePath: string; description: string }[], -): Promise<{ - communityResult: Awaited>; - processResult: Awaited>; -}> { - // ── Phase 4.5: Method Resolution Order ────────────────────────────── - onProgress({ - phase: 'parsing', - percent: 81, - message: 'Computing method resolution order...', - stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount }, - }); - - const mroResult = computeMRO(graph); - if (isDev && mroResult.entries.length > 0) { - console.log( - `🔀 MRO: ${mroResult.entries.length} classes analyzed, ${mroResult.ambiguityCount} ambiguities, ${mroResult.overrideEdges} METHOD_OVERRIDES, ${mroResult.methodImplementsEdges} METHOD_IMPLEMENTS`, - ); - } - - // ── Phase 5: Communities ─────────────────────────────────────────── - onProgress({ - phase: 'communities', - percent: 82, - message: 'Detecting code communities...', - stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount }, - }); - - const communityResult = await processCommunities(graph, (message, progress) => { - const communityProgress = 82 + progress * 0.1; - onProgress({ - phase: 'communities', - percent: Math.round(communityProgress), - message, - stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount }, - }); - }); - - if (isDev) { - console.log( - `🏘️ Community detection: ${communityResult.stats.totalCommunities} communities found (modularity: ${communityResult.stats.modularity.toFixed(3)})`, - ); - } - - communityResult.communities.forEach((comm) => { - graph.addNode({ - id: comm.id, - label: 'Community' as const, - properties: { - name: comm.label, - filePath: '', - heuristicLabel: comm.heuristicLabel, - cohesion: comm.cohesion, - symbolCount: comm.symbolCount, - }, - }); - }); - - communityResult.memberships.forEach((membership) => { - graph.addRelationship({ - id: `${membership.nodeId}_member_of_${membership.communityId}`, - type: 'MEMBER_OF', - sourceId: membership.nodeId, - targetId: membership.communityId, - confidence: 1.0, - reason: 'leiden-algorithm', - }); - }); - - // ── Phase 6: Processes ───────────────────────────────────────────── - onProgress({ - phase: 'processes', - percent: 94, - message: 'Detecting execution flows...', - stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount }, - }); - - let symbolCount = 0; - graph.forEachNode((n) => { - if (n.label !== 'File') symbolCount++; - }); - const dynamicMaxProcesses = Math.max(20, Math.min(300, Math.round(symbolCount / 10))); - - const processResult = await processProcesses( - graph, - communityResult.memberships, - (message, progress) => { - const processProgress = 94 + progress * 0.05; - onProgress({ - phase: 'processes', - percent: Math.round(processProgress), - message, - stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount }, - }); - }, - { maxProcesses: dynamicMaxProcesses, minSteps: 3 }, - ); - - if (isDev) { - console.log( - `🔄 Process detection: ${processResult.stats.totalProcesses} processes found (${processResult.stats.crossCommunityCount} cross-community)`, - ); - } - - processResult.processes.forEach((proc) => { - graph.addNode({ - id: proc.id, - label: 'Process' as const, - properties: { - name: proc.label, - filePath: '', - heuristicLabel: proc.heuristicLabel, - processType: proc.processType, - stepCount: proc.stepCount, - communities: proc.communities, - entryPointId: proc.entryPointId, - terminalId: proc.terminalId, - }, - }); - }); - - processResult.steps.forEach((step) => { - graph.addRelationship({ - id: `${step.nodeId}_step_${step.step}_${step.processId}`, - type: 'STEP_IN_PROCESS', - sourceId: step.nodeId, - targetId: step.processId, - confidence: 1.0, - reason: 'trace-detection', - step: step.step, - }); - }); - - // Link Route and Tool nodes to Processes via reverse index (file → node id) - if ((routeRegistry?.size ?? 0) > 0 || (toolDefs?.length ?? 0) > 0) { - // Reverse indexes: file → all route URLs / tool names (handles multi-route files) - const routesByFile = new Map(); - if (routeRegistry) { - for (const [url, entry] of routeRegistry) { - let list = routesByFile.get(entry.filePath); - if (!list) { - list = []; - routesByFile.set(entry.filePath, list); - } - list.push(url); - } - } - const toolsByFile = new Map(); - if (toolDefs) { - for (const td of toolDefs) { - let list = toolsByFile.get(td.filePath); - if (!list) { - list = []; - toolsByFile.set(td.filePath, list); - } - list.push(td.name); - } - } - - let linked = 0; - for (const proc of processResult.processes) { - if (!proc.entryPointId) continue; - const entryNode = graph.getNode(proc.entryPointId); - if (!entryNode) continue; - const entryFile = entryNode.properties.filePath; - if (!entryFile) continue; - - const routeURLs = routesByFile.get(entryFile); - if (routeURLs) { - for (const routeURL of routeURLs) { - const routeNodeId = generateId('Route', routeURL); - graph.addRelationship({ - id: generateId('ENTRY_POINT_OF', `${routeNodeId}->${proc.id}`), - sourceId: routeNodeId, - targetId: proc.id, - type: 'ENTRY_POINT_OF', - confidence: 0.85, - reason: 'route-handler-entry-point', - }); - linked++; - } - } - const toolNames = toolsByFile.get(entryFile); - if (toolNames) { - for (const toolName of toolNames) { - const toolNodeId = generateId('Tool', toolName); - graph.addRelationship({ - id: generateId('ENTRY_POINT_OF', `${toolNodeId}->${proc.id}`), - sourceId: toolNodeId, - targetId: proc.id, - type: 'ENTRY_POINT_OF', - confidence: 0.85, - reason: 'tool-handler-entry-point', - }); - linked++; - } - } - } - if (isDev && linked > 0) { - console.log(`🔗 Linked ${linked} Route/Tool nodes to execution flows`); - } - } - - return { communityResult, processResult }; + return phases; } // ── Pipeline orchestrator ───────────────────────────────────────────────── @@ -1398,562 +86,42 @@ export const runPipelineFromRepo = async ( options?: PipelineOptions, ): Promise => { const graph = createKnowledgeGraph(); - const ctx = createResolutionContext(); const pipelineStart = Date.now(); - // Hoisted reference for error-path cleanup. The accumulator is normally - // disposed at the happy-path seam after the dev telemetry log, but if any - // step between the runChunkedParseAndResolve return and that seam throws - // (ORM processing, tool node creation, Phase 14, graph analysis), the - // catch handler disposes it here so the heap footprint does not leak - // through the rethrow. See binding-accumulator.ts dispose() JSDoc for the - // lifecycle contract. - let bindingAccumulatorForCleanup: BindingAccumulator | undefined; + const phases = buildPhaseList(options); - try { - // Phase 1+2: Scan paths, build structure, process markdown - const { scannedFiles, allPaths, totalFiles } = await runScanAndStructure( - repoPath, - graph, - onProgress, - ); + const results = await runPipeline(phases, { + repoPath, + graph, + onProgress, + options, + pipelineStart, + }); - // Phase 3+4: Chunked parse + resolve (imports, calls, heritage, routes) - const { - exportedTypeMap, - allFetchCalls, - allExtractedRoutes, - allDecoratorRoutes, - allToolDefs, - allORMQueries, - bindingAccumulator, - } = await runChunkedParseAndResolve( - graph, - ctx, - scannedFiles, - allPaths, - totalFiles, - repoPath, - pipelineStart, - onProgress, - options, - ); - // Track the accumulator for error-path cleanup — the happy-path dispose - // is still at the post-telemetry seam below, this reference is only - // consulted by the catch handler if any step between here and there - // throws. - bindingAccumulatorForCleanup = bindingAccumulator; + // Extract final results for the PipelineResult contract + const { totalFiles } = getPhaseOutput<{ totalFiles: number }>(results, 'parse'); - // ── Phase 3.5: Route Registry (Next.js + PHP + Laravel + decorators) ── - type RouteEntry = { filePath: string; source: string }; - const routeRegistry = new Map(); + let communityResult: CommunitiesOutput['communityResult'] | undefined; + let processResult: ProcessesOutput['processResult'] | undefined; - // Detect Expo Router app/ roots vs Next.js app/ roots (monorepo-safe). - const expoAppRoots = new Set(); - const nextjsAppRoots = new Set(); - const expoAppPaths = new Set(); - for (const p of allPaths) { - const norm = p.replace(/\\/g, '/'); - const appIdx = norm.lastIndexOf('app/'); - if (appIdx < 0) continue; - const root = norm.slice(0, appIdx + 4); - if (/\/_layout\.(tsx?|jsx?)$/.test(norm)) expoAppRoots.add(root); - if (/\/page\.(tsx?|jsx?)$/.test(norm)) nextjsAppRoots.add(root); - } - for (const root of nextjsAppRoots) expoAppRoots.delete(root); - if (expoAppRoots.size > 0) { - for (const p of allPaths) { - const norm = p.replace(/\\/g, '/'); - const appIdx = norm.lastIndexOf('app/'); - if (appIdx >= 0 && expoAppRoots.has(norm.slice(0, appIdx + 4))) expoAppPaths.add(p); - } - } - - for (const p of allPaths) { - if (expoAppPaths.has(p)) { - const expoURL = expoFileToRouteURL(p); - if (expoURL && !routeRegistry.has(expoURL)) { - routeRegistry.set(expoURL, { filePath: p, source: 'expo-filesystem-route' }); - continue; - } - } - const nextjsURL = nextjsFileToRouteURL(p); - if (nextjsURL && !routeRegistry.has(nextjsURL)) { - routeRegistry.set(nextjsURL, { filePath: p, source: 'nextjs-filesystem-route' }); - continue; - } - if (p.endsWith('.php')) { - const phpURL = phpFileToRouteURL(p); - if (phpURL && !routeRegistry.has(phpURL)) { - routeRegistry.set(phpURL, { filePath: p, source: 'php-file-route' }); - } - } - } - - const ensureSlash = (path: string) => (path.startsWith('/') ? path : '/' + path); - let duplicateRoutes = 0; - const addRoute = (url: string, entry: RouteEntry) => { - if (routeRegistry.has(url)) { - duplicateRoutes++; - return; - } - routeRegistry.set(url, entry); - }; - for (const route of allExtractedRoutes) { - if (!route.routePath) continue; - addRoute(ensureSlash(route.routePath), { - filePath: route.filePath, - source: 'framework-route', - }); - } - for (const dr of allDecoratorRoutes) { - addRoute(ensureSlash(dr.routePath), { - filePath: dr.filePath, - source: `decorator-${dr.decoratorName}`, - }); - } - - let handlerContents: Map | undefined; - if (routeRegistry.size > 0) { - const handlerPaths = [...routeRegistry.values()].map((e) => e.filePath); - handlerContents = await readFileContents(repoPath, handlerPaths); - - for (const [routeURL, entry] of routeRegistry) { - const { filePath: handlerPath, source: routeSource } = entry; - const content = handlerContents.get(handlerPath); - - const { responseKeys, errorKeys } = content - ? handlerPath.endsWith('.php') - ? extractPHPResponseShapes(content) - : extractResponseShapes(content) - : { responseKeys: undefined, errorKeys: undefined }; - - const mwResult = content ? extractMiddlewareChain(content) : undefined; - const middleware = mwResult?.chain; - - const routeNodeId = generateId('Route', routeURL); - graph.addNode({ - id: routeNodeId, - label: 'Route', - properties: { - name: routeURL, - filePath: handlerPath, - ...(responseKeys ? { responseKeys } : {}), - ...(errorKeys ? { errorKeys } : {}), - ...(middleware && middleware.length > 0 ? { middleware } : {}), - }, - }); - - const handlerFileId = generateId('File', handlerPath); - graph.addRelationship({ - id: generateId('HANDLES_ROUTE', `${handlerFileId}->${routeNodeId}`), - sourceId: handlerFileId, - targetId: routeNodeId, - type: 'HANDLES_ROUTE', - confidence: 1.0, - reason: routeSource, - }); - } - - if (isDev) { - console.log( - `🗺️ Route registry: ${routeRegistry.size} routes${duplicateRoutes > 0 ? ` (${duplicateRoutes} duplicate URLs skipped)` : ''}`, - ); - } - } - - // ── Phase 3.5b: Link Next.js project-level middleware.ts to routes ── - if (routeRegistry.size > 0) { - const middlewareCandidates = allPaths.filter( - (p) => - p === 'middleware.ts' || - p === 'middleware.js' || - p === 'middleware.tsx' || - p === 'middleware.jsx' || - p === 'src/middleware.ts' || - p === 'src/middleware.js' || - p === 'src/middleware.tsx' || - p === 'src/middleware.jsx', - ); - if (middlewareCandidates.length > 0) { - const mwContents = await readFileContents(repoPath, middlewareCandidates); - for (const [mwPath, mwContent] of mwContents) { - const config = extractNextjsMiddlewareConfig(mwContent); - if (!config) continue; - const mwLabel = - config.wrappedFunctions.length > 0 ? config.wrappedFunctions : [config.exportedName]; - - // Pre-compile matchers once per middleware file - const compiled = config.matchers - .map(compileMatcher) - .filter((m): m is NonNullable => m !== null); - - let linkedCount = 0; - for (const [routeURL] of routeRegistry) { - const matches = - compiled.length === 0 || - compiled.some((cm) => compiledMatcherMatchesRoute(cm, routeURL)); - if (!matches) continue; - - const routeNodeId = generateId('Route', routeURL); - const existing = graph.getNode(routeNodeId); - if (!existing) continue; - - const currentMw = (existing.properties.middleware as string[] | undefined) ?? []; - // Prepend project-level middleware (runs before handler-level wrappers) - existing.properties.middleware = [ - ...mwLabel, - ...currentMw.filter((m) => !mwLabel.includes(m)), - ]; - linkedCount++; - } - if (isDev && linkedCount > 0) { - console.log( - `🛡️ Linked ${mwPath} middleware [${mwLabel.join(', ')}] to ${linkedCount} routes`, - ); - } - } - } - } - - // Scan HTML/PHP/template files for
and AJAX url patterns - // Scan HTML/template files for and AJAX url patterns - // Skip .php — already parsed by tree-sitter with http_client/fetch queries - const htmlCandidates = allPaths.filter( - (p) => - p.endsWith('.html') || - p.endsWith('.htm') || - p.endsWith('.ejs') || - p.endsWith('.hbs') || - p.endsWith('.blade.php'), - ); - if (htmlCandidates.length > 0 && routeRegistry.size > 0) { - const htmlContents = await readFileContents(repoPath, htmlCandidates); - const htmlPatterns = [/action=["']([^"']+)["']/g, /url:\s*["']([^"']+)["']/g]; - for (const [filePath, content] of htmlContents) { - for (const pattern of htmlPatterns) { - pattern.lastIndex = 0; - let match; - while ((match = pattern.exec(content)) !== null) { - const normalized = normalizeFetchURL(match[1]); - if (normalized) { - allFetchCalls.push({ filePath, fetchURL: normalized, lineNumber: 0 }); - } - } - } - } - } - - // ── Phase 3.5c: Extract Expo Router navigation patterns ── - if (expoAppPaths.size > 0 && routeRegistry.size > 0) { - const unreadExpoPaths = [...expoAppPaths].filter((p) => !handlerContents?.has(p)); - const extraContents = - unreadExpoPaths.length > 0 - ? await readFileContents(repoPath, unreadExpoPaths) - : new Map(); - const allExpoContents = new Map([...(handlerContents ?? new Map()), ...extraContents]); - for (const [filePath, content] of allExpoContents) { - if (!expoAppPaths.has(filePath)) continue; - for (const pattern of EXPO_NAV_PATTERNS) { - pattern.lastIndex = 0; - let match; - while ((match = pattern.exec(content)) !== null) { - const url = match[2] ?? match[1]; - if (url && url.startsWith('/')) { - allFetchCalls.push({ filePath, fetchURL: url, lineNumber: 0 }); - } - } - } - } - } - - if (routeRegistry.size > 0 && allFetchCalls.length > 0) { - const routeURLToFile = new Map(); - for (const [url, entry] of routeRegistry) routeURLToFile.set(url, entry.filePath); - - // Read consumer file contents so we can extract property access patterns - const consumerPaths = [...new Set(allFetchCalls.map((c) => c.filePath))]; - const consumerContents = await readFileContents(repoPath, consumerPaths); - - processNextjsFetchRoutes(graph, allFetchCalls, routeURLToFile, consumerContents); - if (isDev) { - console.log( - `🔗 Processed ${allFetchCalls.length} fetch() calls against ${routeRegistry.size} routes`, - ); - } - } - - // ── Phase 3.6: Tool Detection (MCP/RPC) ────────────────────────── - const toolDefs: { name: string; filePath: string; description: string }[] = []; - const seenToolNames = new Set(); - - for (const td of allToolDefs) { - if (seenToolNames.has(td.toolName)) continue; - seenToolNames.add(td.toolName); - toolDefs.push({ name: td.toolName, filePath: td.filePath, description: td.description }); - } - - // TS tool definition arrays — require inputSchema nearby to distinguish from config objects - const toolCandidatePaths = allPaths.filter( - (p) => - (p.endsWith('.ts') || p.endsWith('.js')) && - p.toLowerCase().includes('tool') && - !p.includes('node_modules') && - !p.includes('test') && - !p.includes('__'), - ); - if (toolCandidatePaths.length > 0) { - const toolContents = await readFileContents(repoPath, toolCandidatePaths); - for (const [filePath, content] of toolContents) { - // Only scan files that contain 'inputSchema' — this is the MCP tool signature - if (!content.includes('inputSchema')) continue; - const toolPattern = - /name:\s*['"](\w+)['"]\s*,\s*\n?\s*description:\s*[`'"]([\s\S]*?)[`'"]/g; - let match; - while ((match = toolPattern.exec(content)) !== null) { - const name = match[1]; - if (seenToolNames.has(name)) continue; - seenToolNames.add(name); - toolDefs.push({ - name, - filePath, - description: match[2].slice(0, 200).replace(/\n/g, ' ').trim(), - }); - } - } - } - - // Create Tool nodes and HANDLES_TOOL edges - if (toolDefs.length > 0) { - for (const td of toolDefs) { - const toolNodeId = generateId('Tool', td.name); - graph.addNode({ - id: toolNodeId, - label: 'Tool', - properties: { name: td.name, filePath: td.filePath, description: td.description }, - }); - - const handlerFileId = generateId('File', td.filePath); - graph.addRelationship({ - id: generateId('HANDLES_TOOL', `${handlerFileId}->${toolNodeId}`), - sourceId: handlerFileId, - targetId: toolNodeId, - type: 'HANDLES_TOOL', - confidence: 1.0, - reason: 'tool-definition', - }); - } - - if (isDev) { - console.log(`🔧 Tool registry: ${toolDefs.length} tools detected`); - } - } - - // ── Phase 3.7: ORM Dataflow Detection (Prisma + Supabase) ────────── - if (allORMQueries.length > 0) { - processORMQueries(graph, allORMQueries, isDev); - } - - // `bindingAccumulator.finalize()` was moved inside `runChunkedParseAndResolve` - // to immediately precede the enrichment loop — see the comment there for - // the ordering rationale. By the time execution - // reaches this point, the accumulator has already been finalized, consumed - // by the enrichment loop, and is ready for dispose() below after the dev - // telemetry log captures peak state. - - if (isDev) { - if (bindingAccumulator.totalBindings > 0) { - const memKB = Math.round(bindingAccumulator.estimateMemoryBytes() / 1024); - console.log( - `📦 BindingAccumulator: ${bindingAccumulator.totalBindings} bindings across ${bindingAccumulator.fileCount} files (~${memKB} KB)`, - ); - } else if (totalFiles > 0) { - // Zero-binding signal: if the pipeline parsed files but the - // accumulator is empty, something upstream dropped all bindings. - // Flag it so operators can spot a regression (e.g. a worker path - // that accidentally emits empty fileScopeBindings arrays for every - // file, or a TypeEnv build failure). Dev-mode only. - console.log( - `📦 BindingAccumulator: EMPTY — 0 bindings across 0 files despite ${totalFiles} parsed files. If the codebase has typed bindings, this indicates an upstream regression.`, - ); - } - } - - // Release the accumulator's heap footprint now. Both consumers of the - // accumulator have completed: - // 1. ExportedTypeMap enrichment loop (enrichExportedTypeMap, above). - // 2. Phase 9: processCallsFromExtracted in runChunkedParseAndResolve, - // which uses the accumulator as a BindingAccumulator fallback for - // cross-file return types when the SymbolTable has no returnType. - // Phase 14 (runCrossFileBindingPropagation) and runGraphAnalysisPhases - // do not read the accumulator — keeping it alive through those long- - // running phases pins heap for no reason. - bindingAccumulator.dispose(); - // Happy-path dispose completed — clear the cleanup ref so the catch - // handler doesn't attempt a second (harmless but noisy) dispose if a - // later phase throws. - bindingAccumulatorForCleanup = undefined; - - // ── Phase 14: Cross-file binding propagation (topological level sort) ── - await runCrossFileBindingPropagation( - graph, - ctx, - exportedTypeMap, - allPaths, - totalFiles, - repoPath, - pipelineStart, - onProgress, - ); - - // Post-parse graph analysis (MRO, communities, processes) - let communityResult: Awaited> | undefined; - let processResult: Awaited> | undefined; - - if (!options?.skipGraphPhases) { - const graphResults = await runGraphAnalysisPhases( - graph, - totalFiles, - onProgress, - routeRegistry, - toolDefs, - ); - communityResult = graphResults.communityResult; - processResult = graphResults.processResult; - } - - onProgress({ - phase: 'complete', - percent: 100, - message: - communityResult && processResult - ? `Graph complete! ${communityResult.stats.totalCommunities} communities, ${processResult.stats.totalProcesses} processes detected.` - : 'Graph complete! (graph phases skipped)', - stats: { - filesProcessed: totalFiles, - totalFiles, - nodesCreated: graph.nodeCount, - }, - }); - - return { graph, repoPath, totalFileCount: totalFiles, communityResult, processResult }; - } catch (error) { - // Error-path cleanup: dispose the accumulator if a step after the - // destructure from runChunkedParseAndResolve but before the happy-path - // dispose threw. The reference is cleared on the happy path, so this - // is a no-op when the pipeline completed successfully and then threw - // from an unrelated post-dispose step (e.g., future cleanup code). - bindingAccumulatorForCleanup?.dispose(); - ctx.clear(); - throw error; + if (!options?.skipGraphPhases) { + communityResult = getPhaseOutput(results, 'communities').communityResult; + processResult = getPhaseOutput(results, 'processes').processResult; } + + onProgress({ + phase: 'complete', + percent: 100, + message: + communityResult && processResult + ? `Graph complete! ${communityResult.stats.totalCommunities} communities, ${processResult.stats.totalProcesses} processes detected.` + : 'Graph complete! (graph phases skipped)', + stats: { + filesProcessed: totalFiles, + totalFiles, + nodesCreated: graph.nodeCount, + }, + }); + + return { graph, repoPath, totalFileCount: totalFiles, communityResult, processResult }; }; - -// Inline ORM regex extraction (avoids importing parse-worker which has worker-only code) -const PRISMA_QUERY_RE = - /\bprisma\.(\w+)\.(findMany|findFirst|findUnique|findUniqueOrThrow|findFirstOrThrow|create|createMany|update|updateMany|delete|deleteMany|upsert|count|aggregate|groupBy)\s*\(/g; -const SUPABASE_QUERY_RE = - /\bsupabase\.from\s*\(\s*['"](\w+)['"]\s*\)\s*\.(select|insert|update|delete|upsert)\s*\(/g; - -function extractORMQueriesInline( - filePath: string, - content: string, - out: ExtractedORMQuery[], -): void { - const hasPrisma = content.includes('prisma.'); - const hasSupabase = content.includes('supabase.from'); - if (!hasPrisma && !hasSupabase) return; - - if (hasPrisma) { - PRISMA_QUERY_RE.lastIndex = 0; - let m; - while ((m = PRISMA_QUERY_RE.exec(content)) !== null) { - const model = m[1]; - if (model.startsWith('$')) continue; - out.push({ - filePath, - orm: 'prisma', - model, - method: m[2], - lineNumber: content.substring(0, m.index).split('\n').length - 1, - }); - } - } - - if (hasSupabase) { - SUPABASE_QUERY_RE.lastIndex = 0; - let m; - while ((m = SUPABASE_QUERY_RE.exec(content)) !== null) { - out.push({ - filePath, - orm: 'supabase', - model: m[1], - method: m[2], - lineNumber: content.substring(0, m.index).split('\n').length - 1, - }); - } - } -} - -// ============================================================================ -// ORM Query Processing — creates QUERIES edges from callers to model nodes -// ============================================================================ - -function processORMQueries( - graph: ReturnType, - queries: ExtractedORMQuery[], - isDev: boolean, -): void { - const modelNodes = new Map(); - const seenEdges = new Set(); - let edgesCreated = 0; - - for (const q of queries) { - const modelKey = `${q.orm}:${q.model}`; - let modelNodeId = modelNodes.get(modelKey); - if (!modelNodeId) { - const candidateIds = [ - generateId('Class', `${q.model}`), - generateId('Interface', `${q.model}`), - generateId('CodeElement', `${q.model}`), - ]; - const existing = candidateIds.find((id) => graph.getNode(id)); - if (existing) { - modelNodeId = existing; - } else { - modelNodeId = generateId('CodeElement', `${q.orm}:${q.model}`); - graph.addNode({ - id: modelNodeId, - label: 'CodeElement', - properties: { - name: q.model, - filePath: '', - description: `${q.orm} model/table: ${q.model}`, - }, - }); - } - modelNodes.set(modelKey, modelNodeId); - } - - const fileId = generateId('File', q.filePath); - const edgeKey = `${fileId}->${modelNodeId}:${q.method}`; - if (seenEdges.has(edgeKey)) continue; - seenEdges.add(edgeKey); - - graph.addRelationship({ - id: generateId('QUERIES', edgeKey), - sourceId: fileId, - targetId: modelNodeId, - type: 'QUERIES', - confidence: 0.9, - reason: `${q.orm}-${q.method}`, - }); - edgesCreated++; - } - - if (isDev) { - console.log( - `ORM dataflow: ${edgesCreated} QUERIES edges, ${modelNodes.size} models (${queries.length} total calls)`, - ); - } -} diff --git a/gitnexus/src/core/ingestion/process-processor.ts b/gitnexus/src/core/ingestion/process-processor.ts index 6f06f52d1..a12378c98 100644 --- a/gitnexus/src/core/ingestion/process-processor.ts +++ b/gitnexus/src/core/ingestion/process-processor.ts @@ -15,8 +15,7 @@ import { KnowledgeGraph } from '../graph/types.js'; import { CommunityMembership } from './community-processor.js'; import { calculateEntryPointScore, isTestFile } from './entry-point-scoring.js'; import { SupportedLanguages } from 'gitnexus-shared'; - -const isDev = process.env.NODE_ENV === 'development'; +import { isDev } from './utils/env.js'; // ============================================================================ // CONFIGURATION diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index 49e82938a..2d4b794ae 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -209,13 +209,39 @@ export interface EnclosingClassInfo { } /** Walk up AST to find enclosing class/struct/interface/impl, return its ID and name. - * For Go method_declaration nodes, extracts receiver type (e.g. `func (u *User) Save()` → User struct). */ + * For Go method_declaration nodes, extracts receiver type (e.g. `func (u *User) Save()` → User struct). + * + * @param resolveEnclosingOwner Optional language-specific hook for container remapping. + * When provided and a CLASS_CONTAINER_TYPES node is found, this hook is called: + * - Return a different SyntaxNode to remap the container (e.g., Ruby singleton_class → class). + * - Return `null` to skip this container and keep walking up. + * - Return the input node (identity) to use the container as-is. + * When omitted, the container node is used as-is. + * + * INVARIANT: Implementers SHOULD return either `null`, the input node, or + * another CLASS_CONTAINER_TYPES node. Returning a non-container node is + * permitted but discouraged — it will cause the walk to skip the current + * container and continue from the redirected node's parent. The + * `MAX_ENCLOSING_WALK_ITERATIONS` defense-in-depth guard below prevents + * pathological hooks from creating an infinite loop. */ +const MAX_ENCLOSING_WALK_ITERATIONS = 4096; + export const findEnclosingClassInfo = ( node: SyntaxNode, filePath: string, + resolveEnclosingOwner?: (node: SyntaxNode) => SyntaxNode | null, ): EnclosingClassInfo | null => { let current = node.parent; + let iterations = 0; + // Tracks container nodes already visited via the hook so a misbehaving hook + // that keeps redirecting back to the same container cannot loop forever. + const visitedContainers = new Set(); while (current) { + if (++iterations > MAX_ENCLOSING_WALK_ITERATIONS) { + // Defense-in-depth: a real source tree has nowhere near this many ancestors. + // Bail out rather than hang ingestion. + return null; + } // Go: method_declaration has a receiver parameter with the struct type if (current.type === 'method_declaration') { const receiver = current.childForFieldName?.('receiver'); @@ -255,6 +281,29 @@ export const findEnclosingClassInfo = ( } } if (CLASS_CONTAINER_TYPES.has(current.type)) { + // Delegate language-specific container remapping to the provider hook. + if (resolveEnclosingOwner) { + if (visitedContainers.has(current)) { + // We've already asked the hook about this container once — a loop + // would form (e.g., hook redirects to a child node whose parent is + // this same container). Skip and walk up. + current = current.parent; + continue; + } + visitedContainers.add(current); + const resolved = resolveEnclosingOwner(current); + if (resolved === null) { + // Provider says skip this container — keep walking up. + current = current.parent; + continue; + } + if (resolved !== current) { + // Provider remapped to a different node — re-evaluate from there. + current = resolved; + continue; + } + } + // Rust impl_item: for `impl Trait for Struct {}`, pick the type after `for` // NOTE: This impl_item ownership logic is duplicated in rust.ts:extractOwnerName. // If modifying this block, update the other location too. @@ -286,26 +335,6 @@ export const findEnclosingClassInfo = ( } } - // Ruby singleton_class (class << self): walk up to the enclosing class/module - // to inherit its name. singleton_class has no name field — its receiver is - // `self` (node type 'self'), not 'identifier' or 'constant'. - if (current.type === 'singleton_class') { - let ancestor = current.parent; - while (ancestor) { - if (ancestor.type === 'class' || ancestor.type === 'module') { - const classNameNode = ancestor.childForFieldName?.('name'); - if (classNameNode) { - return { - classId: generateId('Class', `${filePath}:${classNameNode.text}`), - className: classNameNode.text, - }; - } - } - ancestor = ancestor.parent; - } - // No enclosing class/module — skip singleton_class and keep walking up - } - const nameNode = current.childForFieldName?.('name') ?? current.children?.find( diff --git a/gitnexus/src/core/ingestion/utils/env.ts b/gitnexus/src/core/ingestion/utils/env.ts new file mode 100644 index 000000000..b54a7d600 --- /dev/null +++ b/gitnexus/src/core/ingestion/utils/env.ts @@ -0,0 +1,11 @@ +/** + * Environment constants shared across the ingestion module. + * + * Centralizes `isDev` so every file in `ingestion/` imports from + * one canonical location rather than re-declaring the check. + * + * @module + */ + +/** Whether we're running in development mode (enables verbose console logging). */ +export const isDev = process.env.NODE_ENV === 'development'; diff --git a/gitnexus/src/core/ingestion/utils/graph-sort.ts b/gitnexus/src/core/ingestion/utils/graph-sort.ts new file mode 100644 index 000000000..81b728f3c --- /dev/null +++ b/gitnexus/src/core/ingestion/utils/graph-sort.ts @@ -0,0 +1,109 @@ +/** + * Topological level sort for file-level import graphs. + * + * Groups files into topological levels where files within the same level + * have no mutual import dependencies and can be processed in parallel. + * Files involved in import cycles are appended as a final group and + * processed last in an undefined order (best-effort propagation). + * + * Used by cross-file binding propagation to process files in the correct + * order — upstream exports must be resolved before downstream importers. + * + * @module + */ + +/** A group of files with no mutual dependencies, safe to process in parallel. */ +export type IndependentFileGroup = readonly string[]; + +/** + * Groups files by topological level using Kahn's algorithm on the **reverse** + * import graph. + * + * Files in the same level have no mutual dependencies — safe to process in parallel. + * Files involved in import cycles are appended as a final level and processed + * last in an undefined order (best-effort propagation, no ordering guarantees). + * + * ## Why the counter is named `pendingImportsPerFile` (not `inDegree`) + * + * Cross-file binding propagation must process **leaves first** — a file's + * imports must be resolved before the file itself is re-resolved. To get + * leaves first from Kahn's algorithm, we run Kahn's on the **reverse** of + * the import graph: + * + * - `importMap` is `importer → {imports}` (forward edges point at deps). + * - The reverse graph has edges `dep → {importers}`, materialized in + * `reverseDeps`. + * - On the reverse graph, "in-degree of node X" equals "number of imports X + * has in the forward graph" — i.e. X's forward **out-degree**. + * + * So `pendingImportsPerFile.get(file)` counts how many of `file`'s imports + * are still un-emitted. A file is ready (level 0 / appended to `currentLevel`) + * once all its imports have been emitted in earlier levels — that is, once + * its pending-imports count drops to 0. Pairing this counter with + * `reverseDeps` (dep → importers) is the standard Kahn's-on-the-reverse-graph + * formulation; it is **not** a bug to be "fixed" by counting forward + * in-degree (importers per file). + * + * **Do not rename this back to `inDegree` and do not invert the counting + * direction.** Doing either flips the emission order from leaves-first to + * roots-first, which silently breaks cross-file binding propagation + * (downstream files would be re-resolved before their upstream exports + * are available). + * + * @param importMap Map of file → set of files it imports (forward graph) + * @returns Levels (topologically ordered groups, leaves first) + * and count of files in cycles + */ +export function topologicalLevelSort(importMap: ReadonlyMap>): { + levels: readonly IndependentFileGroup[]; + cycleCount: number; +} { + // Per-file count of imports that have not yet been emitted in an earlier + // level. See JSDoc above for why this is **not** standard `inDegree`. + const pendingImportsPerFile = new Map(); + const reverseDeps = new Map(); + + for (const [file, deps] of importMap) { + if (!pendingImportsPerFile.has(file)) pendingImportsPerFile.set(file, 0); + for (const dep of deps) { + if (!pendingImportsPerFile.has(dep)) pendingImportsPerFile.set(dep, 0); + pendingImportsPerFile.set(file, (pendingImportsPerFile.get(file) ?? 0) + 1); + let rev = reverseDeps.get(dep); + if (!rev) { + rev = []; + reverseDeps.set(dep, rev); + } + rev.push(file); + } + } + + const levels: string[][] = []; + // Level 0: files with no un-emitted imports (true leaves of the import graph). + let currentLevel = [...pendingImportsPerFile.entries()] + .filter(([, d]) => d === 0) + .map(([f]) => f); + + while (currentLevel.length > 0) { + levels.push(currentLevel); + const nextLevel: string[] = []; + for (const file of currentLevel) { + // For each importer of `file`, one of its pending imports just got + // emitted — decrement the importer's pending count. If it hits 0, + // the importer is ready for the next level. + for (const dependent of reverseDeps.get(file) ?? []) { + const newPending = (pendingImportsPerFile.get(dependent) ?? 1) - 1; + pendingImportsPerFile.set(dependent, newPending); + if (newPending === 0) nextLevel.push(dependent); + } + } + currentLevel = nextLevel; + } + + // Anything still > 0 participates in a cycle — append in undefined order. + const cycleFiles = [...pendingImportsPerFile.entries()].filter(([, d]) => d > 0).map(([f]) => f); + if (cycleFiles.length > 0) { + levels.push(cycleFiles); + } + + return { levels, cycleCount: cycleFiles.length }; +} diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 0b0cdc499..bcafa38d2 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -561,7 +561,11 @@ const findEnclosingFunctionId = ( if (override !== null) finalLabel = override; } // Qualify with enclosing class to match definition-phase node IDs - const classInfo = cachedFindEnclosingClassInfo(current, filePath); + const classInfo = cachedFindEnclosingClassInfo( + current, + filePath, + provider.resolveEnclosingOwner, + ); const qualifiedName = classInfo ? `${classInfo.className}.${funcName}` : funcName; // Include # suffix to match definition-phase Method/Constructor IDs. // Use the same MethodExtractor (getMethodInfo) as the definition phase. @@ -613,6 +617,7 @@ const findEnclosingFunctionId = ( const classInfo = cachedFindEnclosingClassInfo( current.previousSibling ?? current, filePath, + provider.resolveEnclosingOwner, ); const qualifiedName = classInfo ? `${classInfo.className}.${customResult.funcName}` @@ -663,11 +668,12 @@ const findEnclosingFunctionId = ( const cachedFindEnclosingClassInfo = ( node: SyntaxNode, filePath: string, + resolveEnclosingOwner?: (node: SyntaxNode) => SyntaxNode | null, ): EnclosingClassInfo | null => { const cached = classIdCache.get(node); if (cached !== undefined) return cached; - const result = findEnclosingClassInfo(node, filePath); + const result = findEnclosingClassInfo(node, filePath, resolveEnclosingOwner); classIdCache.set(node, result); return result; }; @@ -1717,7 +1723,11 @@ const processFileGroup = ( } if (routed.kind === 'properties') { - const propEnclosingInfo = cachedFindEnclosingClassInfo(captureMap['call'], file.path); + const propEnclosingInfo = cachedFindEnclosingClassInfo( + captureMap['call'], + file.path, + provider.resolveEnclosingOwner, + ); const propEnclosingClassId = propEnclosingInfo?.classId ?? null; // Enrich routed properties with FieldExtractor metadata let routedFieldMap: Map | undefined; @@ -1946,7 +1956,11 @@ const processFileGroup = ( nodeLabel === 'Property' || nodeLabel === 'Function'; const enclosingClassInfo = needsOwner - ? cachedFindEnclosingClassInfo(nameNode || definitionNode, file.path) + ? cachedFindEnclosingClassInfo( + nameNode || definitionNode, + file.path, + provider.resolveEnclosingOwner, + ) : null; const enclosingClassId = enclosingClassInfo?.classId ?? null; diff --git a/gitnexus/test/fixtures/pipeline-golden/mini-repo/expected-graph.json b/gitnexus/test/fixtures/pipeline-golden/mini-repo/expected-graph.json new file mode 100644 index 000000000..7c9818696 --- /dev/null +++ b/gitnexus/test/fixtures/pipeline-golden/mini-repo/expected-graph.json @@ -0,0 +1,29 @@ +{ + "capture": "initial capture (U8, post-U1–U7)", + "fixture": "mini-repo", + "totalFileCount": 9, + "symbols": 57, + "relationships": 92, + "processes": 4, + "byType": { + "Class": 1, + "Community": 4, + "File": 9, + "Folder": 1, + "Function": 12, + "Interface": 3, + "Method": 1, + "Process": 4, + "Section": 22 + }, + "byRelType": { + "CALLS": 9, + "CONTAINS": 29, + "DEFINES": 17, + "HAS_METHOD": 1, + "IMPORTS": 12, + "MEMBER_OF": 12, + "STEP_IN_PROCESS": 12 + }, + "edgeDigest": "fdf012d4b4197377ab43e27217f9fe08ec46352945140f8b3406dacc3294b715" +} diff --git a/gitnexus/test/integration/pipeline-graph-golden.test.ts b/gitnexus/test/integration/pipeline-graph-golden.test.ts new file mode 100644 index 000000000..f008bdadb --- /dev/null +++ b/gitnexus/test/integration/pipeline-graph-golden.test.ts @@ -0,0 +1,194 @@ +/** + * Golden-file graph-parity test. + * + * Runs the full ingestion pipeline on the `mini-repo` fixture and compares + * the resulting graph against a committed golden JSON. Guards against silent + * behavioural drift from future refactors (post-U1–U7). + * + * Regenerate the golden file intentionally by running the test with + * `UPDATE_GOLDEN=1` in the environment. + * + * The snapshot captures: + * - totalFileCount + * - symbols (node count) and relationships (edge count) + * - byType: sorted map of NodeLabel -> count + * - byRelType: sorted map of RelationshipType -> count + * - processes count + * - edgeDigest: sha256 of deterministically-sorted `"type|src|dst"` strings + * + * Nothing path-dependent, time-dependent, or id-opaque leaks into the snapshot. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import fs from 'fs'; +import crypto from 'crypto'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; +import type { PipelineResult } from '../../src/types/pipeline.js'; + +const MINI_REPO = path.resolve(__dirname, '..', 'fixtures', 'mini-repo'); +const GOLDEN_DIR = path.resolve(__dirname, '..', 'fixtures', 'pipeline-golden', 'mini-repo'); +const GOLDEN_FILE = path.join(GOLDEN_DIR, 'expected-graph.json'); + +const UPDATE = process.env.UPDATE_GOLDEN === '1'; + +interface GraphSnapshot { + /** + * Tag to clarify provenance and to prime reviewers when the file first + * lands. Rewriting this string has no semantic meaning — only drift of + * the other fields matters for test pass/fail. + */ + capture: string; + fixture: string; + totalFileCount: number; + symbols: number; + relationships: number; + processes: number; + byType: Record; + byRelType: Record; + edgeDigest: string; +} + +/** + * Build a deterministic snapshot from a pipeline result. + * + * All maps are sorted by key so JSON serialization is stable. Edge digest + * is a sha256 over newline-joined, lexicographically-sorted `type|src|dst` + * triples — where `src` and `dst` are symbolic names (node label + property + * name + filePath for files) so that id generation changes don't cause + * spurious digest churn if they don't change the semantic edge set. + */ +function buildSnapshot(result: PipelineResult): GraphSnapshot { + const byType: Record = {}; + const byRelType: Record = {}; + + // Node id -> stable symbolic key for digest + const nodeKey = new Map(); + + result.graph.forEachNode((n) => { + byType[n.label] = (byType[n.label] ?? 0) + 1; + const props = n.properties as Record; + const fp = (props.filePath as string | undefined) ?? ''; + const nm = (props.name as string | undefined) ?? ''; + // Symbolic key: stable across id-format refactors as long as the + // (label, name, filePath) tuple is unchanged. + nodeKey.set(n.id, `${n.label}:${nm}@${fp}`); + }); + + const edgeTriples: string[] = []; + for (const rel of result.graph.iterRelationships()) { + byRelType[rel.type] = (byRelType[rel.type] ?? 0) + 1; + const src = nodeKey.get(rel.sourceId) ?? `?:${rel.sourceId}`; + const dst = nodeKey.get(rel.targetId) ?? `?:${rel.targetId}`; + // step included for STEP_IN_PROCESS so the digest captures ordering + const step = rel.step !== undefined ? `#${rel.step}` : ''; + edgeTriples.push(`${rel.type}${step}|${src}|${dst}`); + } + edgeTriples.sort(); + + const digest = crypto.createHash('sha256').update(edgeTriples.join('\n')).digest('hex'); + + return { + capture: 'initial capture (U8, post-U1–U7)', + fixture: 'mini-repo', + totalFileCount: result.totalFileCount, + symbols: result.graph.nodeCount, + relationships: result.graph.relationshipCount, + processes: result.processResult?.stats.totalProcesses ?? 0, + byType: sortObject(byType), + byRelType: sortObject(byRelType), + edgeDigest: digest, + }; +} + +function sortObject(obj: Record): Record { + const out: Record = {}; + for (const k of Object.keys(obj).sort()) out[k] = obj[k]; + return out; +} + +function formatGolden(snapshot: GraphSnapshot): string { + return JSON.stringify(snapshot, null, 2) + '\n'; +} + +function diffCounts( + label: string, + actual: Record, + expected: Record, +): string[] { + const lines: string[] = []; + const keys = new Set([...Object.keys(actual), ...Object.keys(expected)]); + for (const k of [...keys].sort()) { + const a = actual[k] ?? 0; + const e = expected[k] ?? 0; + if (a !== e) lines.push(` ${label}.${k}: expected ${e}, got ${a}`); + } + return lines; +} + +describe('pipeline graph golden', () => { + let result: PipelineResult; + let snapshot: GraphSnapshot; + + beforeAll(async () => { + result = await runPipelineFromRepo(MINI_REPO, () => {}); + snapshot = buildSnapshot(result); + }, 60000); + + it('matches committed golden snapshot on mini-repo', () => { + if (UPDATE || !fs.existsSync(GOLDEN_FILE)) { + fs.mkdirSync(GOLDEN_DIR, { recursive: true }); + fs.writeFileSync(GOLDEN_FILE, formatGolden(snapshot), 'utf8'); + // First run / intentional regen: succeed and report. + // Subsequent CI runs without UPDATE_GOLDEN will diff against this file. + console.log( + `[pipeline-graph-golden] ${UPDATE ? 'Regenerated' : 'Created'} golden file at ${GOLDEN_FILE}`, + ); + return; + } + + const rawExpected = fs.readFileSync(GOLDEN_FILE, 'utf8'); + const expected = JSON.parse(rawExpected) as GraphSnapshot; + + const diffs: string[] = []; + if (snapshot.totalFileCount !== expected.totalFileCount) { + diffs.push( + ` totalFileCount: expected ${expected.totalFileCount}, got ${snapshot.totalFileCount}`, + ); + } + if (snapshot.symbols !== expected.symbols) { + diffs.push(` symbols (nodeCount): expected ${expected.symbols}, got ${snapshot.symbols}`); + } + if (snapshot.relationships !== expected.relationships) { + diffs.push( + ` relationships (edgeCount): expected ${expected.relationships}, got ${snapshot.relationships}`, + ); + } + if (snapshot.processes !== expected.processes) { + diffs.push(` processes: expected ${expected.processes}, got ${snapshot.processes}`); + } + diffs.push(...diffCounts('byType', snapshot.byType, expected.byType)); + diffs.push(...diffCounts('byRelType', snapshot.byRelType, expected.byRelType)); + if (snapshot.edgeDigest !== expected.edgeDigest) { + diffs.push( + ` edgeDigest changed: the set of (type, source-symbol, target-symbol) edge triples differs from golden. ` + + `Counts may match while edges are rewired — inspect graph manually or re-run with UPDATE_GOLDEN=1 if the change is intentional.`, + ); + } + + if (diffs.length > 0) { + const msg = [ + 'Pipeline graph output drifted from golden snapshot.', + `Golden file: ${GOLDEN_FILE}`, + 'Changes:', + ...diffs, + '', + 'If this drift is intentional, regenerate the golden file with:', + ' UPDATE_GOLDEN=1 npm --prefix gitnexus test -- pipeline-graph-golden', + ].join('\n'); + throw new Error(msg); + } + + // Belt-and-suspenders: if nothing diffed, the serialized forms should match too. + expect(formatGolden(snapshot)).toBe(rawExpected); + }); +}); diff --git a/gitnexus/test/unit/binding-accumulator.test.ts b/gitnexus/test/unit/binding-accumulator.test.ts index 95b1ddef8..80f61b3f5 100644 --- a/gitnexus/test/unit/binding-accumulator.test.ts +++ b/gitnexus/test/unit/binding-accumulator.test.ts @@ -593,18 +593,16 @@ describe('BindingAccumulator', () => { expect(acc.totalBindings).toBe(0); }); - it('works before finalize() — accumulator behaves like a fresh one after dispose', () => { + it('appendFile after dispose throws with the expected message', () => { + // Single-use lifecycle: dispose is terminal. Any subsequent append is + // a programming error (the consumer is treating a released accumulator + // as if it were live). Convert the silent failure into a loud one. const acc = new BindingAccumulator(); acc.appendFile('src/a.ts', [{ scope: '', varName: 'x', typeName: 'X' }]); acc.dispose(); - // Not finalized, so appends still work post-dispose. expect(() => acc.appendFile('src/b.ts', [{ scope: '', varName: 'y', typeName: 'Y' }]), - ).not.toThrow(); - expect(acc.fileCount).toBe(1); - expect(acc.totalBindings).toBe(1); - expect(acc.getFile('src/b.ts')).toHaveLength(1); - expect(acc.getFile('src/a.ts')).toBeUndefined(); + ).toThrow('BindingAccumulator: use after dispose'); }); it('works after finalize() — append still throws, reads return empty', () => { @@ -612,11 +610,13 @@ describe('BindingAccumulator', () => { acc.appendFile('src/a.ts', [{ scope: '', varName: 'x', typeName: 'X' }]); acc.finalize(); acc.dispose(); - // Finalized, so appends throw even post-dispose. + // Finalized takes precedence — the finalize check runs first in + // appendFile, so the error is the "finalize" one, not the + // "use after dispose" one. expect(() => acc.appendFile('src/b.ts', [{ scope: '', varName: 'y', typeName: 'Y' }]), ).toThrow(/finalize/); - // But reads return empty. + // Reads still return empty. expect(acc.fileCount).toBe(0); expect(acc.totalBindings).toBe(0); expect(acc.getFile('src/a.ts')).toBeUndefined(); diff --git a/gitnexus/test/unit/cross-file-impl.test.ts b/gitnexus/test/unit/cross-file-impl.test.ts new file mode 100644 index 000000000..55a47a5b5 --- /dev/null +++ b/gitnexus/test/unit/cross-file-impl.test.ts @@ -0,0 +1,206 @@ +/** + * Coverage tests for cross-file-impl.ts — `runCrossFileBindingPropagation`. + * + * Scenarios aimed at branches the integration tests exercise only on the + * happy path: + * 1. gapRatio < CROSS_FILE_SKIP_THRESHOLD → returns 0 without reprocess. + * 2. MAX_CROSS_FILE_REPROCESS cap → outer level loop breaks. + * 3. parse-supplied exportedTypeMap is NEVER mutated by crossFile (cross + * file builds its own local working copy for re-resolution writes). + * 4. namedImportMap.size === 0 → returns 0 immediately. + * + * Note: `processCalls`, `readFileContents`, and `isLanguageAvailable` are + * mocked so the test doesn't require tree-sitter or filesystem access. + * `buildImportedReturnTypes` and `buildImportedRawReturnTypes` are preserved + * via `importOriginal`. The graph-fallback enrichment that used to live here + * was moved into parse-impl's `runChunkedParseAndResolve` so the parse phase + * hands crossFile a fully-populated, truly read-only map. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../../src/core/ingestion/call-processor.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + processCalls: vi.fn(async () => {}), + }; +}); + +vi.mock('../../src/core/ingestion/filesystem-walker.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + readFileContents: vi.fn(async (_repo: string, paths: string[]) => { + const m = new Map(); + for (const p of paths) m.set(p, '// stub'); + return m; + }), + }; +}); + +vi.mock('../../src/core/tree-sitter/parser-loader.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + isLanguageAvailable: vi.fn(() => true), + }; +}); + +import { runCrossFileBindingPropagation } from '../../src/core/ingestion/pipeline-phases/cross-file-impl.js'; +import { processCalls } from '../../src/core/ingestion/call-processor.js'; +import { createResolutionContext } from '../../src/core/ingestion/model/resolution-context.js'; +import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; +import type { ExportedTypeMap } from '../../src/core/ingestion/call-processor.js'; + +const processCallsMock = vi.mocked(processCalls); + +describe('runCrossFileBindingPropagation', () => { + beforeEach(() => { + processCallsMock.mockClear(); + }); + + it('returns 0 immediately when namedImportMap is empty', async () => { + const graph = createKnowledgeGraph(); + const ctx = createResolutionContext(); + const exportedTypeMap: ExportedTypeMap = new Map([ + ['upstream.ts', new Map([['User', 'User']])], + ]); + + const result = await runCrossFileBindingPropagation( + graph, + ctx, + exportedTypeMap, + new Set(['upstream.ts']), + 1, + '/repo', + Date.now(), + () => {}, + ); + + expect(result).toBe(0); + expect(processCallsMock).not.toHaveBeenCalled(); + }); + + it('returns 0 when gapRatio < CROSS_FILE_SKIP_THRESHOLD', async () => { + const graph = createKnowledgeGraph(); + const ctx = createResolutionContext(); + + // 100 files total; exportedTypeMap has an export but no downstream + // namedImportMap entry references a matching name → zero gaps. + const exportedTypeMap: ExportedTypeMap = new Map([ + ['upstream.ts', new Map([['User', 'User']])], + ]); + + // One downstream importer whose binding points at a symbol NOT in + // exportedTypeMap and NOT in ctx.model.symbols → no gap-filling seed + // available, so filesWithGaps stays at 0. + const downstreamBindings = new Map(); + downstreamBindings.set('Missing', { + sourcePath: 'upstream.ts', + exportedName: 'Missing', + }); + ctx.namedImportMap.set('downstream.ts', downstreamBindings); + + const totalFiles = 100; // threshold = ceil(100 * 0.03) = 3 + + const result = await runCrossFileBindingPropagation( + graph, + ctx, + exportedTypeMap, + new Set(['downstream.ts', 'upstream.ts']), + totalFiles, + '/repo', + Date.now(), + () => {}, + ); + + expect(result).toBe(0); + expect(processCallsMock).not.toHaveBeenCalled(); + }); + + it('does not mutate the parse-supplied exportedTypeMap (works on a local copy)', async () => { + const graph = createKnowledgeGraph(); + const ctx = createResolutionContext(); + + // Seed a single upstream export and a downstream importer so the gap + // ratio crosses the skip threshold and processCalls (mocked) is invoked. + const parseExports: ExportedTypeMap = new Map([['upstream.ts', new Map([['User', 'User']])]]); + const parseExportsSnapshot = new Map( + Array.from(parseExports, ([k, v]) => [k, new Map(v)] as const), + ); + + const bindings = new Map(); + bindings.set('User', { sourcePath: 'upstream.ts', exportedName: 'User' }); + ctx.namedImportMap.set('downstream.ts', bindings); + ctx.importMap.set('upstream.ts', new Set()); + ctx.importMap.set('downstream.ts', new Set(['upstream.ts'])); + + await runCrossFileBindingPropagation( + graph, + ctx, + parseExports, + new Set(['downstream.ts', 'upstream.ts']), + 10, + '/repo', + Date.now(), + () => {}, + ); + + // Outer map identity preserved, sizes unchanged, inner Maps unchanged — + // crossFile must operate on its own working copy. + expect(parseExports.size).toBe(parseExportsSnapshot.size); + for (const [k, v] of parseExportsSnapshot) { + const after = parseExports.get(k); + expect(after).toBeDefined(); + expect(after!.size).toBe(v.size); + for (const [innerK, innerV] of v) { + expect(after!.get(innerK)).toBe(innerV); + } + } + }); + + it('caps processing at MAX_CROSS_FILE_REPROCESS (2000)', async () => { + const graph = createKnowledgeGraph(); + const ctx = createResolutionContext(); + + // Seed one upstream export reused by every downstream file. + const exportedTypeMap: ExportedTypeMap = new Map([ + ['upstream.ts', new Map([['User', 'User']])], + ]); + + const allPaths: string[] = ['upstream.ts']; + // Create 2100 downstream importers — each will qualify as a candidate + // (seeded.size === 1 because upstream.ts has the export we bind to). + // Populate ctx.importMap so topologicalLevelSort returns real levels. + ctx.importMap.set('upstream.ts', new Set()); + for (let i = 0; i < 2100; i++) { + const file = `downstream${i}.ts`; + allPaths.push(file); + const bindings = new Map(); + bindings.set('User', { sourcePath: 'upstream.ts', exportedName: 'User' }); + ctx.namedImportMap.set(file, bindings); + ctx.importMap.set(file, new Set(['upstream.ts'])); + } + + const totalFiles = allPaths.length; + + const result = await runCrossFileBindingPropagation( + graph, + ctx, + exportedTypeMap, + new Set(allPaths), + totalFiles, + '/repo', + Date.now(), + () => {}, + ); + + // Hard cap is 2000. The function returns `crossFileResolved`, which + // equals MAX_CROSS_FILE_REPROCESS once the cap is hit. + expect(result).toBe(2000); + expect(processCallsMock).toHaveBeenCalledTimes(2000); + }); +}); diff --git a/gitnexus/test/unit/cross-file.test.ts b/gitnexus/test/unit/cross-file.test.ts new file mode 100644 index 000000000..6e2ce6aee --- /dev/null +++ b/gitnexus/test/unit/cross-file.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { BindingAccumulator } from '../../src/core/ingestion/binding-accumulator.js'; + +// Mock the cross-file-impl module so we can control whether the propagation +// step throws or returns cleanly. The `crossFilePhase` only depends on this +// one external symbol — nothing else in the body has to be stubbed. +vi.mock('../../src/core/ingestion/pipeline-phases/cross-file-impl.js', () => ({ + runCrossFileBindingPropagation: vi.fn(), +})); + +import { runCrossFileBindingPropagation } from '../../src/core/ingestion/pipeline-phases/cross-file-impl.js'; +import { crossFilePhase } from '../../src/core/ingestion/pipeline-phases/cross-file.js'; +import type { + PipelineContext, + PhaseResult, +} from '../../src/core/ingestion/pipeline-phases/types.js'; +import type { ParseOutput } from '../../src/core/ingestion/pipeline-phases/parse.js'; + +const runCrossFileMock = vi.mocked(runCrossFileBindingPropagation); + +function makeCtx(): PipelineContext { + return { + repoPath: '/tmp/repo', + // Cast — the body never touches graph methods on the happy/error paths + // this test exercises (the propagation call is stubbed). + graph: {} as PipelineContext['graph'], + onProgress: () => {}, + pipelineStart: 0, + }; +} + +function makeParseOutput(acc: BindingAccumulator): ParseOutput { + return { + exportedTypeMap: new Map(), + allFetchCalls: [], + allExtractedRoutes: [], + allDecoratorRoutes: [], + allToolDefs: [], + allORMQueries: [], + bindingAccumulator: acc, + // Cast — the body forwards this to the (mocked) propagation fn but + // never inspects it. + resolutionContext: {} as ParseOutput['resolutionContext'], + allPaths: [], + totalFiles: 0, + }; +} + +function makeDeps(acc: BindingAccumulator): ReadonlyMap> { + return new Map>([ + [ + 'parse', + { + phaseName: 'parse', + output: makeParseOutput(acc), + durationMs: 0, + }, + ], + ]); +} + +describe('crossFilePhase', () => { + beforeEach(() => { + runCrossFileMock.mockReset(); + }); + + it('disposes the binding accumulator on the happy path', async () => { + runCrossFileMock.mockResolvedValueOnce(7); + + const acc = new BindingAccumulator(); + acc.appendFile('src/a.ts', [{ scope: '', varName: 'x', typeName: 'X' }]); + expect(acc.disposed).toBe(false); + + const result = await crossFilePhase.execute(makeCtx(), makeDeps(acc)); + + expect(result.filesReprocessed).toBe(7); + expect(acc.disposed).toBe(true); + // Post-dispose contract holds. + expect(acc.fileCount).toBe(0); + expect(acc.totalBindings).toBe(0); + }); + + it('disposes the binding accumulator even when propagation throws', async () => { + // Error-injection: the leak-on-throw gap — without the finally block, + // the accumulator would stay live (and reachable via the closed-over + // ParseOutput) until GC. With the finally block, dispose runs on the + // unwind and the heap is released regardless. + const boom = new Error('cross-file propagation exploded'); + runCrossFileMock.mockRejectedValueOnce(boom); + + const acc = new BindingAccumulator(); + acc.appendFile('src/a.ts', [{ scope: '', varName: 'x', typeName: 'X' }]); + + await expect(crossFilePhase.execute(makeCtx(), makeDeps(acc))).rejects.toBe(boom); + + expect(acc.disposed).toBe(true); + expect(acc.fileCount).toBe(0); + expect(acc.totalBindings).toBe(0); + }); +}); diff --git a/gitnexus/test/unit/method-extraction.test.ts b/gitnexus/test/unit/method-extraction.test.ts index b6d36cf67..c8b7b31eb 100644 --- a/gitnexus/test/unit/method-extraction.test.ts +++ b/gitnexus/test/unit/method-extraction.test.ts @@ -4623,3 +4623,199 @@ type Animal interface { }); }); }); + +// --------------------------------------------------------------------------- +// Regression: config-driven staticOwnerTypes (no hardcoded STATIC_OWNER_TYPES) +// --------------------------------------------------------------------------- + +const extractor_ruby = createMethodExtractor(rubyMethodConfig); +const extractor_kotlin = Kotlin ? createMethodExtractor(kotlinMethodConfig) : null; + +describe('staticOwnerTypes config-driven static detection', () => { + it('Ruby: singleton_class methods are static via rubyMethodConfig.staticOwnerTypes', () => { + expect(rubyMethodConfig.staticOwnerTypes).toBeDefined(); + expect(rubyMethodConfig.staticOwnerTypes!.has('singleton_class')).toBe(true); + + const tree = parseRuby(` +class Animal + class << self + def from_habitat(habitat) + end + end +end + `); + const classNode = tree.rootNode.child(0)!; + const bodyStmt = classNode.namedChildren.find((c) => c.type === 'body_statement')!; + const singletonClass = bodyStmt.namedChildren.find((c) => c.type === 'singleton_class')!; + const result = extractor_ruby.extract(singletonClass, rubyCtx); + + expect(result).not.toBeNull(); + expect(result!.ownerName).toBe('Animal'); + expect(result!.methods[0].name).toBe('from_habitat'); + expect(result!.methods[0].isStatic).toBe(true); + }); + + (Kotlin ? it : it.skip)( + 'Kotlin: companion_object methods are static via kotlinMethodConfig.staticOwnerTypes', + () => { + expect(kotlinMethodConfig.staticOwnerTypes).toBeDefined(); + expect(kotlinMethodConfig.staticOwnerTypes!.has('companion_object')).toBe(true); + expect(kotlinMethodConfig.staticOwnerTypes!.has('object_declaration')).toBe(true); + + const tree = parseKotlin(` + class Service { + companion object { + fun create(): Service = Service() + } + } + `); + const classNode = tree.rootNode.child(0)!; + const classBody = classNode.namedChild(1)!; + const companion = classBody.namedChild(0)!; + const result = extractor_kotlin!.extract(companion, kotlinCtx); + + expect(result).not.toBeNull(); + expect(result!.methods[0].name).toBe('create'); + expect(result!.methods[0].isStatic).toBe(true); + }, + ); + + (Kotlin ? it : it.skip)( + 'Kotlin: object_declaration methods are static via staticOwnerTypes', + () => { + const tree = parseKotlin(` + object Singleton { + fun instance(): Singleton = Singleton() + } + `); + const objDecl = tree.rootNode.child(0)!; + const result = extractor_kotlin!.extract(objDecl, kotlinCtx); + + expect(result).not.toBeNull(); + expect(result!.methods[0].name).toBe('instance'); + expect(result!.methods[0].isStatic).toBe(true); + }, + ); + + it('languages without staticOwnerTypes do not have implicit static owner types', () => { + // These configs should NOT have staticOwnerTypes set — static detection + // is purely from their isStatic() method, not from shared STATIC_OWNER_TYPES. + expect(javaMethodConfig.staticOwnerTypes).toBeUndefined(); + expect(typescriptMethodConfig.staticOwnerTypes).toBeUndefined(); + expect(pythonMethodConfig.staticOwnerTypes).toBeUndefined(); + expect(cppMethodConfig.staticOwnerTypes).toBeUndefined(); + expect(csharpMethodConfig.staticOwnerTypes).toBeUndefined(); + expect(phpMethodConfig.staticOwnerTypes).toBeUndefined(); + expect(goMethodConfig.staticOwnerTypes).toBeUndefined(); + expect(rustMethodConfig.staticOwnerTypes).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// U5: Runtime guard — misconfigured staticOwnerTypes must throw at construction +// --------------------------------------------------------------------------- + +import type { MethodExtractionConfig } from '../../src/core/ingestion/method-types.js'; + +/** + * Minimal stub config factory. Caller overrides `typeDeclarationNodes` and + * `staticOwnerTypes` to exercise the guard; everything else is a no-op. + */ +function makeStubConfig(overrides: Partial = {}): MethodExtractionConfig { + return { + language: SupportedLanguages.Kotlin, + typeDeclarationNodes: [], + methodNodeTypes: [], + bodyNodeTypes: [], + extractName: () => undefined, + extractReturnType: () => undefined, + extractParameters: () => [], + extractVisibility: () => 'public', + isStatic: () => false, + isAbstract: () => false, + isFinal: () => false, + ...overrides, + }; +} + +describe('createMethodExtractor — staticOwnerTypes guard (U5)', () => { + it('throws when companion_object is in typeDeclarationNodes but staticOwnerTypes is missing', () => { + const config = makeStubConfig({ + typeDeclarationNodes: ['class_declaration', 'companion_object'], + // staticOwnerTypes intentionally omitted + }); + expect(() => createMethodExtractor(config)).toThrow(/companion_object/); + }); + + it('throws when object_declaration is in typeDeclarationNodes but staticOwnerTypes is missing', () => { + const config = makeStubConfig({ + typeDeclarationNodes: ['object_declaration'], + }); + expect(() => createMethodExtractor(config)).toThrow(/object_declaration/); + }); + + it('throws when singleton_class is listed but staticOwnerTypes contains wrong entries', () => { + const config = makeStubConfig({ + typeDeclarationNodes: ['class', 'singleton_class'], + staticOwnerTypes: new Set(['companion_object']), // wrong — missing singleton_class + }); + expect(() => createMethodExtractor(config)).toThrow(/singleton_class/); + }); + + it('names the language in the error message when available', () => { + const config = makeStubConfig({ + language: SupportedLanguages.Kotlin, + typeDeclarationNodes: ['companion_object'], + }); + expect(() => createMethodExtractor(config)).toThrow(/kotlin/i); + }); + + // Happy paths — real configs must continue to construct cleanly. + + it('accepts the current Kotlin config (companion_object + object_declaration covered)', () => { + expect(() => createMethodExtractor(kotlinMethodConfig)).not.toThrow(); + }); + + it('accepts the current Ruby config (singleton_class covered)', () => { + expect(() => createMethodExtractor(rubyMethodConfig)).not.toThrow(); + }); + + it('accepts configs with no static-implying node types (Java, Python)', () => { + expect(() => createMethodExtractor(javaMethodConfig)).not.toThrow(); + expect(() => createMethodExtractor(pythonMethodConfig)).not.toThrow(); + }); + + it('accepts all currently registered language configs', () => { + const configs: MethodExtractionConfig[] = [ + javaMethodConfig, + kotlinMethodConfig, + csharpMethodConfig, + typescriptMethodConfig, + javascriptMethodConfig, + cppMethodConfig, + pythonMethodConfig, + rubyMethodConfig, + rustMethodConfig, + dartMethodConfig, + phpMethodConfig, + swiftMethodConfig, + goMethodConfig, + ]; + for (const cfg of configs) { + expect( + () => createMethodExtractor(cfg), + `config for ${cfg.language} must construct cleanly`, + ).not.toThrow(); + } + }); + + // Edge case — explicit empty Set is the documented opt-out convention. + + it('allows explicit opt-out via staticOwnerTypes: new Set() (empty Set)', () => { + const config = makeStubConfig({ + typeDeclarationNodes: ['companion_object'], + staticOwnerTypes: new Set(), // explicit opt-out: "yes I know, I handle static-ness elsewhere" + }); + expect(() => createMethodExtractor(config)).not.toThrow(); + }); +}); diff --git a/gitnexus/test/unit/parse-impl-fallback.test.ts b/gitnexus/test/unit/parse-impl-fallback.test.ts new file mode 100644 index 000000000..869016552 --- /dev/null +++ b/gitnexus/test/unit/parse-impl-fallback.test.ts @@ -0,0 +1,204 @@ +/** + * U6 — Sequential-fallback cleanup safety. + * + * Verifies that `runChunkedParseAndResolve` runs its cleanup steps + * (`astCache.clear()`, `bindingAccumulator.finalize()`, + * `enrichExportedTypeMap`) even when the sequential-fallback loop throws + * mid-iteration. These tests exercise the try/finally added in U6. + * + * We drive the sequential fallback by passing `{ skipWorkers: true }` so the + * worker pool is never created and `sequentialChunkPaths` is populated with + * every chunk. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +// Spies captured from the module mocks below — populated per-test. +const spies = { + astCacheClearCalls: 0, + resetSpies() { + this.astCacheClearCalls = 0; + }, +}; + +// Controls which dependency throws for a given test. +// `readFileContentsFailAfter`: call count threshold — fail once the N-th call +// is reached. The first `readFileContents` call happens in the outer +// worker/parse loop (before sequential fallback); we want to fail only on the +// second call (inside the fallback) so the U6 try/finally is exercised. +const failureConfig: { + readFileContentsFailAfter: number; + readFileContentsCalls: number; + processCalls: boolean; +} = { + readFileContentsFailAfter: Infinity, + readFileContentsCalls: 0, + processCalls: false, +}; + +vi.mock('../../src/core/ingestion/filesystem-walker.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + readFileContents: vi.fn(async (repoPath: string, chunkPaths: string[]) => { + failureConfig.readFileContentsCalls += 1; + if (failureConfig.readFileContentsCalls >= failureConfig.readFileContentsFailAfter) { + throw new Error('injected readFileContents failure'); + } + return actual.readFileContents(repoPath, chunkPaths); + }), + }; +}); + +vi.mock('../../src/core/ingestion/call-processor.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + processCalls: vi.fn(async (...args: unknown[]) => { + if (failureConfig.processCalls) { + throw new Error('injected processCalls failure'); + } + // Delegate to original + return (actual.processCalls as unknown as (...a: unknown[]) => Promise)(...args); + }), + }; +}); + +// Wrap createASTCache so we can count clear() calls across all cache instances. +vi.mock('../../src/core/ingestion/ast-cache.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createASTCache: (max?: number) => { + const cache = actual.createASTCache(max); + const origClear = cache.clear.bind(cache); + cache.clear = () => { + spies.astCacheClearCalls += 1; + origClear(); + }; + return cache; + }, + }; +}); + +// Import after the mocks so bindings reference the wrapped versions. +const { runChunkedParseAndResolve } = + await import('../../src/core/ingestion/pipeline-phases/parse-impl.js'); +const { createKnowledgeGraph } = await import('../../src/core/graph/graph.js'); + +function makeTempRepo(files: Record): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'parse-impl-fallback-')); + for (const [rel, content] of Object.entries(files)) { + const abs = path.join(dir, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, content); + } + return dir; +} + +function scanned(repo: string, files: string[]) { + return files.map((rel) => ({ + path: rel, + size: fs.statSync(path.join(repo, rel)).size, + })); +} + +describe('parse-impl sequential fallback cleanup (U6)', () => { + let repoPath = ''; + + beforeEach(() => { + spies.resetSpies(); + failureConfig.readFileContentsFailAfter = Infinity; + failureConfig.readFileContentsCalls = 0; + failureConfig.processCalls = false; + repoPath = makeTempRepo({ + 'a.ts': `export function foo() { return 1; }\n`, + 'b.ts': `import { foo } from './a';\nexport function bar() { return foo(); }\n`, + }); + }); + + afterEach(() => { + if (repoPath && fs.existsSync(repoPath)) { + fs.rmSync(repoPath, { recursive: true, force: true }); + } + }); + + it('happy path: sequential fallback completes and bindingAccumulator is finalized', async () => { + const graph = createKnowledgeGraph(); + const files = ['a.ts', 'b.ts']; + const result = await runChunkedParseAndResolve( + graph, + scanned(repoPath, files), + files, + files.length, + repoPath, + Date.now(), + () => {}, + { skipWorkers: true }, + ); + // Happy path — should return a BindingAccumulator and clear astCache at + // least once (per-chunk + finally). + expect(result.bindingAccumulator).toBeDefined(); + expect(spies.astCacheClearCalls).toBeGreaterThanOrEqual(1); + // finalize() on a BindingAccumulator makes it read-only; appending after + // finalize throws. We use that to prove finalize actually ran. + expect(() => + result.bindingAccumulator.appendFile('after.ts', [ + { scope: '', varName: 'x', typeName: 'number' }, + ]), + ).toThrow(); + }); + + it('error path: readFileContents throws mid-fallback — astCache is cleared and finalize runs', async () => { + const graph = createKnowledgeGraph(); + const files = ['a.ts', 'b.ts']; + // Fail the second readFileContents call — first call is in the outer + // worker/parse loop, second is inside the sequential fallback. + failureConfig.readFileContentsFailAfter = 2; + + const clearsBefore = spies.astCacheClearCalls; + await expect( + runChunkedParseAndResolve( + graph, + scanned(repoPath, files), + files, + files.length, + repoPath, + Date.now(), + () => {}, + { skipWorkers: true }, + ), + ).rejects.toThrow(/injected readFileContents failure/); + + // Finally-block must have cleared astCache at least once on the error path. + expect(spies.astCacheClearCalls).toBeGreaterThan(clearsBefore); + }); + + it('error path: processCalls throws in fallback loop — cleanup still runs', async () => { + const graph = createKnowledgeGraph(); + const files = ['a.ts', 'b.ts']; + failureConfig.processCalls = true; + + const clearsBefore = spies.astCacheClearCalls; + await expect( + runChunkedParseAndResolve( + graph, + scanned(repoPath, files), + files, + files.length, + repoPath, + Date.now(), + () => {}, + { skipWorkers: true }, + ), + ).rejects.toThrow(/injected processCalls failure/); + + // astCache.clear() must have run in the finally block. + expect(spies.astCacheClearCalls).toBeGreaterThan(clearsBefore); + }); +}); diff --git a/gitnexus/test/unit/pipeline-runner.test.ts b/gitnexus/test/unit/pipeline-runner.test.ts new file mode 100644 index 000000000..bddde7bd1 --- /dev/null +++ b/gitnexus/test/unit/pipeline-runner.test.ts @@ -0,0 +1,417 @@ +import { describe, it, expect } from 'vitest'; +import { runPipeline } from '../../src/core/ingestion/pipeline-phases/runner.js'; +import type { + PipelinePhase, + PipelineContext, + PhaseResult, +} from '../../src/core/ingestion/pipeline-phases/types.js'; +import { getPhaseOutput } from '../../src/core/ingestion/pipeline-phases/types.js'; +import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; + +function makeCtx(): PipelineContext { + return { + repoPath: '/tmp/test', + graph: createKnowledgeGraph(), + onProgress: () => {}, + pipelineStart: Date.now(), + }; +} + +describe('runPipeline', () => { + it('executes phases in dependency order', async () => { + const order: string[] = []; + + const phaseA: PipelinePhase = { + name: 'a', + deps: [], + async execute() { + order.push('a'); + return 'resultA'; + }, + }; + + const phaseB: PipelinePhase = { + name: 'b', + deps: ['a'], + async execute(_ctx, deps) { + const a = getPhaseOutput(deps, 'a'); + order.push('b'); + return `${a}+B`; + }, + }; + + const phaseC: PipelinePhase = { + name: 'c', + deps: ['a'], + async execute(_ctx, deps) { + const a = getPhaseOutput(deps, 'a'); + order.push('c'); + return `${a}+C`; + }, + }; + + const phaseD: PipelinePhase = { + name: 'd', + deps: ['b', 'c'], + async execute(_ctx, deps) { + const b = getPhaseOutput(deps, 'b'); + const c = getPhaseOutput(deps, 'c'); + order.push('d'); + return `${b}|${c}`; + }, + }; + + const results = await runPipeline([phaseD, phaseA, phaseC, phaseB], makeCtx()); + + // A must run before B and C; B and C must run before D + expect(order.indexOf('a')).toBeLessThan(order.indexOf('b')); + expect(order.indexOf('a')).toBeLessThan(order.indexOf('c')); + expect(order.indexOf('b')).toBeLessThan(order.indexOf('d')); + expect(order.indexOf('c')).toBeLessThan(order.indexOf('d')); + + // Check outputs are correctly threaded + expect(results.get('d')?.output).toBe('resultA+B|resultA+C'); + }); + + it('passes shared PipelineContext to every phase', async () => { + const ctx = makeCtx(); + const seenContexts: PipelineContext[] = []; + + const phase: PipelinePhase = { + name: 'test', + deps: [], + async execute(c) { + seenContexts.push(c); + }, + }; + + await runPipeline([phase], ctx); + expect(seenContexts).toHaveLength(1); + expect(seenContexts[0]).toBe(ctx); + }); + + it('records timing metadata in PhaseResult', async () => { + const phase: PipelinePhase = { + name: 'slow', + deps: [], + async execute() { + await new Promise((r) => setTimeout(r, 10)); + return 42; + }, + }; + + const results = await runPipeline([phase], makeCtx()); + const result = results.get('slow')!; + expect(result.phaseName).toBe('slow'); + expect(result.output).toBe(42); + expect(result.durationMs).toBeGreaterThanOrEqual(0); + }); + + it('rejects duplicate phase names', async () => { + const phaseA: PipelinePhase = { + name: 'dup', + deps: [], + async execute() {}, + }; + const phaseB: PipelinePhase = { + name: 'dup', + deps: [], + async execute() {}, + }; + + await expect(runPipeline([phaseA, phaseB], makeCtx())).rejects.toThrow(/Duplicate phase name/); + }); + + it('rejects missing dependencies', async () => { + const phase: PipelinePhase = { + name: 'orphan', + deps: ['nonexistent'], + async execute() {}, + }; + + await expect(runPipeline([phase], makeCtx())).rejects.toThrow(/depends on 'nonexistent'/); + }); + + it('rejects cyclic dependencies', async () => { + const phaseA: PipelinePhase = { + name: 'x', + deps: ['y'], + async execute() {}, + }; + const phaseB: PipelinePhase = { + name: 'y', + deps: ['x'], + async execute() {}, + }; + + await expect(runPipeline([phaseA, phaseB], makeCtx())).rejects.toThrow(/Cycle detected/); + }); + + it('reports only cycle members (not transitive dependents) in cycle error', async () => { + // A <-> B is the actual cycle. C, D, E are downstream and would also have + // inDegree > 0 after Kahn's drains, but they are NOT cycle members. + const phases: PipelinePhase[] = [ + { name: 'a', deps: ['b'], async execute() {} }, + { name: 'b', deps: ['a'], async execute() {} }, + { name: 'c', deps: ['a'], async execute() {} }, + { name: 'd', deps: ['c'], async execute() {} }, + { name: 'e', deps: ['c'], async execute() {} }, + ]; + + let caught: Error | undefined; + try { + await runPipeline(phases, makeCtx()); + } catch (err) { + caught = err as Error; + } + + expect(caught).toBeDefined(); + const msg = caught!.message; + // Cycle path must include both A and B + expect(msg).toMatch(/Cycle detected in pipeline phases: /); + expect(msg).toMatch(/\ba\b/); + expect(msg).toMatch(/\bb\b/); + // Transitive dependents must NOT appear in the cycle path itself, + // they should be summarized in the parenthetical. + const pathSection = msg.split('(')[0]; + expect(pathSection).not.toMatch(/\bc\b/); + expect(pathSection).not.toMatch(/\bd\b/); + expect(pathSection).not.toMatch(/\be\b/); + expect(msg).toMatch(/3 transitive dependents blocked/); + }); + + it('reports the full path for a 3-phase cycle', async () => { + // A -> B -> C -> A + const phases: PipelinePhase[] = [ + { name: 'a', deps: ['c'], async execute() {} }, + { name: 'b', deps: ['a'], async execute() {} }, + { name: 'c', deps: ['b'], async execute() {} }, + ]; + + let caught: Error | undefined; + try { + await runPipeline(phases, makeCtx()); + } catch (err) { + caught = err as Error; + } + + expect(caught).toBeDefined(); + const msg = caught!.message; + expect(msg).toMatch(/Cycle detected in pipeline phases: /); + // All three names must appear + expect(msg).toMatch(/\ba\b/); + expect(msg).toMatch(/\bb\b/); + expect(msg).toMatch(/\bc\b/); + // Path uses the " -> " arrow separator + expect(msg).toMatch(/ -> /); + // No transitive-dependent suffix when every leftover IS a cycle member + expect(msg).not.toMatch(/transitive dependent/); + }); + + it("emits a terminal 'error' progress event on cycle detection", async () => { + const events: { phase: string; message: string; detail?: string }[] = []; + const ctx: PipelineContext = { + ...makeCtx(), + onProgress: (p) => { + events.push({ phase: p.phase, message: p.message, detail: p.detail }); + }, + }; + + const phases: PipelinePhase[] = [ + { name: 'x', deps: ['y'], async execute() {} }, + { name: 'y', deps: ['x'], async execute() {} }, + ]; + + await expect(runPipeline(phases, ctx)).rejects.toThrow(/Cycle detected/); + + const errorEvents = events.filter((e) => e.phase === 'error'); + expect(errorEvents).toHaveLength(1); + expect(errorEvents[0].detail).toMatch(/Cycle detected/); + }); + + it('executes a single root phase with no deps', async () => { + const phase: PipelinePhase = { + name: 'root', + deps: [], + async execute() { + return 'hello'; + }, + }; + + const results = await runPipeline([phase], makeCtx()); + expect(results.get('root')?.output).toBe('hello'); + }); + + it('handles a linear chain correctly', async () => { + const order: string[] = []; + + const phases: PipelinePhase[] = []; + for (let i = 0; i < 5; i++) { + const idx = i; + phases.push({ + name: `step${i}`, + deps: i > 0 ? [`step${i - 1}`] : [], + async execute(_ctx, deps) { + if (idx > 0) { + const prev = getPhaseOutput(deps, `step${idx - 1}`); + order.push(`step${idx}`); + return prev + 1; + } + order.push(`step${idx}`); + return 0; + }, + }); + } + + const results = await runPipeline(phases, makeCtx()); + expect(results.get('step4')?.output).toBe(4); + expect(order).toEqual(['step0', 'step1', 'step2', 'step3', 'step4']); + }); + + it('wraps phase Error with phase name and preserves cause', async () => { + const original = new Error('boom'); + const phase: PipelinePhase = { + name: 'failing', + deps: [], + async execute() { + throw original; + }, + }; + + await expect(runPipeline([phase], makeCtx())).rejects.toThrow(/Phase 'failing' failed: boom/); + + try { + await runPipeline([phase], makeCtx()); + throw new Error('expected runPipeline to reject'); + } catch (err) { + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toMatch(/Phase 'failing' failed: boom/); + expect((err as Error & { cause?: unknown }).cause).toBe(original); + } + }); + + it('surfaces phase name when phase throws a non-Error value', async () => { + const phaseString: PipelinePhase = { + name: 'string-thrower', + deps: [], + async execute() { + throw 'oops'; + }, + }; + + await expect(runPipeline([phaseString], makeCtx())).rejects.toThrow( + /Phase 'string-thrower' failed: oops/, + ); + + const phaseNumber: PipelinePhase = { + name: 'number-thrower', + deps: [], + async execute() { + throw 42; + }, + }; + + await expect(runPipeline([phaseNumber], makeCtx())).rejects.toThrow( + /Phase 'number-thrower' failed: 42/, + ); + }); + + it("emits a terminal 'error' progress event exactly once on phase failure", async () => { + const events: { phase: string; message: string; detail?: string }[] = []; + const ctx: PipelineContext = { + ...makeCtx(), + onProgress: (p) => { + events.push({ phase: p.phase, message: p.message, detail: p.detail }); + }, + }; + + const phase: PipelinePhase = { + name: 'failing', + deps: [], + async execute() { + throw new Error('kaboom'); + }, + }; + + await expect(runPipeline([phase], ctx)).rejects.toThrow(/Phase 'failing' failed/); + + const errorEvents = events.filter((e) => e.phase === 'error'); + expect(errorEvents).toHaveLength(1); + expect(errorEvents[0].message).toMatch(/failing/); + expect(errorEvents[0].detail).toBe('kaboom'); + }); + + it('still rejects when onProgress handler throws during error reporting', async () => { + const original = new Error('underlying'); + const ctx: PipelineContext = { + ...makeCtx(), + onProgress: () => { + throw new Error('handler exploded'); + }, + }; + + const phase: PipelinePhase = { + name: 'failing', + deps: [], + async execute() { + throw original; + }, + }; + + try { + await runPipeline([phase], ctx); + throw new Error('expected runPipeline to reject'); + } catch (err) { + // The original phase error must win, not the handler's error. + expect((err as Error).message).toMatch(/Phase 'failing' failed: underlying/); + expect((err as Error & { cause?: unknown }).cause).toBe(original); + } + }); + + it('only exposes declared deps to each phase', async () => { + const phaseA: PipelinePhase = { + name: 'a', + deps: [], + async execute() { + return 'resultA'; + }, + }; + + const phaseB: PipelinePhase = { + name: 'b', + deps: ['a'], + async execute() { + return 'resultB'; + }, + }; + + // C depends on B but not A — should not see A's result + const phaseC: PipelinePhase = { + name: 'c', + deps: ['b'], + async execute(_ctx, deps) { + expect(deps.has('b')).toBe(true); + expect(deps.has('a')).toBe(false); + return 'resultC'; + }, + }; + + await runPipeline([phaseA, phaseB, phaseC], makeCtx()); + }); +}); + +describe('getPhaseOutput', () => { + it('retrieves typed output from dependency map', () => { + const deps = new Map>(); + deps.set('test', { phaseName: 'test', output: { value: 42 }, durationMs: 0 }); + + const result = getPhaseOutput<{ value: number }>(deps, 'test'); + expect(result.value).toBe(42); + }); + + it('throws for missing phase', () => { + const deps = new Map>(); + + expect(() => getPhaseOutput(deps, 'missing')).toThrow(/Phase 'missing' not found/); + }); +}); diff --git a/gitnexus/test/unit/resolve-enclosing-owner.test.ts b/gitnexus/test/unit/resolve-enclosing-owner.test.ts new file mode 100644 index 000000000..b00da7d8c --- /dev/null +++ b/gitnexus/test/unit/resolve-enclosing-owner.test.ts @@ -0,0 +1,286 @@ +/** + * Regression tests for the provider-driven resolveEnclosingOwner hook. + * + * Verifies that: + * 1. findEnclosingClassInfo delegates to the resolveEnclosingOwner hook + * 2. Ruby's resolveEnclosingOwner correctly remaps singleton_class → class/module + * 3. The hook returns null to skip containers (keep walking up) + * 4. Without the hook, the generic behavior is preserved + */ +import { describe, it, expect } from 'vitest'; +import Parser from 'tree-sitter'; +import Ruby from 'tree-sitter-ruby'; +import { findEnclosingClassInfo } from '../../src/core/ingestion/utils/ast-helpers.js'; +import { rubyProvider } from '../../src/core/ingestion/languages/ruby.js'; + +let Kotlin: unknown; +try { + Kotlin = require('tree-sitter-kotlin'); +} catch { + // Kotlin grammar may not be installed +} + +const parser = new Parser(); + +const parseRuby = (code: string) => { + parser.setLanguage(Ruby); + return parser.parse(code); +}; + +const parseKotlin = (code: string) => { + parser.setLanguage(Kotlin as Parser.Language); + return parser.parse(code); +}; + +// --------------------------------------------------------------------------- +// Ruby resolveEnclosingOwner hook +// --------------------------------------------------------------------------- + +describe('Ruby resolveEnclosingOwner', () => { + it('remaps singleton_class to enclosing class for findEnclosingClassInfo', () => { + const tree = parseRuby(` +class Animal + class << self + def from_habitat(habitat) + end + end +end + `); + // Navigate to the method node inside singleton_class + const classNode = tree.rootNode.child(0)!; + const bodyStmt = classNode.namedChildren.find((c) => c.type === 'body_statement')!; + const singletonClass = bodyStmt.namedChildren.find((c) => c.type === 'singleton_class')!; + const innerBody = singletonClass.namedChildren.find((c) => c.type === 'body_statement')!; + const methodNode = innerBody.namedChildren.find((c) => c.type === 'method')!; + + const info = findEnclosingClassInfo( + methodNode, + 'animal.rb', + rubyProvider.resolveEnclosingOwner, + ); + + expect(info).not.toBeNull(); + expect(info!.className).toBe('Animal'); + expect(info!.classId).toContain('Animal'); + }); + + it('remaps singleton_class inside module to enclosing module', () => { + const tree = parseRuby(` +module Helpers + class << self + def greet + end + end +end + `); + const moduleNode = tree.rootNode.child(0)!; + const bodyStmt = moduleNode.namedChildren.find((c) => c.type === 'body_statement')!; + const singletonClass = bodyStmt.namedChildren.find((c) => c.type === 'singleton_class')!; + const innerBody = singletonClass.namedChildren.find((c) => c.type === 'body_statement')!; + const methodNode = innerBody.namedChildren.find((c) => c.type === 'method')!; + + const info = findEnclosingClassInfo( + methodNode, + 'helpers.rb', + rubyProvider.resolveEnclosingOwner, + ); + + expect(info).not.toBeNull(); + expect(info!.className).toBe('Helpers'); + expect(info!.classId).toContain('Module'); + }); + + it('returns null for file-level singleton_class without enclosing class', () => { + const tree = parseRuby(` +class << self + def orphan + end +end + `); + const singletonClass = tree.rootNode.child(0)!; + const innerBody = singletonClass.namedChildren.find((c) => c.type === 'body_statement')!; + const methodNode = innerBody.namedChildren.find((c) => c.type === 'method')!; + + const info = findEnclosingClassInfo( + methodNode, + 'orphan.rb', + rubyProvider.resolveEnclosingOwner, + ); + + // No enclosing class/module — should return null + expect(info).toBeNull(); + }); + + it('non-singleton containers pass through unchanged', () => { + const tree = parseRuby(` +class Dog + def bark + end +end + `); + const classNode = tree.rootNode.child(0)!; + const bodyStmt = classNode.namedChildren.find((c) => c.type === 'body_statement')!; + const methodNode = bodyStmt.namedChildren.find((c) => c.type === 'method')!; + + const info = findEnclosingClassInfo(methodNode, 'dog.rb', rubyProvider.resolveEnclosingOwner); + + expect(info).not.toBeNull(); + expect(info!.className).toBe('Dog'); + }); +}); + +// --------------------------------------------------------------------------- +// Kotlin: findEnclosingClassInfo without resolveEnclosingOwner +// --------------------------------------------------------------------------- + +describe('Kotlin enclosing owner resolution (no resolveEnclosingOwner needed)', () => { + (Kotlin ? it : it.skip)('companion_object methods resolve to the companion object name', () => { + const tree = parseKotlin(` + class UserService { + companion object Factory { + fun create(): UserService = UserService() + } + } + `); + // Navigate to the function_declaration inside companion object + const classNode = tree.rootNode.child(0)!; + const classBody = classNode.namedChild(1)!; + const companion = classBody.namedChild(0)!; + const companionBody = companion.namedChildren.find((c) => c.type === 'class_body')!; + const funcDecl = companionBody.namedChildren.find((c) => c.type === 'function_declaration')!; + + const info = findEnclosingClassInfo(funcDecl, 'service.kt'); + + expect(info).not.toBeNull(); + // companion_object is a valid CLASS_CONTAINER_TYPES — its name resolves via generic logic + expect(info!.className).toBe('Factory'); + }); + + (Kotlin ? it : it.skip)('object_declaration methods resolve to the object name', () => { + const tree = parseKotlin(` + object Singleton { + fun instance(): Singleton = Singleton() + } + `); + const objDecl = tree.rootNode.child(0)!; + const objBody = objDecl.namedChildren.find((c) => c.type === 'class_body')!; + const funcDecl = objBody.namedChildren.find((c) => c.type === 'function_declaration')!; + + const info = findEnclosingClassInfo(funcDecl, 'singleton.kt'); + + expect(info).not.toBeNull(); + expect(info!.className).toBe('Singleton'); + }); +}); + +// --------------------------------------------------------------------------- +// Future-proofing: invariants of the resolveEnclosingOwner hook contract +// --------------------------------------------------------------------------- +// +// A future provider implementer might: +// (a) Return a non-container node by mistake (e.g. a raw identifier). +// (b) Return `current` (identity), expecting "use this container as-is". +// +// Neither case must produce an infinite loop. These tests pin the contract. + +describe('findEnclosingClassInfo: hook contract guards', () => { + it('handles a hook that returns a non-container node without infinite-looping', () => { + // Ruby: class Outer { class Inner { def foo } } + // Hook will redirect from `Inner` (a CLASS_CONTAINER_TYPES node) to a + // raw `identifier`/`constant` node, which is NOT in CLASS_CONTAINER_TYPES. + const tree = parseRuby(` +class Outer + class Inner + def foo + end + end +end + `); + + const outerClass = tree.rootNode.child(0)!; + const outerBody = outerClass.namedChildren.find((c) => c.type === 'body_statement')!; + const innerClass = outerBody.namedChildren.find((c) => c.type === 'class')!; + const innerBody = innerClass.namedChildren.find((c) => c.type === 'body_statement')!; + const methodNode = innerBody.namedChildren.find((c) => c.type === 'method')!; + + let calls = 0; + const start = Date.now(); + // Resolve-hook returns a non-container child node (the class name identifier/constant). + // Per the documented contract, the walk must not infinite-loop: after the hook + // remaps, the next iteration sees a non-container node and continues walking + // up via `current = current.parent` at the end of the loop body. + const info = findEnclosingClassInfo(methodNode, 'nested.rb', (node) => { + calls += 1; + // Bail out if the contract is broken — fail fast rather than hang the suite. + if (calls > 50) throw new Error('hook called too many times — possible infinite loop'); + // Always redirect to the class's name node (a 'constant' in tree-sitter-ruby), + // which is NOT in CLASS_CONTAINER_TYPES. + const nameNode = node.childForFieldName?.('name'); + return nameNode ?? node; + }); + const elapsed = Date.now() - start; + + // Must complete quickly — no hang. + expect(elapsed).toBeLessThan(1000); + // Hook was exercised. + expect(calls).toBeGreaterThan(0); + // The function should return null (no resolvable container) rather than loop. + // It should NOT throw, and behavior is well-defined. + expect(info === null || (info && typeof info.className === 'string')).toBe(true); + }); + + it('handles a hook that returns the input node (identity) without infinite-looping', () => { + // Identity return is the documented "use this container as-is" branch. + // The existing `resolved === current` short-circuit must keep the algorithm + // moving forward (no re-evaluation), and the container is used directly. + const tree = parseRuby(` +class Wolf + def howl + end +end + `); + const classNode = tree.rootNode.child(0)!; + const bodyStmt = classNode.namedChildren.find((c) => c.type === 'body_statement')!; + const methodNode = bodyStmt.namedChildren.find((c) => c.type === 'method')!; + + let calls = 0; + const start = Date.now(); + const info = findEnclosingClassInfo(methodNode, 'wolf.rb', (node) => { + calls += 1; + if (calls > 50) throw new Error('hook called too many times — possible infinite loop'); + return node; // identity — equivalent to "no remap" + }); + const elapsed = Date.now() - start; + + expect(elapsed).toBeLessThan(1000); + expect(info).not.toBeNull(); + expect(info!.className).toBe('Wolf'); + // Hook should be called exactly once per CLASS_CONTAINER_TYPES node visited + // (here: just `class Wolf`). If the identity branch re-entered the hook, calls > 1. + expect(calls).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// Generic behavior: no hook → container used as-is +// --------------------------------------------------------------------------- + +describe('findEnclosingClassInfo without resolveEnclosingOwner', () => { + it('returns the first matching container without any remapping', () => { + const tree = parseRuby(` +class Dog + def bark + end +end + `); + const classNode = tree.rootNode.child(0)!; + const bodyStmt = classNode.namedChildren.find((c) => c.type === 'body_statement')!; + const methodNode = bodyStmt.namedChildren.find((c) => c.type === 'method')!; + + // Without the hook, generic behavior applies + const info = findEnclosingClassInfo(methodNode, 'dog.rb'); + + expect(info).not.toBeNull(); + expect(info!.className).toBe('Dog'); + }); +}); diff --git a/gitnexus/test/unit/symbol-resolver.test.ts b/gitnexus/test/unit/symbol-resolver.test.ts index dee9cdecc..75454f3cd 100644 --- a/gitnexus/test/unit/symbol-resolver.test.ts +++ b/gitnexus/test/unit/symbol-resolver.test.ts @@ -393,7 +393,7 @@ describe('heritage false-positive guard', () => { // These two describe blocks (`lookupExactFull` and `SM-16: SymbolTable.getFiles()`) // intentionally use `createSymbolTable()` directly instead of going through -// `createSemanticModel()`. The behaviors under test belong to the pure DAG +// `createSemanticModel()`. The behaviors under test belong to the pure // leaf — file/callable indexes, getFiles iterator — and do not involve the // owner-scoped registries. Testing them on the bare leaf keeps the unit // isolated. Do not migrate these blocks to createSemanticModel() "for @@ -863,7 +863,7 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup }); it('Rust: Impl is separate from Class-like types — does not affect heritage (lookupClassByName)', () => { - // SM-23 DAG: registry lookups go through SemanticModel; SymbolTable + // SM-23: registry lookups go through SemanticModel; SymbolTable // is a pure leaf with no registry knowledge. const model = createSemanticModel(); model.symbols.add('src/user.rs', 'User', 'Struct:src/user.rs:User', 'Struct'); diff --git a/gitnexus/test/unit/symbol-table.test.ts b/gitnexus/test/unit/symbol-table.test.ts index 1b8490fce..7bf0d7b4b 100644 --- a/gitnexus/test/unit/symbol-table.test.ts +++ b/gitnexus/test/unit/symbol-table.test.ts @@ -6,7 +6,7 @@ import { } from '../../src/core/ingestion/model/semantic-model.js'; describe('SymbolTable', () => { - // SM-23 DAG: SymbolTable is now a pure leaf with no registry knowledge. + // SM-23: SymbolTable is now a pure leaf with no registry knowledge. // Tests that exercise owner-scoped lookups (lookupClassByName, // lookupMethodByOwner, lookupFieldByOwner, lookupClassByQualifiedName, // lookupImplByName) must go through SemanticModel which composes diff --git a/gitnexus/test/unit/topological-sort.test.ts b/gitnexus/test/unit/topological-sort.test.ts index 03fba7e54..182f64957 100644 --- a/gitnexus/test/unit/topological-sort.test.ts +++ b/gitnexus/test/unit/topological-sort.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { topologicalLevelSort } from '../../src/core/ingestion/pipeline.js'; +import { topologicalLevelSort } from '../../src/core/ingestion/utils/graph-sort.js'; describe('topologicalLevelSort', () => { it('returns empty levels for empty graph', () => { @@ -132,6 +132,16 @@ describe('topologicalLevelSort', () => { expect(allFiles).toContain('c.ts'); }); + it('treats a self-edge (file imports itself) as a cycle', () => { + // A imports A — Kahn's never reduces A's pending-imports count below 1, + // so A is appended in the cycle group. Preserves prior semantics. + const importMap = new Map>([['a.ts', new Set(['a.ts'])]]); + const { levels, cycleCount } = topologicalLevelSort(importMap); + const allFiles = levels.flat(); + expect(allFiles).toContain('a.ts'); + expect(cycleCount).toBe(1); + }); + it('all files appear exactly once across all levels', () => { const importMap = new Map>([ ['a.ts', new Set()], diff --git a/gitnexus/test/unit/wildcard-synthesis.test.ts b/gitnexus/test/unit/wildcard-synthesis.test.ts new file mode 100644 index 000000000..b69af18de --- /dev/null +++ b/gitnexus/test/unit/wildcard-synthesis.test.ts @@ -0,0 +1,157 @@ +/** + * Coverage tests for wildcard-synthesis.ts. + * + * Scenarios aimed at branches that the integration tests only exercise on + * the happy path: + * 1. Go graph-IMPORTS fallback (importMap lacks the edge, graph has it). + * 2. Python buildPythonModuleAliasForFile populates moduleAliasMap. + * 3. MAX_SYNTHETIC_BINDINGS_PER_FILE cap halts further synthesis. + * 4. Deduplication against an already-present namedImportMap entry. + * 5. Empty exportedSymbolsByFile → early return, no work. + */ +import { describe, it, expect } from 'vitest'; +import { synthesizeWildcardImportBindings } from '../../src/core/ingestion/pipeline-phases/wildcard-synthesis.js'; +import { createResolutionContext } from '../../src/core/ingestion/model/resolution-context.js'; +import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; +import type { GraphNode, GraphRelationship } from '../../src/core/graph/types.js'; + +function makeExportedFuncNode( + id: string, + name: string, + filePath: string, + label: GraphNode['label'] = 'Function', +): GraphNode { + return { + id, + label, + properties: { + name, + filePath, + startLine: 1, + endLine: 5, + isExported: true, + }, + }; +} + +function makeImportsRel(srcFile: string, tgtFile: string): GraphRelationship { + return { + id: `File:${srcFile}-IMPORTS-File:${tgtFile}`, + sourceId: `File:${srcFile}`, + targetId: `File:${tgtFile}`, + type: 'IMPORTS', + confidence: 1.0, + reason: '', + }; +} + +describe('synthesizeWildcardImportBindings', () => { + it('uses graph-level IMPORTS fallback for Go when ctx.importMap lacks the edge', () => { + const graph = createKnowledgeGraph(); + const ctx = createResolutionContext(); + + // Exported Go symbol in the upstream file + graph.addNode(makeExportedFuncNode('Function:pkg/util.go:Helper', 'Helper', 'pkg/util.go')); + + // Only the graph edge exists — ctx.importMap has NO entry for main.go + graph.addRelationship(makeImportsRel('cmd/main.go', 'pkg/util.go')); + + const total = synthesizeWildcardImportBindings(graph, ctx); + + expect(total).toBe(1); + const mainBindings = ctx.namedImportMap.get('cmd/main.go'); + expect(mainBindings).toBeDefined(); + expect(mainBindings!.get('Helper')).toEqual({ + sourcePath: 'pkg/util.go', + exportedName: 'Helper', + }); + }); + + it('populates moduleAliasMap for Python namespace-import files', () => { + const graph = createKnowledgeGraph(); + const ctx = createResolutionContext(); + + // Need at least one exported symbol so exportedSymbolsByFile is non-empty + // (otherwise the function early-returns before reaching alias-map build). + graph.addNode(makeExportedFuncNode('Function:models.py:User', 'User', 'models.py', 'Class')); + + // Python importer — recorded in ctx.importMap (Python has namespace semantics) + ctx.importMap.set('app.py', new Set(['models.py', 'utils/helpers.py'])); + + synthesizeWildcardImportBindings(graph, ctx); + + const aliasMap = ctx.moduleAliasMap.get('app.py'); + expect(aliasMap).toBeDefined(); + // basename stem → full path + expect(aliasMap!.get('models')).toBe('models.py'); + expect(aliasMap!.get('helpers')).toBe('utils/helpers.py'); + }); + + it('caps synthesis at MAX_SYNTHETIC_BINDINGS_PER_FILE (1000) per file', () => { + const graph = createKnowledgeGraph(); + const ctx = createResolutionContext(); + + // Emit 1200 exported symbols in a single upstream Go file. + for (let i = 0; i < 1200; i++) { + graph.addNode(makeExportedFuncNode(`Function:pkg/big.go:Sym${i}`, `Sym${i}`, 'pkg/big.go')); + } + + // Go is wildcard — use ctx.importMap (C/C++/Ruby/Swift path also works, + // but Go via importMap exercises the same synthesizeForFile branch). + ctx.importMap.set('cmd/main.go', new Set(['pkg/big.go'])); + + const total = synthesizeWildcardImportBindings(graph, ctx); + + // Cap is 1000; totalSynthesized should equal the cap (not 1200). + expect(total).toBe(1000); + const bindings = ctx.namedImportMap.get('cmd/main.go'); + expect(bindings).toBeDefined(); + expect(bindings!.size).toBe(1000); + }); + + it('skips symbols already present in namedImportMap (dedup)', () => { + const graph = createKnowledgeGraph(); + const ctx = createResolutionContext(); + + graph.addNode(makeExportedFuncNode('Function:pkg/util.go:Helper', 'Helper', 'pkg/util.go')); + graph.addNode(makeExportedFuncNode('Function:pkg/util.go:Other', 'Other', 'pkg/util.go')); + + // Pre-seed a binding for "Helper" with a distinct sourcePath so we can + // detect that it was preserved rather than overwritten. + const preExisting = new Map(); + preExisting.set('Helper', { + sourcePath: 'other/source.go', + exportedName: 'Helper', + }); + ctx.namedImportMap.set('cmd/main.go', preExisting); + + ctx.importMap.set('cmd/main.go', new Set(['pkg/util.go'])); + + const total = synthesizeWildcardImportBindings(graph, ctx); + + // Only "Other" should have been synthesized; "Helper" was skipped. + expect(total).toBe(1); + const bindings = ctx.namedImportMap.get('cmd/main.go')!; + expect(bindings.get('Helper')!.sourcePath).toBe('other/source.go'); // untouched + expect(bindings.get('Other')).toEqual({ + sourcePath: 'pkg/util.go', + exportedName: 'Other', + }); + }); + + it('returns 0 early when exportedSymbolsByFile is empty (no exported symbols)', () => { + const graph = createKnowledgeGraph(); + const ctx = createResolutionContext(); + + // Even with wildcard-language imports declared, no exported symbols + // means nothing to synthesize — function must short-circuit. + ctx.importMap.set('cmd/main.go', new Set(['pkg/util.go'])); + graph.addRelationship(makeImportsRel('cmd/main.go', 'pkg/util.go')); + + const total = synthesizeWildcardImportBindings(graph, ctx); + + expect(total).toBe(0); + expect(ctx.namedImportMap.size).toBe(0); + expect(ctx.moduleAliasMap.size).toBe(0); + }); +}); From 3fbee2d3d2f56fc085fd9cb6624c041687e36378 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Mon, 13 Apr 2026 20:38:09 +0100 Subject: [PATCH 30/67] chore: release v1.6.1 (#815) --- gitnexus/CHANGELOG.md | 16 ++++++++++++++++ gitnexus/package-lock.json | 4 ++-- gitnexus/package.json | 2 +- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/gitnexus/CHANGELOG.md b/gitnexus/CHANGELOG.md index 26126a9e7..80096345d 100644 --- a/gitnexus/CHANGELOG.md +++ b/gitnexus/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to GitNexus will be documented in this file. +## [1.6.1] - 2026-04-13 + +### Added +- **Service group extractor expansion** — manifest extractor and broader extractor coverage (2/4 of #606 split) (#796) +- **Dart call patterns** for `await`, cascade, lambda, and widget-tree contexts (#801) + +### Fixed +- **Stack overflow and memory exhaustion** on large repository analysis (#814) +- **`tree-sitter-dart` install crash** — switched from git URL to npm tarball (#811) +- **Generic TypeScript awaited function calls** missing from the call graph (#804) +- **Runtime dependency on `file:../gitnexus-shared`** removed from the published package (#803) +- **Ruby `singleton_class` context** preserved during sequential parsing (#774) + +### Changed +- **DAG-based ingestion pipeline architecture** — pipeline phases now declare typed dependencies and run via a topologically sorted DAG; container-node logic extracted to `LanguageProvider`. Includes hardened lifecycle (try/finally cleanup, error wrapping, cycle reporting), tightened `ParseOutput.exportedTypeMap` immutability, and corrected phase dependencies (#809) + ## [1.6.0] - 2026-04-12 ### Added diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index abf0945d4..c9ca9017f 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitnexus", - "version": "1.6.0", + "version": "1.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitnexus", - "version": "1.6.0", + "version": "1.6.1", "hasInstallScript": true, "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { diff --git a/gitnexus/package.json b/gitnexus/package.json index 30fd90578..d0d0704f5 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -1,6 +1,6 @@ { "name": "gitnexus", - "version": "1.6.0", + "version": "1.6.1", "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.", "author": "Abhigyan Patwari", "license": "PolyForm-Noncommercial-1.0.0", From 759c983dce35b67bca75d1aac4fadf5a85b8181b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Tue, 14 Apr 2026 08:03:02 +0100 Subject: [PATCH 31/67] fix(extractors): resolve 3 silent contract mis-resolution bugs (#793) (#817) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(extractors): resolve 3 silent contract mis-resolution bugs (#793) Addresses Codex adversarial review findings for extractor contract resolution on the new group extractor surface. F1 (manifest-extractor): resolveSymbol passed the full "METHOD::path" contract string through normalizeRoutePath, producing "/GET::/api/orders" which never matches Route.name. Adds parseHttpContract() helper that strips the METHOD:: prefix before path normalization. Contract ID construction (buildContractId) is unchanged. F2 (http-route-extractor): graph-assisted backfill used path-only detections.find(), so multi-verb same-URL files attached the wrong verb/handler to provider rows and inferred the wrong verb on FETCHES consumer edges. Now requires path+method match when method is known, and skips backfill when method is unknown and multiple detections tie on path. F3 (grpc-extractor): resolveProtoConflict seeded bestScore=-1 and only replaced on strict >, so all-zero-score ties silently selected candidates[0]. Now computes all scores, counts ties at the top score, and returns null on ambiguity (caller skips contract emission and warns with service name + candidate paths). All three fixes are test-first; 73 tests pass across the three suites. No schema changes, no new dependencies, contract ID wire format (http::METHOD::path, grpc::pkg.Service/Method, http::*::path) preserved. * fix(extractors): address PR #817 review — ambiguous symbol pick + contract id casing Copilot + Claude review on PR #817 flagged two follow-up bugs on top of the F1/F2/F3 fixes: 1. http-route-extractor: ambiguous multi-verb case left handlerName null but still ran the CONTAINS DB query. pickSymbolUid(syms, null) then silently picked pool[0] — reintroducing handler mis-attribution via a different route than the .find() bug F2 fixed. Now gates symbol enrichment on an ambiguousCandidates flag so the file-basename fallback wins instead. 2. manifest-extractor: buildContractId passed raw user casing through for the explicit-method form, so get::/api/orders and GET::/api/orders produced different contract ids even though parseHttpContract upper-cases during lookup. Now reuses parseHttpContract + normalizeRoutePath to canonicalize both method and path, so logically equivalent manifest inputs share a contract id (and share a manifestSymbolUid fallback). Adds one regression test per bug: lowercase vs uppercase manifest contract ids must match, and ambiguous multi-verb with CONTAINS rows must not silently attach a real handler or call the CONTAINS query at all. 75 tests pass across the three extractor suites. * chore: prettier formatting --- gitnexus-web/src/hooks/useAutoScroll.ts | 2 +- .../test/unit/use-auto-scroll.test.tsx | 4 +- .../core/group/extractors/grpc-extractor.ts | 45 +- .../group/extractors/http-route-extractor.ts | 40 +- .../group/extractors/manifest-extractor.ts | 49 ++- .../test/unit/group/grpc-extractor.test.ts | 116 +++++- .../unit/group/http-route-multi-verb.test.ts | 393 ++++++++++++++++++ .../unit/group/manifest-extractor.test.ts | 278 +++++++++++++ 8 files changed, 901 insertions(+), 26 deletions(-) create mode 100644 gitnexus/test/unit/group/http-route-multi-verb.test.ts diff --git a/gitnexus-web/src/hooks/useAutoScroll.ts b/gitnexus-web/src/hooks/useAutoScroll.ts index 58a0aedcc..55c2946f7 100644 --- a/gitnexus-web/src/hooks/useAutoScroll.ts +++ b/gitnexus-web/src/hooks/useAutoScroll.ts @@ -142,4 +142,4 @@ export function useAutoScroll( isAtBottom, scrollToBottom, }; -} \ No newline at end of file +} diff --git a/gitnexus-web/test/unit/use-auto-scroll.test.tsx b/gitnexus-web/test/unit/use-auto-scroll.test.tsx index a8bc1a795..e58227d67 100644 --- a/gitnexus-web/test/unit/use-auto-scroll.test.tsx +++ b/gitnexus-web/test/unit/use-auto-scroll.test.tsx @@ -267,9 +267,7 @@ describe('useAutoScroll', () => { }); it('attaches the observer when the messages wrapper first appears and disconnects on unmount', () => { - const { rerender, unmount } = render( - , - ); + const { rerender, unmount } = render(); expect(screen.queryByTestId('messages-container')).toBeNull(); expect(resizeObserverInstances).toHaveLength(0); diff --git a/gitnexus/src/core/group/extractors/grpc-extractor.ts b/gitnexus/src/core/group/extractors/grpc-extractor.ts index c6af9138a..b379a4dbd 100644 --- a/gitnexus/src/core/group/extractors/grpc-extractor.ts +++ b/gitnexus/src/core/group/extractors/grpc-extractor.ts @@ -314,7 +314,7 @@ export async function buildProtoMap(repoPath: string): Promise { const protoDir = normalizeProtoPath(path.dirname(c.protoPath)); - const sharedRun = longestSharedSegmentRun(sourceDir, protoDir); - if (sharedRun > bestScore) { - bestScore = sharedRun; - best = c; - } + return { candidate: c, score: longestSharedSegmentRun(sourceDir, protoDir) }; + }); + + let maxScore = -1; + for (const s of scored) { + if (s.score > maxScore) maxScore = s.score; } - return best; + const winners = scored.filter((s) => s.score === maxScore); + + // Path heuristic cannot uniquely identify a winner — refuse to guess. + // Ties (including all-zero ties) would otherwise silently merge unrelated + // services under a fabricated package-qualified contract id. + if (winners.length !== 1) { + const paths = candidates.map((c) => c.protoPath).join(', '); + console.warn( + `[grpc-extractor] Ambiguous proto resolution for service "${serviceName}" from ${sourceFilePath}: ${winners.length} candidates tied at score ${maxScore} among [${paths}] — skipping canonical contract`, + ); + return null; + } + + return winners[0].candidate; } export function serviceContractId(pkg: string, serviceName: string): string { @@ -410,7 +422,8 @@ export class GrpcExtractor implements ContractExtractor { continue; } for (const d of detections) { - out.push(this.detectionToContract(d, rel, protoMap)); + const contract = this.detectionToContract(d, rel, protoMap); + if (contract) out.push(contract); } } @@ -428,9 +441,13 @@ export class GrpcExtractor implements ContractExtractor { d: GrpcDetection, filePath: string, protoMap: Map, - ): ExtractedContract { - const candidates = protoMap.get(d.serviceName); - const proto = resolveProtoConflict(d.serviceName, filePath, candidates ?? []); + ): ExtractedContract | null { + const candidates = protoMap.get(d.serviceName) ?? []; + const proto = resolveProtoConflict(d.serviceName, filePath, candidates); + // If there were proto candidates but resolution was ambiguous, skip + // contract emission rather than fabricating a package-qualified id from + // an arbitrary candidate. resolveProtoConflict already warned. + if (candidates.length > 0 && proto === null) return null; const pkg = proto?.package ?? ''; const cid = d.methodName ? contractId(pkg, d.serviceName, d.methodName) diff --git a/gitnexus/src/core/group/extractors/http-route-extractor.ts b/gitnexus/src/core/group/extractors/http-route-extractor.ts index 0b07090e1..f2914613d 100644 --- a/gitnexus/src/core/group/extractors/http-route-extractor.ts +++ b/gitnexus/src/core/group/extractors/http-route-extractor.ts @@ -245,7 +245,30 @@ export class HttpRouteExtractor implements ContractExtractor { const providerDetections = detections.filter((d) => d.role === 'provider'); let handlerName: string | null = null; const normalizedRoute = normalizeHttpPath(routePath); - const match = providerDetections.find((d) => normalizeHttpPath(d.path) === normalizedRoute); + // Candidates share the same normalized path. When multiple + // detections at the same path exist (e.g. GET + POST /api/orders + // in one router), a blind `.find()` silently returned the first + // verb — attaching the wrong handler and, when method was not + // already pinned by the route reason, the wrong method too. + // Disambiguate by method when we know it; refuse to guess when + // we don't. + const candidates = providerDetections.filter( + (d) => normalizeHttpPath(d.path) === normalizedRoute, + ); + let match: (typeof candidates)[number] | undefined; + const ambiguousCandidates = !method && candidates.length > 1; + if (method) { + match = candidates.find((d) => d.method === method); + } else if (candidates.length === 1) { + match = candidates[0]; + } + // else: multiple candidates + unknown method → leave match + // undefined so handlerName stays null and skip symbol + // enrichment below, keeping the file-basename fallback instead + // of letting pickSymbolUid silently pick the first Function / + // Method in the file (which reintroduces the mis-attribution + // we were trying to avoid). Method stays at the conservative + // 'GET' default set below. if (match) { if (!method) method = match.method; handlerName = match.name; @@ -259,7 +282,7 @@ export class HttpRouteExtractor implements ContractExtractor { let symbolName = path.basename(filePath) || 'handler'; let symPath = filePath; const fileId = row.fileId ?? row[0]; - if (fileId) { + if (fileId && !ambiguousCandidates) { try { const syms = await db(CONTAINS_QUERY, { fileId }); if (syms.length > 0) { @@ -347,10 +370,19 @@ export class HttpRouteExtractor implements ContractExtractor { // Prefer the plugin's detected method if we can find a matching // fetch/axios call in the same file. const detections = filePath ? getDetections(filePath) : []; - const inferred = detections.find( + // Symmetric to the provider path: if multiple consumer calls in + // the same file share the same normalized path (e.g. a GET + // fetch AND a POST fetch to `/api/orders`), `.find()` silently + // picked the first verb and keyed the contract id on the wrong + // method. With no upstream method signal here, refuse to guess + // when candidates are ambiguous — leave `method` at its + // conservative 'GET' default. + const consumerCandidates = detections.filter( (d) => d.role === 'consumer' && normalizeConsumerPath(d.path) === pathNorm, ); - if (inferred) method = inferred.method; + if (consumerCandidates.length === 1) { + method = consumerCandidates[0].method; + } const cid = contractIdFor(method, pathNorm); let symbolUid = ''; diff --git a/gitnexus/src/core/group/extractors/manifest-extractor.ts b/gitnexus/src/core/group/extractors/manifest-extractor.ts index 29c8f9b21..4c0d737b7 100644 --- a/gitnexus/src/core/group/extractors/manifest-extractor.ts +++ b/gitnexus/src/core/group/extractors/manifest-extractor.ts @@ -23,6 +23,34 @@ function normalizeRoutePath(raw: string): string { return collapsed.replace(/\/+$/, ''); } +/** + * Split a manifest HTTP contract into its optional `METHOD::` prefix and + * its path portion. + * + * `buildContractId` recommends the explicit-method form `GET::/api/orders` + * in group.yaml; if we hand that raw string to `normalizeRoutePath` we get + * `/GET::/api/orders`, which can never match `Route.name = "/api/orders"` + * in the graph. This helper extracts the path portion so the Cypher + * lookup uses the canonical route name. + * + * The method prefix regex mirrors `buildContractId` (line ~251) for + * symmetry: case-insensitive `[A-Za-z]+` followed by `::`. The captured + * method is upper-cased for downstream use; method-constrained matching + * against `HANDLES_ROUTE` is a future enhancement (not yet wired). + * + * Edge cases: + * - `"::/api/orders"` — empty method portion, no alpha prefix match, so + * the whole string is treated as a bare path (matches buildContractId + * which also requires `[A-Za-z]+`). + * - `"GET::"` — method with empty path, returns `{ method: 'GET', path: '' }`; + * `normalizeRoutePath('')` resolves to `/` for caller. + */ +function parseHttpContract(raw: string): { method: string | null; path: string } { + const match = raw.match(/^([A-Za-z]+)::/); + if (!match) return { method: null, path: raw }; + return { method: match[1].toUpperCase(), path: raw.slice(match[0].length) }; +} + /** * Stable synthetic symbolUid for a manifest-declared contract whose target * symbol could not be resolved against the per-repo graph (resolveSymbol @@ -134,7 +162,15 @@ export class ManifestExtractor { // core/ingestion/pipeline.ts ensureSlash + generateId('Route', ...)). // Normalize the manifest contract the same way so a user-written // "/api/orders" matches "api/orders" in the graph. - const normalized = normalizeRoutePath(link.contract); + // + // The contract may also use the explicit-method form "GET::/api/orders" + // recommended by buildContractId. Strip the METHOD:: prefix before + // normalizing — otherwise `normalizeRoutePath('GET::/api/orders')` + // returns `/GET::/api/orders` and never matches Route.name. The + // captured method is not yet used to constrain the Cypher query + // (method-aware HANDLES_ROUTE matching is a future enhancement). + const parsed = parseHttpContract(link.contract); + const normalized = normalizeRoutePath(parsed.path); rows = await executor( `MATCH (handler)-[r:CodeRelation {type: 'HANDLES_ROUTE'}]->(route:Route) WHERE route.name = $normalized @@ -248,8 +284,15 @@ export class ManifestExtractor { private buildContractId(type: ContractType, contract: string): string { switch (type) { case 'http': { - if (/^[A-Za-z]+::/.test(contract)) return `http::${contract}`; - return `http::*::${contract}`; + // Canonicalize method casing and path separators so logically + // equivalent inputs (`get::/api/orders` vs `GET::/api/orders`, + // or trailing-slash variants) produce the same contractId and + // matching `manifestSymbolUid` fallback. Without this, raw + // user casing leaks into cross-impact join keys and fragments + // matches across repos. + const { method, path: rawPath } = parseHttpContract(contract); + const normalizedPath = normalizeRoutePath(rawPath); + return method ? `http::${method}::${normalizedPath}` : `http::*::${normalizedPath}`; } case 'grpc': return `grpc::${contract}`; diff --git a/gitnexus/test/unit/group/grpc-extractor.test.ts b/gitnexus/test/unit/group/grpc-extractor.test.ts index 82d79cbd6..1664bd1a7 100644 --- a/gitnexus/test/unit/group/grpc-extractor.test.ts +++ b/gitnexus/test/unit/group/grpc-extractor.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'node:fs'; import fsp from 'node:fs/promises'; import * as path from 'node:path'; @@ -732,6 +732,120 @@ describe('resolveProtoConflict', () => { it('test_no_candidates_returns_null', () => { expect(resolveProtoConflict('Svc', 'src/main.go', [])).toBeNull(); }); + + it('test_all_zero_tie_returns_null', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const candidates = [ + makeInfo('pkgA', 'totally/unrelated/a/svc.proto'), + makeInfo('pkgB', 'completely/different/b/svc.proto'), + ]; + const result = resolveProtoConflict('Svc', 'src/main.go', candidates); + expect(result).toBeNull(); + warnSpy.mockRestore(); + }); + + it('test_positive_score_tie_returns_null', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + // Both candidates share `src/proto` with the source dir — equal shared runs. + const candidates = [ + makeInfo('pkgA', 'src/proto/a/svc.proto'), + makeInfo('pkgB', 'src/proto/b/svc.proto'), + ]; + const result = resolveProtoConflict('Svc', 'src/proto/main.go', candidates); + expect(result).toBeNull(); + warnSpy.mockRestore(); + }); + + it('test_three_way_zero_tie_returns_null', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const candidates = [ + makeInfo('pkgA', 'aaa/svc.proto'), + makeInfo('pkgB', 'bbb/svc.proto'), + makeInfo('pkgC', 'ccc/svc.proto'), + ]; + const result = resolveProtoConflict('Svc', 'src/main.go', candidates); + expect(result).toBeNull(); + warnSpy.mockRestore(); + }); + + it('test_unique_winner_among_ties', () => { + // Winner with shared run 2 (services/auth), two losers with score 0. + const candidates = [ + makeInfo('winner', 'services/auth/proto/svc.proto'), + makeInfo('loserA', 'totally/unrelated/a/svc.proto'), + makeInfo('loserB', 'elsewhere/b/svc.proto'), + ]; + const result = resolveProtoConflict('Svc', 'services/auth/src/server.ts', candidates); + expect(result?.package).toBe('winner'); + }); + + it('test_ambiguous_emits_single_warn_with_service_and_paths', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const candidates = [ + makeInfo('pkgA', 'totally/unrelated/a/svc.proto'), + makeInfo('pkgB', 'completely/different/b/svc.proto'), + ]; + resolveProtoConflict('MyService', 'src/main.go', candidates); + expect(warnSpy).toHaveBeenCalledTimes(1); + const msg = String(warnSpy.mock.calls[0][0]); + expect(msg).toContain('MyService'); + expect(msg).toContain('src/main.go'); + expect(msg).toContain('totally/unrelated/a/svc.proto'); + expect(msg).toContain('completely/different/b/svc.proto'); + warnSpy.mockRestore(); + }); +}); + +describe('GrpcExtractor.extract ambiguous proto resolution', () => { + let tmpDir: string; + let extractor: GrpcExtractor; + + beforeEach(async () => { + tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'gitnexus-grpc-ambig-')); + extractor = new GrpcExtractor(); + }); + afterEach(async () => { + await fsp.rm(tmpDir, { recursive: true, force: true }); + }); + + const makeRepo = (repoPath: string): RepoHandle => ({ + id: 'test-repo', + path: '', + repoPath, + storagePath: '', + }); + + it('test_ambiguous_short_name_across_unrelated_protos_yields_no_source_contract', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + // Two unrelated proto files defining the same short name `UserService` in + // unrelated directories, neither sharing path segments with the Go source. + await fsp.mkdir(path.join(tmpDir, 'billing-team', 'proto'), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, 'billing-team', 'proto', 'user.proto'), + 'package billing.v1;\nservice UserService { rpc GetUser (R) returns (R); }', + ); + await fsp.mkdir(path.join(tmpDir, 'auth-team', 'proto'), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, 'auth-team', 'proto', 'user.proto'), + 'package auth.v1;\nservice UserService { rpc GetUser (R) returns (R); }', + ); + // Consumer in an unrelated directory. + await fsp.mkdir(path.join(tmpDir, 'apps', 'gateway'), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, 'apps', 'gateway', 'client.go'), + 'package main\nfunc init() { client := pb.NewUserServiceClient(conn) }', + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + + // No source-attributed contract for UserService should be emitted. + const sourceContracts = contracts.filter( + (c) => c.meta.source === 'go_client' && c.meta.service === 'UserService', + ); + expect(sourceContracts).toHaveLength(0); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); }); describe('serviceContractId', () => { diff --git a/gitnexus/test/unit/group/http-route-multi-verb.test.ts b/gitnexus/test/unit/group/http-route-multi-verb.test.ts new file mode 100644 index 000000000..b2884fc60 --- /dev/null +++ b/gitnexus/test/unit/group/http-route-multi-verb.test.ts @@ -0,0 +1,393 @@ +/** + * Coverage tests for `HttpRouteExtractor` graph-assisted paths — + * specifically the multi-verb same-path regression (Codex finding F2). + * + * The bug: `extractProvidersGraph` / `extractConsumersGraph` used + * `detections.find(d => normalizeHttpPath(d.path) === routePath)` to + * backfill handler name and (for providers) method. On a file with + * multiple verbs at the same normalized path (e.g. `GET /api/orders` + * and `POST /api/orders` in one router), `.find()` returned the first + * match, silently attaching the wrong handler and/or method. + * + * Strategy: mock `./http-patterns/index.js` + `./fs-utils.js` so we + * can inject a synthetic `HttpDetection[]` per file without needing + * real tree-sitter grammars. The `db` executor is a vi.fn() that + * returns stubbed rows for the HANDLES_ROUTE / FETCHES / CONTAINS + * queries. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type Parser from 'tree-sitter'; +import type { HttpDetection } from '../../../src/core/group/extractors/http-patterns/types.js'; + +// Per-file detections injected into the mocked plugin. +const FILE_DETECTIONS = new Map(); + +vi.mock('../../../src/core/group/extractors/fs-utils.js', () => ({ + readSafe: (_repo: string, _rel: string) => 'stub content', +})); + +vi.mock('../../../src/core/group/extractors/http-patterns/index.js', () => { + return { + HTTP_SCAN_GLOB: '**/*.fake', + getPluginForFile: (rel: string) => ({ + name: 'fake', + language: {}, + scan: (_tree: Parser.Tree) => FILE_DETECTIONS.get(rel) ?? [], + }), + }; +}); + +// Patch tree-sitter Parser so `.setLanguage()` + `.parse()` don't +// require a real grammar — the mocked plugin's scan() ignores the +// tree anyway. +vi.mock('tree-sitter', () => { + class FakeParser { + setLanguage(_lang: unknown) {} + parse(_src: string) { + return {} as Parser.Tree; + } + } + return { default: FakeParser }; +}); + +import { HttpRouteExtractor } from '../../../src/core/group/extractors/http-route-extractor.js'; + +function detection( + role: 'provider' | 'consumer', + method: string, + p: string, + name: string | null, +): HttpDetection { + return { role, framework: 'test', method, path: p, name, confidence: 0.8 }; +} + +describe('HttpRouteExtractor — graph-assisted multi-verb disambiguation', () => { + beforeEach(() => { + FILE_DETECTIONS.clear(); + }); + + // Helper to build a CONTAINS response covering all handler names in a file. + const containsFor = (names: string[]) => + names.map((name, i) => ({ + uid: `uid-${name}`, + name, + filePath: 'routes.ts', + labels: ['Function'], + 0: `uid-${name}`, + 1: name, + 2: 'routes.ts', + 3: ['Function'], + })); + + // ── Provider: happy path (single match) ──────────────────────────── + it('provider: single detection backfills handler name as today', async () => { + FILE_DETECTIONS.set('routes.ts', [detection('provider', 'GET', '/api/orders', 'listOrders')]); + + const db = vi.fn(async (query: string) => { + if (query.includes('HANDLES_ROUTE')) { + return [ + { + fileId: 'f1', + filePath: 'routes.ts', + routePath: '/api/orders', + routeId: 'r1', + responseKeys: [], + routeSource: 'decorator-Get', + }, + ]; + } + if (query.includes('CONTAINS')) return containsFor(['listOrders']); + return []; + }); + + const ex = new HttpRouteExtractor(); + const out = await ex.extract(db, '/repo', { name: 'r', url: 'r' } as never); + expect(out).toHaveLength(1); + expect(out[0].symbolName).toBe('listOrders'); + expect(out[0].meta.method).toBe('GET'); + }); + + // ── Provider: multi-verb, method KNOWN (POST) ────────────────────── + it('provider: multi-verb with method known picks the matching verb (POST)', async () => { + FILE_DETECTIONS.set('routes.ts', [ + detection('provider', 'GET', '/api/orders', 'listOrders'), + detection('provider', 'POST', '/api/orders', 'createOrder'), + ]); + + const db = vi.fn(async (query: string) => { + if (query.includes('HANDLES_ROUTE')) { + return [ + { + fileId: 'f1', + filePath: 'routes.ts', + routePath: '/api/orders', + routeSource: 'decorator-Post', + }, + ]; + } + if (query.includes('CONTAINS')) return containsFor(['listOrders', 'createOrder']); + return []; + }); + + const out = await new HttpRouteExtractor().extract(db, '/repo', { + name: 'r', + url: 'r', + } as never); + expect(out).toHaveLength(1); + expect(out[0].symbolName).toBe('createOrder'); + expect(out[0].meta.method).toBe('POST'); + }); + + it('provider: multi-verb with method known picks the matching verb (GET)', async () => { + FILE_DETECTIONS.set('routes.ts', [ + detection('provider', 'GET', '/api/orders', 'listOrders'), + detection('provider', 'POST', '/api/orders', 'createOrder'), + ]); + + const db = vi.fn(async (query: string) => { + if (query.includes('HANDLES_ROUTE')) { + return [ + { + fileId: 'f1', + filePath: 'routes.ts', + routePath: '/api/orders', + routeSource: 'decorator-Get', + }, + ]; + } + if (query.includes('CONTAINS')) return containsFor(['listOrders', 'createOrder']); + return []; + }); + + const out = await new HttpRouteExtractor().extract(db, '/repo', { + name: 'r', + url: 'r', + } as never); + expect(out).toHaveLength(1); + expect(out[0].symbolName).toBe('listOrders'); + expect(out[0].meta.method).toBe('GET'); + }); + + // ── Provider: multi-verb, method UNKNOWN → refuse to guess ───────── + it('provider: multi-verb with method unknown skips backfill (no silent inheritance)', async () => { + FILE_DETECTIONS.set('routes.ts', [ + detection('provider', 'GET', '/api/orders', 'listOrders'), + detection('provider', 'POST', '/api/orders', 'createOrder'), + ]); + + const db = vi.fn(async (query: string) => { + if (query.includes('HANDLES_ROUTE')) { + return [ + { + fileId: 'f1', + filePath: 'routes.ts', + routePath: '/api/orders', + routeSource: 'unknown-reason', // methodFromRouteReason → null + }, + ]; + } + return []; + }); + + const out = await new HttpRouteExtractor().extract(db, '/repo', { + name: 'r', + url: 'r', + } as never); + expect(out).toHaveLength(1); + // CRITICAL: must NOT silently inherit POST from createOrder via .find() + expect(out[0].meta.method).toBe('GET'); // conservative default + // CRITICAL: must NOT silently attach createOrder as handler + expect(out[0].symbolName).not.toBe('createOrder'); + // With no CONTAINS rows, handlerName stays null and file-basename fallback wins. + expect(out[0].symbolName).toBe('routes.ts'); + }); + + // ── Provider: multi-verb + CONTAINS rows → must still refuse to guess ── + it('provider: ambiguous multi-verb skips CONTAINS enrichment (no silent pool[0] pick)', async () => { + // Regression test for Copilot's review on PR #817. Before the fix, + // the ambiguous-case code path left `handlerName` null but still ran + // the CONTAINS DB query, and `pickSymbolUid(syms, null)` silently + // picked pool[0] — reintroducing handler mis-attribution via a + // different route than `.find()`. + FILE_DETECTIONS.set('routes.ts', [ + detection('provider', 'GET', '/api/orders', 'listOrders'), + detection('provider', 'POST', '/api/orders', 'createOrder'), + ]); + + const db = vi.fn(async (query: string) => { + if (query.includes('HANDLES_ROUTE')) { + return [ + { + fileId: 'f1', + filePath: 'routes.ts', + routePath: '/api/orders', + routeSource: 'unknown-reason', + }, + ]; + } + if (query.includes('CONTAINS')) return containsFor(['listOrders', 'createOrder']); + return []; + }); + + const out = await new HttpRouteExtractor().extract(db, '/repo', { + name: 'r', + url: 'r', + } as never); + expect(out).toHaveLength(1); + // Ambiguous → do not attribute to any real handler in the file. + expect(out[0].symbolName).not.toBe('listOrders'); + expect(out[0].symbolName).not.toBe('createOrder'); + expect(out[0].symbolUid).toBe(''); + expect(out[0].symbolName).toBe('routes.ts'); + expect(out[0].meta.method).toBe('GET'); + // CONTAINS query must have been skipped entirely under ambiguity. + const calls = db.mock.calls.map(([q]) => q as string); + expect(calls.some((q) => q.includes('CONTAINS'))).toBe(false); + }); + + // ── Provider: three-verb method known ────────────────────────────── + it('provider: three verbs at same path with method known still matches correctly', async () => { + FILE_DETECTIONS.set('routes.ts', [ + detection('provider', 'GET', '/api/orders', 'listOrders'), + detection('provider', 'POST', '/api/orders', 'createOrder'), + detection('provider', 'PUT', '/api/orders', 'replaceOrder'), + ]); + + const db = vi.fn(async (query: string) => { + if (query.includes('HANDLES_ROUTE')) { + return [ + { + fileId: 'f1', + filePath: 'routes.ts', + routePath: '/api/orders', + routeSource: 'decorator-Put', + }, + ]; + } + if (query.includes('CONTAINS')) + return containsFor(['listOrders', 'createOrder', 'replaceOrder']); + return []; + }); + + const out = await new HttpRouteExtractor().extract(db, '/repo', { + name: 'r', + url: 'r', + } as never); + expect(out[0].symbolName).toBe('replaceOrder'); + expect(out[0].meta.method).toBe('PUT'); + }); + + // ── Provider: unrelated path detections don't false-positive ─────── + it('provider: detection for unrelated path does not backfill', async () => { + FILE_DETECTIONS.set('routes.ts', [detection('provider', 'POST', '/api/users', 'createUser')]); + + const db = vi.fn(async (query: string) => { + if (query.includes('HANDLES_ROUTE')) { + return [ + { + fileId: 'f1', + filePath: 'routes.ts', + routePath: '/api/orders', + routeSource: 'unknown', + }, + ]; + } + return []; + }); + + const out = await new HttpRouteExtractor().extract(db, '/repo', { + name: 'r', + url: 'r', + } as never); + expect(out[0].meta.method).toBe('GET'); + expect(out[0].symbolName).not.toBe('createUser'); + }); + + // ── Integration: one row, two detections, one out.push ───────────── + it('integration: one db row with two same-path detections yields exactly one contract', async () => { + FILE_DETECTIONS.set('routes.ts', [ + detection('provider', 'GET', '/api/orders', 'listOrders'), + detection('provider', 'POST', '/api/orders', 'createOrder'), + ]); + + const db = vi.fn(async (query: string) => { + if (query.includes('HANDLES_ROUTE')) { + return [ + { + fileId: 'f1', + filePath: 'routes.ts', + routePath: '/api/orders', + routeSource: 'decorator-Post', + }, + ]; + } + if (query.includes('CONTAINS')) return containsFor(['listOrders', 'createOrder']); + return []; + }); + + const out = await new HttpRouteExtractor().extract(db, '/repo', { + name: 'r', + url: 'r', + } as never); + expect(out).toHaveLength(1); + expect(out[0].meta.method).toBe('POST'); + expect(out[0].symbolName).toBe('createOrder'); + }); + + // ── Consumer: single match ───────────────────────────────────────── + it('consumer: single detection backfills method as today', async () => { + FILE_DETECTIONS.set('client.ts', [detection('consumer', 'POST', '/api/orders', null)]); + + const db = vi.fn(async (query: string) => { + if (query.includes('FETCHES')) { + return [ + { + fileId: 'f1', + filePath: 'client.ts', + routePath: '/api/orders', + fetchReason: 'fetch', + }, + ]; + } + return []; + }); + + const out = await new HttpRouteExtractor().extract(db, '/repo', { + name: 'r', + url: 'r', + } as never); + expect(out).toHaveLength(1); + expect(out[0].meta.method).toBe('POST'); + }); + + // ── Consumer: multi-verb skips backfill ──────────────────────────── + it('consumer: multi-verb at same path skips backfill (conservative GET)', async () => { + FILE_DETECTIONS.set('client.ts', [ + detection('consumer', 'GET', '/api/orders', null), + detection('consumer', 'POST', '/api/orders', null), + ]); + + const db = vi.fn(async (query: string) => { + if (query.includes('FETCHES')) { + return [ + { + fileId: 'f1', + filePath: 'client.ts', + routePath: '/api/orders', + fetchReason: 'fetch', + }, + ]; + } + return []; + }); + + const out = await new HttpRouteExtractor().extract(db, '/repo', { + name: 'r', + url: 'r', + } as never); + expect(out).toHaveLength(1); + // CRITICAL: must NOT silently pick POST (first/last via .find) + expect(out[0].meta.method).toBe('GET'); // conservative default + expect(out[0].contractId).toBe('http::GET::/api/orders'); + }); +}); diff --git a/gitnexus/test/unit/group/manifest-extractor.test.ts b/gitnexus/test/unit/group/manifest-extractor.test.ts index c2c67a33a..42ed86b84 100644 --- a/gitnexus/test/unit/group/manifest-extractor.test.ts +++ b/gitnexus/test/unit/group/manifest-extractor.test.ts @@ -300,6 +300,284 @@ describe('ManifestExtractor', () => { } }); + it('resolves http contract with explicit METHOD prefix (GET::/api/orders)', async () => { + // Regression test for Codex finding F1: resolveSymbol was passing the + // raw `link.contract` through normalizeRoutePath, which turned + // "GET::/api/orders" into "/GET::/api/orders" and never matched + // Route.name = "/api/orders". The extractor must strip the METHOD:: + // prefix and pass only the path portion to the Cypher executor. + const links: GroupManifestLink[] = [ + { + from: 'gateway', + to: 'orders-svc', + type: 'http', + contract: 'GET::/api/orders', + role: 'consumer', + }, + ]; + + let seenParam: string | undefined; + const dbExecutors = new Map< + string, + (cypher: string, params?: Record) => Promise[]> + >([ + [ + 'orders-svc', + async (_cypher, params) => { + seenParam = params?.normalized as string; + if (seenParam === '/api/orders') { + return [ + { + uid: 'uid-orders-list', + name: 'listOrders', + filePath: 'src/orders.ts', + }, + ]; + } + return []; + }, + ], + ['gateway', async () => []], + ]); + + const result = await extractor.extractFromManifest(links, dbExecutors); + + // The key assertion: $normalized must be the path only, NOT "/GET::/api/orders". + expect(seenParam).toBe('/api/orders'); + + const provider = result.contracts.find((c) => c.role === 'provider'); + expect(provider?.symbolUid).toBe('uid-orders-list'); + expect(provider?.symbolRef.filePath).toBe('src/orders.ts'); + }); + + it('resolves http contract with parameterised path (POST::/users/:id)', async () => { + const links: GroupManifestLink[] = [ + { + from: 'gateway', + to: 'users-svc', + type: 'http', + contract: 'POST::/users/:id', + role: 'consumer', + }, + ]; + + let seenParam: string | undefined; + const dbExecutors = new Map< + string, + (cypher: string, params?: Record) => Promise[]> + >([ + [ + 'users-svc', + async (_cypher, params) => { + seenParam = params?.normalized as string; + if (seenParam === '/users/:id') { + return [ + { + uid: 'uid-update-user', + name: 'updateUser', + filePath: 'src/users.ts', + }, + ]; + } + return []; + }, + ], + ['gateway', async () => []], + ]); + + const result = await extractor.extractFromManifest(links, dbExecutors); + expect(seenParam).toBe('/users/:id'); + const provider = result.contracts.find((c) => c.role === 'provider'); + expect(provider?.symbolUid).toBe('uid-update-user'); + }); + + it('handles http contract with empty path after METHOD:: (GET::)', async () => { + // Edge case: "GET::" (empty path after prefix). Normalizer produces "/" + // — either resolves to a root route or returns null cleanly. + const links: GroupManifestLink[] = [ + { + from: 'gateway', + to: 'orders-svc', + type: 'http', + contract: 'GET::', + role: 'consumer', + }, + ]; + + let seenParam: string | undefined; + const dbExecutors = new Map< + string, + (cypher: string, params?: Record) => Promise[]> + >([ + [ + 'orders-svc', + async (_cypher, params) => { + seenParam = params?.normalized as string; + return []; + }, + ], + ['gateway', async () => []], + ]); + + const result = await extractor.extractFromManifest(links, dbExecutors); + expect(seenParam).toBe('/'); + // No match → synthetic uid, no crash. + const provider = result.contracts.find((c) => c.role === 'provider'); + // buildContractId canonicalizes the empty path to `/` so contract ids + // match regardless of trailing-slash variants in the manifest input. + expect(provider?.symbolUid).toBe('manifest::orders-svc::http::GET::/'); + }); + + it('treats empty method portion (::/api/orders) as a bare path', async () => { + const links: GroupManifestLink[] = [ + { + from: 'gateway', + to: 'orders-svc', + type: 'http', + contract: '::/api/orders', + role: 'consumer', + }, + ]; + + let seenParam: string | undefined; + const dbExecutors = new Map< + string, + (cypher: string, params?: Record) => Promise[]> + >([ + [ + 'orders-svc', + async (_cypher, params) => { + seenParam = params?.normalized as string; + return []; + }, + ], + ['gateway', async () => []], + ]); + + await extractor.extractFromManifest(links, dbExecutors); + // "::/api/orders" has no method prefix per buildContractId's regex + // (`[A-Za-z]+::`), so the whole string is treated as a bare path. + // Normalizer collapses leading slashes, so "::/api/orders" stays + // essentially as-is (no alpha prefix match). + expect(seenParam).toBe('/::/api/orders'); + }); + + it('resolves http contract with lowercase verb (get::/api/orders)', async () => { + const links: GroupManifestLink[] = [ + { + from: 'gateway', + to: 'orders-svc', + type: 'http', + contract: 'get::/api/orders', + role: 'consumer', + }, + ]; + + let seenParam: string | undefined; + const dbExecutors = new Map< + string, + (cypher: string, params?: Record) => Promise[]> + >([ + [ + 'orders-svc', + async (_cypher, params) => { + seenParam = params?.normalized as string; + if (seenParam === '/api/orders') { + return [ + { + uid: 'uid-orders-list', + name: 'listOrders', + filePath: 'src/orders.ts', + }, + ]; + } + return []; + }, + ], + ['gateway', async () => []], + ]); + + const result = await extractor.extractFromManifest(links, dbExecutors); + expect(seenParam).toBe('/api/orders'); + const provider = result.contracts.find((c) => c.role === 'provider'); + expect(provider?.symbolUid).toBe('uid-orders-list'); + }); + + it('returns null cleanly when no Route matches explicit-method http contract', async () => { + const links: GroupManifestLink[] = [ + { + from: 'gateway', + to: 'orders-svc', + type: 'http', + contract: 'GET::/api/orders', + role: 'consumer', + }, + ]; + + const dbExecutors = new Map< + string, + (cypher: string, params?: Record) => Promise[]> + >([ + ['orders-svc', async () => []], + ['gateway', async () => []], + ]); + + const result = await extractor.extractFromManifest(links, dbExecutors); + const provider = result.contracts.find((c) => c.role === 'provider'); + // No match → synthetic uid, caller falls back as today. + expect(provider?.symbolUid).toBe('manifest::orders-svc::http::GET::/api/orders'); + }); + + it('buildContractId round-trip regression for GET::/api/orders', async () => { + // Verifies buildContractId still produces http::GET::/api/orders for + // explicit-method form — i.e. the fix to resolveSymbol did not touch + // buildContractId. + const links: GroupManifestLink[] = [ + { + from: 'gateway', + to: 'orders-svc', + type: 'http', + contract: 'GET::/api/orders', + role: 'consumer', + }, + ]; + + const result = await extractor.extractFromManifest(links); + const provider = result.contracts.find((c) => c.role === 'provider'); + expect(provider?.contractId).toBe('http::GET::/api/orders'); + }); + + it('canonicalizes method casing so get::/api/orders and GET::/api/orders share a contractId', async () => { + // Regression for Copilot's review on PR #817: without canonicalization, + // `buildContractId` passed raw casing through (`http::get::/api/orders`) + // while `parseHttpContract` upper-cased during lookup, fragmenting + // cross-impact joins between providers and consumers that happened to + // use different casing conventions in their group.yaml. + const lower = await extractor.extractFromManifest([ + { + from: 'gateway', + to: 'orders-svc', + type: 'http', + contract: 'get::/api/orders', + role: 'consumer', + }, + ]); + const upper = await extractor.extractFromManifest([ + { + from: 'gateway', + to: 'orders-svc', + type: 'http', + contract: 'GET::/api/orders', + role: 'consumer', + }, + ]); + const lowerContractId = lower.contracts.find((c) => c.role === 'provider')?.contractId; + const upperContractId = upper.contracts.find((c) => c.role === 'provider')?.contractId; + expect(lowerContractId).toBe('http::GET::/api/orders'); + expect(upperContractId).toBe('http::GET::/api/orders'); + expect(lowerContractId).toBe(upperContractId); + }); + it('returns empty for no links', async () => { const result = await extractor.extractFromManifest([]); expect(result.contracts).toHaveLength(0); From 1a597f3cc6a6d4ac1eed61fc6bb904050285655b Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 08:57:29 +0100 Subject: [PATCH 32/67] Fix npm arborist crash caused by tree-sitter-dart tarball URL format (#820) * Initial plan * fix: change tree-sitter-dart from tarball URL to git URL to fix npm arborist crash, add error handling and troubleshooting docs Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/382c76c6-89c3-463a-8631-2a5d6510be4c Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * refine error handler patterns and troubleshooting docs for arborist crash Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/909b319b-c367-40aa-8033-32dfb6231d4e Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * style: run prettier on changed files Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/50eb6b94-9300-4bf2-9b61-c2d78f637fc6 * fix: use github: shorthand for tree-sitter-dart to avoid SSH in CI Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/2ce3f4b3-4c1e-4c39-b824-c25cfe145529 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * revert: use git+https:// for tree-sitter-dart instead of github: shorthand (fixes arborist crash from PR #811) Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b786b68d-6c76-4054-88eb-ad46ea9f5b81 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --- gitnexus/README.md | 50 +++++++++++++++++++++++++++++++++++++ gitnexus/package-lock.json | 6 ++--- gitnexus/package.json | 2 +- gitnexus/src/cli/analyze.ts | 24 +++++++++++++++++- 4 files changed, 77 insertions(+), 5 deletions(-) diff --git a/gitnexus/README.md b/gitnexus/README.md index 7e87c93b4..8c7888d66 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -234,6 +234,56 @@ Installed automatically by both `gitnexus analyze` (per-repo) and `gitnexus setu - Node.js >= 18 - Git repository (uses git for commit tracking) +## Troubleshooting + +### `Cannot destructure property 'package' of 'node.target' as it is null` + +This crash was caused by a dependency URL format that is incompatible with +certain npm/arborist versions ([npm/cli#8126](https://github.com/npm/cli/issues/8126)). +It is fixed in **gitnexus v1.6.2+**. Upgrade to the latest version: + +```bash +npx gitnexus@latest analyze # always uses the newest release +# — or — +npm install -g gitnexus@latest # upgrade a global install +``` + +If you still hit npm install issues after upgrading, these generic workarounds +may help: + +```bash +npm install -g npm@latest # update npm itself +npm cache clean --force # clear a possibly corrupt cache +``` + +### Installation fails with native module errors + +Some optional language grammars (Dart, Kotlin, Swift) require native compilation. If they fail, GitNexus still works — those languages will be skipped. + +If `npm install -g gitnexus` fails on native modules: + +```bash +# Ensure build tools are available (Linux/macOS) +# Ubuntu/Debian: sudo apt install python3 make g++ +# macOS: xcode-select --install + +# Retry installation +npm install -g gitnexus +``` + +### Analysis runs out of memory + +For very large repositories: + +```bash +# Increase Node.js heap size +NODE_OPTIONS="--max-old-space-size=16384" npx gitnexus analyze + +# Exclude large directories +echo "vendor/" >> .gitnexusignore +echo "dist/" >> .gitnexusignore +``` + ## Privacy - All processing happens locally on your machine diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index c9ca9017f..bc4fc7b20 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -62,7 +62,7 @@ "node": ">=20.0.0" }, "optionalDependencies": { - "tree-sitter-dart": "https://github.com/UserNobody14/tree-sitter-dart/archive/80e23c07b64494f7e21090bb3450223ef0b192f4.tar.gz", + "tree-sitter-dart": "git+https://github.com/UserNobody14/tree-sitter-dart.git#80e23c07b64494f7e21090bb3450223ef0b192f4", "tree-sitter-kotlin": "^0.3.8", "tree-sitter-proto": "file:./vendor/tree-sitter-proto", "tree-sitter-swift": "^0.6.0" @@ -5132,8 +5132,8 @@ }, "node_modules/tree-sitter-dart": { "version": "1.0.0", - "resolved": "https://github.com/UserNobody14/tree-sitter-dart/archive/80e23c07b64494f7e21090bb3450223ef0b192f4.tar.gz", - "integrity": "sha512-aqLZTEji2vAZPdbaCSjR0SXJGzFRKD//7VtrSV3st9bgrCM2tsXxXAHZlMlQLOCt7K2yKxM5K3gNXYph8TCjCQ==", + "resolved": "git+https://github.com/UserNobody14/tree-sitter-dart.git#80e23c07b64494f7e21090bb3450223ef0b192f4", + "integrity": "sha512-Bs/1wAOIJ2akPEXlE/XVpuES19Oo3NqoSJRJ/0N2r38qAd9nTXdqmaGHQ44/JXnA6QHcbgD2YzCCc4wUc98cyQ==", "hasInstallScript": true, "license": "ISC", "optional": true, diff --git a/gitnexus/package.json b/gitnexus/package.json index d0d0704f5..571a341c5 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -84,7 +84,7 @@ "uuid": "^13.0.0" }, "optionalDependencies": { - "tree-sitter-dart": "https://github.com/UserNobody14/tree-sitter-dart/archive/80e23c07b64494f7e21090bb3450223ef0b192f4.tar.gz", + "tree-sitter-dart": "git+https://github.com/UserNobody14/tree-sitter-dart.git#80e23c07b64494f7e21090bb3450223ef0b192f4", "tree-sitter-kotlin": "^0.3.8", "tree-sitter-proto": "file:./vendor/tree-sitter-proto", "tree-sitter-swift": "^0.6.0" diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index dc8fd87a9..26d1ae8c6 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -297,7 +297,7 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption const msg = err.message || String(err); console.error(`\n Analysis failed: ${msg}\n`); - // Provide helpful guidance for known large-repo failure modes + // Provide helpful guidance for known failure modes if ( msg.includes('Maximum call stack size exceeded') || msg.includes('call stack') || @@ -314,6 +314,28 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption console.error(' 2. Increase Node.js heap: NODE_OPTIONS="--max-old-space-size=16384"'); console.error(' 3. Increase stack size: NODE_OPTIONS="--stack-size=4096"'); console.error(''); + } else if (msg.includes('ERESOLVE') || msg.includes('Could not resolve dependency')) { + // Note: the original arborist "Cannot destructure property 'package' of + // 'node.target'" crash happens inside npm *before* gitnexus code runs, + // so it can't be caught here. This branch handles dependency-resolution + // errors that surface at runtime (e.g. dynamic require failures). + console.error(' This looks like an npm dependency resolution issue.'); + console.error(' Suggestions:'); + console.error(' 1. Clear the npm cache: npm cache clean --force'); + console.error(' 2. Update npm: npm install -g npm@latest'); + console.error(' 3. Reinstall gitnexus: npm install -g gitnexus@latest'); + console.error(' 4. Or try npx directly: npx gitnexus@latest analyze'); + console.error(''); + } else if ( + msg.includes('MODULE_NOT_FOUND') || + msg.includes('Cannot find module') || + msg.includes('ERR_MODULE_NOT_FOUND') + ) { + console.error(' A required module could not be loaded. The installation may be corrupt.'); + console.error(' Suggestions:'); + console.error(' 1. Reinstall: npm install -g gitnexus@latest'); + console.error(' 2. Clear cache: npm cache clean --force && npx gitnexus@latest analyze'); + console.error(''); } process.exitCode = 1; From 9ad1984b17e4c53f7e3085226f0e4ce17ad9354b Mon Sep 17 00:00:00 2001 From: "Filipe Oliveira (Redis)" Date: Tue, 14 Apr 2026 09:39:17 +0100 Subject: [PATCH 33/67] fix: resolve C/C++ cross-file calls through transitive #include chains (#816) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: resolve C/C++ cross-file calls through transitive #include chains In C/C++, #include is transitive: if a.c includes b.h and b.h includes c.h, then a.c can call any function declared in c.h. The wildcard import synthesis only walked direct imports (1 hop), missing symbols reachable through transitive header chains. This is the dominant pattern in large C codebases — Redis's db.c includes server.h which includes dict.h, so db.c should resolve calls to dictFind() declared in dict.h and defined in dict.c. Before this fix, those cross-file call edges were missing entirely. The fix expands the import closure transitively for C/C++ files before synthesizing wildcard bindings. A BFS walks ctx.importMap and graphImports to collect all transitively reachable headers, then passes the full closure to synthesizeForFile. Tested on Redis (github.com/redis/redis): - Before: dictFetchValue had 0 cross-file callers, processCommand had 0 - After: dictFetchValue has 9 callers, processCommand has 1, +1946 edges total Fixes #813 * refactor(ingestion): dispatch wildcard synthesis by import-semantics strategy Generalize PR #816's C/C++ transitive #include fix into a language-agnostic strategy pattern. The `wildcard-synthesis.ts` pipeline phase no longer references `SupportedLanguages.C` / `SupportedLanguages.CPlusPlus` — it dispatches on `provider.importSemantics` via an exhaustive `switch`. Also fixes a correctness bug the original BFS introduced: `queue.pop()` (LIFO/DFS) reversed the iteration order of `#include` directives, which — combined with first-seen-wins dedup in `synthesizeForFile` — silently bound overloaded symbols to the wrong header. For the `cpp-calls` fixture, `write_audit("hello")` was being resolved to `zero.h`'s arity-0 overload instead of `one.h`'s arity-1 overload, breaking arity narrowing. Switched to FIFO (`queue.shift()`) with direct imports seeded in declaration order. Taxonomy (researched across 20+ languages + stack-graphs / SCIP prior art): | Tag | Traversal | Languages | |---------------------|-----------------|------------------------------------| | named | none | TS, JS, Java, C#, Rust, PHP, Kotlin| | wildcard-transitive | BFS closure | C, C++ | | wildcard-leaf | single hop | Go, Ruby, Swift, Dart | | namespace | none at import | Python | | explicit-reexport | topological DAG | (scaffold; TS `export *` future) | Changes: - Widen `ImportSemantics` union from 3 to 5 tags with full taxonomy JSDoc - Retag 5 providers: c-cpp (x2) → wildcard-transitive; dart, go, ruby, swift → wildcard-leaf - Move BFS closure into `wildcard-synthesis.ts` as `expandTransitiveIncludeClosure` (pipeline-owned; providers stay pure declarations) - Replace `if (lang === C || CPP)` with `dispatchSynthesis` helper called by both Loop 1 (ctx.importMap) and Loop 2 (graphImports) so a future transitive language whose edges arrive via graphImports gets closure expansion consistently - `never`-assertion default arm forces compile-time exhaustiveness - `explicit-reexport` arm falls through to leaf behavior (scaffold; TODO: implement re-export DAG walk for TS `export *` / Rust `pub use`) - New unit tests covering circular includes, deep chains, diamond dedup, graphImports-only paths, and order-preservation (the regression fix) Verification: - All existing C/C++ transitive tests pass unchanged - Previously failing `cpp.test.ts > resolves run → write_audit to one.h via arity narrowing` now passes - `tsc --noEmit` clean - 225/225 tests pass across wildcard-synthesis, cross-file-binding, cpp resolver, and new closure unit tests * fix(ingestion): bound closure size, O(1) dequeue, track Strategy 4 (#816 review) Address @xkonjin's review feedback on the import-resolution strategy refactor: 1. **DoS guard**: cap transitive closures at 5,000 files via `MAX_TRANSITIVE_CLOSURE_SIZE`. Pathological codebases (boost-style headers, monoheader kernels) could previously produce closures with tens of thousands of entries per translation unit. BFS now stops early and returns a partial closure rather than risking OOM. The closest-headers-first BFS ordering means the partial closure still contains the files overload resolution cares about. 2. **Perf**: replace `Array.prototype.shift()` (O(n)) with a head-index queue (O(1) dequeue). Deep chains previously had quadratic BFS behavior; now linear in closure size. 3. **Strategy 4 tracking**: change TODO in `dispatchSynthesis` to `TODO(#821)` referencing the filed issue for TS `export *` / Rust `pub use` DAG-walk implementation, and clarify that today's leaf fallthrough preserves correctness for direct imports — only the extra re-export traversal is missing. 4. **Test**: new unit test exercising the 5,000-file cap on a 10k-file synthetic chain, verifying partial-closure invariants (starts from importer side, bounded, deep nodes excluded). Not addressed in this commit (followups): - Review point 3 (graphImports-only deep-chain *integration* fixture): unit tests already exercise the `graphImports` traversal path directly in isolation and combined with `importMap`. A fixture that stresses graphImports-only transitive resolution is valuable but requires understanding when the pipeline populates graphImports distinctly from ctx.importMap — tracking as a followup rather than blocking this PR. --------- Co-authored-by: Gergo Magyar --- .../src/core/ingestion/language-provider.ts | 34 +++- .../src/core/ingestion/languages/c-cpp.ts | 4 +- gitnexus/src/core/ingestion/languages/dart.ts | 4 +- gitnexus/src/core/ingestion/languages/go.ts | 4 +- gitnexus/src/core/ingestion/languages/ruby.ts | 2 +- .../src/core/ingestion/languages/swift.ts | 4 +- .../pipeline-phases/wildcard-synthesis.ts | 162 +++++++++++++++++- .../cross-file-binding/c-cross-file/src/db.c | 12 ++ .../c-cross-file/src/dict.c | 12 ++ .../c-cross-file/src/dict.h | 12 ++ .../c-cross-file/src/server.h | 8 + .../integration/cross-file-binding.test.ts | 36 ++++ .../unit/transitive-include-closure.test.ts | 101 +++++++++++ 13 files changed, 375 insertions(+), 20 deletions(-) create mode 100644 gitnexus/test/fixtures/cross-file-binding/c-cross-file/src/db.c create mode 100644 gitnexus/test/fixtures/cross-file-binding/c-cross-file/src/dict.c create mode 100644 gitnexus/test/fixtures/cross-file-binding/c-cross-file/src/dict.h create mode 100644 gitnexus/test/fixtures/cross-file-binding/c-cross-file/src/server.h create mode 100644 gitnexus/test/unit/transitive-include-closure.test.ts diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index fae696be6..736c3b666 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -30,8 +30,30 @@ export type CaptureMap = Record; // so `core/ingestion/model/resolve.ts` can consume it without importing from // this file (which would pull in the full language-registry dependency graph). -/** How a language handles imports — determines wildcard synthesis behavior. */ -export type ImportSemantics = 'named' | 'wildcard' | 'namespace'; +/** + * How a language handles imports — determines wildcard synthesis behavior. + * + * Import resolution is a graph-traversal policy with multiple distinct strategies, + * analogous to MRO for method resolution. Each tag picks a strategy: + * + * | Tag | Mechanism | Traversal | Languages | + * |-----------------------|------------------------------------------------|---------------------|--------------------------------------------| + * | `named` | Per-symbol imports | None (use-site) | JS/TS, Java, C#, Rust, PHP, Kotlin, Vue | + * | `wildcard-transitive` | Textual paste, symbols chain through files | BFS closure | C, C++ (future: Obj-C, Fortran, Nim) | + * | `wildcard-leaf` | Whole public API, single hop | None (direct only) | Go, Ruby, Swift, Dart | + * | `namespace` | Qualified handle; symbols resolved at call site| None at import | Python | + * | `explicit-reexport` | Opt-in per-symbol re-export (SCAFFOLD) | Topological DAG | (future: TS `export *`, Rust `pub use`) | + * + * The `explicit-reexport` tag is a compile-time scaffold; no provider claims it yet. + * It falls through to `wildcard-leaf` behavior in synthesis so today's TS/Rust + * handling is unchanged. A future PR will implement the DAG walk for `export *`. + */ +export type ImportSemantics = + | 'named' + | 'wildcard-transitive' + | 'wildcard-leaf' + | 'namespace' + | 'explicit-reexport'; /** * Everything a language needs to provide. @@ -68,10 +90,12 @@ interface LanguageProviderConfig { /** Named binding extraction from import statements. * Default: undefined (language uses wildcard/whole-module imports). */ readonly namedBindingExtractor?: NamedBindingExtractorFn; - /** How this language handles imports. + /** How this language handles imports. See `ImportSemantics` for the full taxonomy. * - 'named': per-symbol imports (JS/TS, Java, C#, Rust, PHP, Kotlin) - * - 'wildcard': whole-module imports, needs synthesis (Go, Ruby, C/C++, Swift) - * - 'namespace': namespace imports, needs moduleAliasMap (Python) + * - 'wildcard-transitive': textual-include closure; imports chain through files (C, C++) + * - 'wildcard-leaf': whole-module single-hop imports; no transitive chaining (Go, Ruby, Swift, Dart) + * - 'namespace': qualified namespace imports, needs moduleAliasMap (Python) + * - 'explicit-reexport': opt-in per-symbol re-export (scaffold; no provider uses yet) * Default: 'named'. */ readonly importSemantics?: ImportSemantics; /** Language-specific transformation of raw import path text before resolution. diff --git a/gitnexus/src/core/ingestion/languages/c-cpp.ts b/gitnexus/src/core/ingestion/languages/c-cpp.ts index a0508ff5b..5b887634f 100644 --- a/gitnexus/src/core/ingestion/languages/c-cpp.ts +++ b/gitnexus/src/core/ingestion/languages/c-cpp.ts @@ -321,7 +321,7 @@ export const cProvider = defineLanguage({ typeConfig: cCppConfig, exportChecker: cCppExportChecker, importResolver: resolveCImport, - importSemantics: 'wildcard', + importSemantics: 'wildcard-transitive', fieldExtractor: createFieldExtractor(cFieldConfig), methodExtractor: createMethodExtractor({ ...cMethodConfig, @@ -339,7 +339,7 @@ export const cppProvider = defineLanguage({ typeConfig: cCppConfig, exportChecker: cCppExportChecker, importResolver: resolveCppImport, - importSemantics: 'wildcard', + importSemantics: 'wildcard-transitive', mroStrategy: 'leftmost-base', fieldExtractor: createFieldExtractor(cppFieldConfig), methodExtractor: createMethodExtractor({ diff --git a/gitnexus/src/core/ingestion/languages/dart.ts b/gitnexus/src/core/ingestion/languages/dart.ts index 591519895..7dc6769c6 100644 --- a/gitnexus/src/core/ingestion/languages/dart.ts +++ b/gitnexus/src/core/ingestion/languages/dart.ts @@ -2,7 +2,7 @@ * Dart Language Provider * * Dart traits: - * - importSemantics: 'wildcard' (Dart imports bring everything public into scope) + * - importSemantics: 'wildcard-leaf' (Dart imports bring everything public into scope) * - exportChecker: public if no leading underscore * - Dart SDK imports (dart:*) and external packages are skipped * - enclosingFunctionFinder: Dart's tree-sitter grammar places function_body @@ -90,7 +90,7 @@ export const dartProvider = defineLanguage({ typeConfig: dartConfig, exportChecker: dartExportChecker, importResolver: resolveDartImport, - importSemantics: 'wildcard', + importSemantics: 'wildcard-leaf', fieldExtractor: createFieldExtractor(dartFieldConfig), methodExtractor: createMethodExtractor(dartMethodConfig), classExtractor: createClassExtractor({ diff --git a/gitnexus/src/core/ingestion/languages/go.ts b/gitnexus/src/core/ingestion/languages/go.ts index 803e70bbb..2a2b35f50 100644 --- a/gitnexus/src/core/ingestion/languages/go.ts +++ b/gitnexus/src/core/ingestion/languages/go.ts @@ -5,7 +5,7 @@ * LanguageProvider, following the Strategy pattern used by the pipeline. * * Key Go traits: - * - importSemantics: 'wildcard' (Go imports entire packages) + * - importSemantics: 'wildcard-leaf' (Go imports entire packages) * - callRouter: present (Go method calls may need routing) */ @@ -28,7 +28,7 @@ export const goProvider = defineLanguage({ typeConfig: goConfig, exportChecker: goExportChecker, importResolver: resolveGoImport, - importSemantics: 'wildcard', + importSemantics: 'wildcard-leaf', fieldExtractor: createFieldExtractor(goFieldConfig), methodExtractor: createMethodExtractor(goMethodConfig), classExtractor: createClassExtractor({ diff --git a/gitnexus/src/core/ingestion/languages/ruby.ts b/gitnexus/src/core/ingestion/languages/ruby.ts index 00b566068..a15b5439c 100644 --- a/gitnexus/src/core/ingestion/languages/ruby.ts +++ b/gitnexus/src/core/ingestion/languages/ruby.ts @@ -107,7 +107,7 @@ export const rubyProvider = defineLanguage({ exportChecker: rubyExportChecker, importResolver: resolveRubyImport, callRouter: routeRubyCall, - importSemantics: 'wildcard', + importSemantics: 'wildcard-leaf', resolveEnclosingOwner(node) { // Ruby singleton_class (class << self) should resolve to the enclosing // class or module for owner/container resolution (HAS_METHOD edges, class IDs). diff --git a/gitnexus/src/core/ingestion/languages/swift.ts b/gitnexus/src/core/ingestion/languages/swift.ts index 7d01b4912..314c27c1d 100644 --- a/gitnexus/src/core/ingestion/languages/swift.ts +++ b/gitnexus/src/core/ingestion/languages/swift.ts @@ -5,7 +5,7 @@ * LanguageProvider, following the Strategy pattern used by the pipeline. * * Key Swift traits: - * - importSemantics: 'wildcard' (Swift imports entire modules) + * - importSemantics: 'wildcard-leaf' (Swift imports entire modules) * - heritageDefaultEdge: 'IMPLEMENTS' (protocols are more common than class inheritance) * - implicitImportWirer: all files in the same SPM target see each other */ @@ -238,7 +238,7 @@ export const swiftProvider = defineLanguage({ typeConfig: swiftConfig, exportChecker: swiftExportChecker, importResolver: resolveSwiftImport, - importSemantics: 'wildcard', + importSemantics: 'wildcard-leaf', heritageDefaultEdge: 'IMPLEMENTS', fieldExtractor: createFieldExtractor(swiftFieldConfig), methodExtractor: createMethodExtractor({ diff --git a/gitnexus/src/core/ingestion/pipeline-phases/wildcard-synthesis.ts b/gitnexus/src/core/ingestion/pipeline-phases/wildcard-synthesis.ts index c2c0f986d..83f38bc60 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/wildcard-synthesis.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/wildcard-synthesis.ts @@ -15,8 +15,10 @@ import type { KnowledgeGraph } from '../../graph/types.js'; import type { createResolutionContext } from '../model/resolution-context.js'; -import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared'; +import { getLanguageFromFilename } from 'gitnexus-shared'; +import type { SupportedLanguages } from 'gitnexus-shared'; import { providers, getProviderForFile } from '../languages/index.js'; +import type { LanguageProvider, ImportSemantics } from '../language-provider.js'; // ── Constants ────────────────────────────────────────────────────────────── @@ -41,10 +43,29 @@ const IMPORTABLE_SYMBOL_LABELS = new Set([ * for C/C++ files that include many large headers. */ const MAX_SYNTHETIC_BINDINGS_PER_FILE = 1000; +/** Max files allowed in a single transitive include closure. Guards against + * OOM on pathological C/C++ codebases (boost, Linux kernel-style monoheaders) + * where a single translation unit can transitively reach many thousands of + * headers. When the cap is hit, BFS expansion stops early — the file still + * synthesizes bindings from the partial closure rather than failing. */ +const MAX_TRANSITIVE_CLOSURE_SIZE = 5000; + +/** Import semantics tags whose languages need synthesis of whole-module imports. + * `wildcard-transitive` (C/C++) and `wildcard-leaf` (Go, Ruby, Swift, Dart) are + * the file-based wildcard strategies. `explicit-reexport` is a scaffold tag — + * no provider uses it yet, but it goes through the same leaf-style synthesis + * path today because a re-exporter is still an importer; only the extra DAG + * walk to surface re-exported symbols is missing (future work). */ +const WILDCARD_SEMANTICS: ReadonlySet = new Set([ + 'wildcard-transitive', + 'wildcard-leaf', + 'explicit-reexport', +]); + /** Languages with whole-module import semantics (derived from providers at module load). */ const WILDCARD_LANGUAGES = new Set( Object.values(providers) - .filter((p) => p.importSemantics === 'wildcard') + .filter((p) => WILDCARD_SEMANTICS.has(p.importSemantics)) .map((p) => p.id), ); @@ -66,6 +87,84 @@ export function needsSynthesis(lang: SupportedLanguages): boolean { return SYNTHESIS_LANGUAGES.has(lang); } +// ── Strategy implementations ─────────────────────────────────────────────── + +/** + * Strategy implementation for `importSemantics: 'wildcard-transitive'` (C, C++). + * + * Textual-include languages chain symbols through files: if `dict.c` includes + * `server.h` and `server.h` includes `dict.h`, then `dict.c` sees symbols from + * all three files. This helper walks the include graph (combining both the + * ingestion-context `importMap` and the graph-level IMPORTS edges) until the + * closure is stable. + * + * **Order matters.** The returned `Set` preserves iteration order (insertion + * order). `synthesizeWildcardImportBindings` dedupes bindings by symbol name + * on a first-seen-wins basis, so this closure's ordering determines which + * declaration wins when multiple headers export the same name (e.g. overloaded + * free functions like `write_audit()` vs `write_audit(const char*)` in + * different headers). We therefore: + * 1. Seed the closure with direct imports in declaration order (matches the + * order of `#include` directives in the source file). + * 2. Use FIFO / true BFS (`queue.shift()`) for transitive expansion, so + * closer headers are seen before deeper ones. + * + * Cycle-safe: the `closure.has(file)` guard prevents infinite loops on circular + * header includes, which are valid C/C++ when paired with `#pragma once` or + * include guards. + * + * Size-bounded: the closure is capped at `MAX_TRANSITIVE_CLOSURE_SIZE` files to + * prevent OOM on pathological codebases (e.g. boost, monoheader kernel code) + * where one translation unit can transitively reach tens of thousands of + * headers. Partial closures still yield useful bindings for the cluster of + * headers closest to the importer, which is what overload resolution and + * cross-file call resolution care about. + * + * Queue implementation: uses a head-index over a growing array (O(1) dequeue) + * instead of `Array.prototype.shift()` (O(n)) so deep chains stay linear. + */ +export function expandTransitiveIncludeClosure( + directImports: Iterable, + importMap: ReadonlyMap>, + graphImports: ReadonlyMap>, +): Set { + const closure = new Set(); + const queue: string[] = []; + let head = 0; // O(1) dequeue: advance the head index instead of shift()-ing. + + const tryEnqueue = (file: string): boolean => { + if (closure.has(file)) return true; + if (closure.size >= MAX_TRANSITIVE_CLOSURE_SIZE) return false; + closure.add(file); + queue.push(file); + return true; + }; + + // Seed direct imports in declaration order (see JSDoc on order-sensitivity). + for (const f of directImports) { + if (!tryEnqueue(f)) break; + } + // True BFS for transitive reach: head-index FIFO preserves the "closer + // headers first" ordering that overload resolution depends on. + while (head < queue.length) { + if (closure.size >= MAX_TRANSITIVE_CLOSURE_SIZE) break; + const file = queue[head++]!; + const nested = importMap.get(file); + if (nested) { + for (const n of nested) { + if (!tryEnqueue(n)) break; + } + } + const nestedGraph = graphImports.get(file); + if (nestedGraph) { + for (const n of nestedGraph) { + if (!tryEnqueue(n)) break; + } + } + } + return closure; +} + // ── Main synthesis function ──────────────────────────────────────────────── /** @@ -149,16 +248,67 @@ export function synthesizeWildcardImportBindings( } }; - // Synthesize from ctx.importMap (Ruby, C/C++, Swift file-based imports) + /** + * Dispatch wildcard synthesis by the file's language provider strategy. + * + * Strategy tags (see `ImportSemantics`): + * - `wildcard-transitive`: expand the include closure first (C/C++ #include + * chains — e.g. `dict.c` → `server.h` → `dict.h` so `dictFind` resolves + * across header chains) + * - `wildcard-leaf`: synthesize from direct imports only (Go, Ruby, Swift, Dart) + * - `explicit-reexport`: scaffold tag; falls through to leaf behavior. + * TODO(#821): implement re-export DAG walk for TS `export *` / Rust + * `pub use`. The leaf fallthrough preserves today's TS/Rust behavior + * (their direct imports still synthesize correctly); only the extra + * re-export DAG walk for barrel-file correctness is missing. + * - `namespace` / `named`: no-op here (namespace handled in Loop 3 below, + * named needs no synthesis). + * + * Used by both Loop 1 (ctx.importMap) and Loop 2 (graphImports) so a future + * transitive-import language whose edges arrive via graphImports gets closure + * expansion consistently regardless of edge source. + */ + const dispatchSynthesis = ( + filePath: string, + importedFiles: ReadonlySet, + provider: LanguageProvider, + ) => { + switch (provider.importSemantics) { + case 'wildcard-transitive': + synthesizeForFile( + filePath, + expandTransitiveIncludeClosure(importedFiles, ctx.importMap, graphImports), + ); + return; + case 'wildcard-leaf': + case 'explicit-reexport': + synthesizeForFile(filePath, importedFiles); + return; + case 'namespace': + case 'named': + return; + default: { + const _exhaustive: never = provider.importSemantics; + void _exhaustive; + } + } + }; + + // Loop 1: synthesize from ctx.importMap (Ruby, C/C++, Swift, Dart file-based imports). for (const [filePath, importedFiles] of ctx.importMap) { const lang = getLanguageFromFilename(filePath); if (!lang || !isWildcardImportLanguage(lang)) continue; - synthesizeForFile(filePath, importedFiles); + const provider = getProviderForFile(filePath); + if (!provider) continue; + dispatchSynthesis(filePath, importedFiles, provider); } - // Synthesize from graph IMPORTS edges (Go and other wildcard-import languages) + // Loop 2: synthesize from graph IMPORTS edges (Go and other wildcard-import + // languages whose edges live in the graph rather than ctx.importMap). for (const [filePath, importedFiles] of graphImports) { - synthesizeForFile(filePath, importedFiles); + const provider = getProviderForFile(filePath); + if (!provider) continue; + dispatchSynthesis(filePath, importedFiles, provider); } // Build Python module-alias maps for namespace-import languages. diff --git a/gitnexus/test/fixtures/cross-file-binding/c-cross-file/src/db.c b/gitnexus/test/fixtures/cross-file-binding/c-cross-file/src/db.c new file mode 100644 index 000000000..deb76c90e --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/c-cross-file/src/db.c @@ -0,0 +1,12 @@ +#include "server.h" + +void lookupKey(const char *key) { + dictEntry *entry = dictFind(key); + if (entry) { + void *val = entry->val; + } +} + +void dbGet(const char *key) { + void *val = dictFetchValue(key); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/c-cross-file/src/dict.c b/gitnexus/test/fixtures/cross-file-binding/c-cross-file/src/dict.c new file mode 100644 index 000000000..514a6c866 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/c-cross-file/src/dict.c @@ -0,0 +1,12 @@ +#include "dict.h" +#include + +dictEntry *dictFind(const char *key) { + return NULL; +} + +void *dictFetchValue(const char *key) { + dictEntry *entry = dictFind(key); + if (entry) return entry->val; + return NULL; +} diff --git a/gitnexus/test/fixtures/cross-file-binding/c-cross-file/src/dict.h b/gitnexus/test/fixtures/cross-file-binding/c-cross-file/src/dict.h new file mode 100644 index 000000000..9d17d19bf --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/c-cross-file/src/dict.h @@ -0,0 +1,12 @@ +#ifndef DICT_H +#define DICT_H + +typedef struct dictEntry { + void *key; + void *val; +} dictEntry; + +dictEntry *dictFind(const char *key); +void *dictFetchValue(const char *key); + +#endif diff --git a/gitnexus/test/fixtures/cross-file-binding/c-cross-file/src/server.h b/gitnexus/test/fixtures/cross-file-binding/c-cross-file/src/server.h new file mode 100644 index 000000000..e7ccf7fdd --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/c-cross-file/src/server.h @@ -0,0 +1,8 @@ +#ifndef SERVER_H +#define SERVER_H + +#include "dict.h" + +void processCommand(const char *cmd); + +#endif diff --git a/gitnexus/test/integration/cross-file-binding.test.ts b/gitnexus/test/integration/cross-file-binding.test.ts index 33063f94f..88ba776ed 100644 --- a/gitnexus/test/integration/cross-file-binding.test.ts +++ b/gitnexus/test/integration/cross-file-binding.test.ts @@ -436,6 +436,42 @@ describe('Phase 9 — Cross-File Call-Result Binding: C++', () => { }); }); +describe('Cross-File Call Resolution: pure C transitive #include', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(CROSS_FILE_FIXTURES, 'c-cross-file'), () => {}); + }, 60000); + + it('detects dictFind and dictFetchValue functions', () => { + expect(getNodesByLabel(result, 'Function')).toContain('dictFind'); + expect(getNodesByLabel(result, 'Function')).toContain('dictFetchValue'); + }); + + it('detects lookupKey and dbGet in db.c', () => { + expect(getNodesByLabel(result, 'Function')).toContain('lookupKey'); + expect(getNodesByLabel(result, 'Function')).toContain('dbGet'); + }); + + it('resolves dictFind() call in db.c to dict via transitive header chain', () => { + const calls = getRelationships(result, 'CALLS'); + const crossFileCall = calls.find( + (c) => + c.target === 'dictFind' && c.source === 'lookupKey' && c.targetFilePath.includes('dict'), + ); + expect(crossFileCall).toBeDefined(); + }); + + it('resolves dictFetchValue() call in db.c to dict via transitive header chain', () => { + const calls = getRelationships(result, 'CALLS'); + const crossFileCall = calls.find( + (c) => + c.target === 'dictFetchValue' && c.source === 'dbGet' && c.targetFilePath.includes('dict'), + ); + expect(crossFileCall).toBeDefined(); + }); +}); + describe('Phase 9 — Cross-File Call-Result Binding: C#', () => { let result: PipelineResult; diff --git a/gitnexus/test/unit/transitive-include-closure.test.ts b/gitnexus/test/unit/transitive-include-closure.test.ts new file mode 100644 index 000000000..fa9c0e6c9 --- /dev/null +++ b/gitnexus/test/unit/transitive-include-closure.test.ts @@ -0,0 +1,101 @@ +/** + * Unit tests for `expandTransitiveIncludeClosure` — the C/C++ Strategy 1 + * (`wildcard-transitive`) implementation extracted from `wildcard-synthesis.ts`. + * + * These tests exercise the BFS/DFS closure algorithm in isolation, without + * running the full pipeline. They cover edge cases flagged in PR #816 review: + * circular header includes, deep chains, and graphImports-only transitive paths. + */ + +import { describe, it, expect } from 'vitest'; +import { expandTransitiveIncludeClosure } from '../../src/core/ingestion/pipeline-phases/wildcard-synthesis.js'; + +const EMPTY = new Map>(); + +describe('expandTransitiveIncludeClosure', () => { + it('returns the direct imports when none are chained', () => { + const direct = new Set(['a.h', 'b.h']); + const closure = expandTransitiveIncludeClosure(direct, EMPTY, EMPTY); + expect([...closure].sort()).toEqual(['a.h', 'b.h']); + }); + + it('expands a two-hop chain via importMap (a.c → b.h → c.h)', () => { + const importMap = new Map>([['b.h', new Set(['c.h'])]]); + const closure = expandTransitiveIncludeClosure(new Set(['b.h']), importMap, EMPTY); + expect([...closure].sort()).toEqual(['b.h', 'c.h']); + }); + + it('expands a deep 5-level chain (A → B → C → D → E)', () => { + const importMap = new Map>([ + ['B.h', new Set(['C.h'])], + ['C.h', new Set(['D.h'])], + ['D.h', new Set(['E.h'])], + ]); + const closure = expandTransitiveIncludeClosure(new Set(['B.h']), importMap, EMPTY); + expect([...closure].sort()).toEqual(['B.h', 'C.h', 'D.h', 'E.h']); + }); + + it('terminates on circular header includes (A.h ↔ B.h)', () => { + const importMap = new Map>([ + ['A.h', new Set(['B.h'])], + ['B.h', new Set(['A.h'])], + ]); + const closure = expandTransitiveIncludeClosure(new Set(['A.h']), importMap, EMPTY); + expect([...closure].sort()).toEqual(['A.h', 'B.h']); + }); + + it('terminates on self-referential include (A.h includes A.h)', () => { + const importMap = new Map>([['A.h', new Set(['A.h'])]]); + const closure = expandTransitiveIncludeClosure(new Set(['A.h']), importMap, EMPTY); + expect([...closure]).toEqual(['A.h']); + }); + + it('expands through graphImports edges when importMap is empty', () => { + const graphImports = new Map>([['b.h', new Set(['c.h'])]]); + const closure = expandTransitiveIncludeClosure(new Set(['b.h']), EMPTY, graphImports); + expect([...closure].sort()).toEqual(['b.h', 'c.h']); + }); + + it('combines importMap and graphImports in one traversal', () => { + const importMap = new Map>([['b.h', new Set(['c.h'])]]); + const graphImports = new Map>([['c.h', new Set(['d.h'])]]); + const closure = expandTransitiveIncludeClosure(new Set(['b.h']), importMap, graphImports); + expect([...closure].sort()).toEqual(['b.h', 'c.h', 'd.h']); + }); + + it('returns an empty set when given no direct imports', () => { + const closure = expandTransitiveIncludeClosure(new Set(), EMPTY, EMPTY); + expect(closure.size).toBe(0); + }); + + it('caps closure size to prevent OOM on pathological codebases', () => { + // Build a synthetic include graph of 10,000 files, each including the next. + // The cap (5000) should halt BFS early with a partial but bounded closure. + const importMap = new Map>(); + for (let i = 0; i < 10_000; i++) { + importMap.set(`h${i}.h`, new Set([`h${i + 1}.h`])); + } + const closure = expandTransitiveIncludeClosure(new Set(['h0.h']), importMap, EMPTY); + expect(closure.size).toBe(5000); + // Partial closure still starts from the importer's side (BFS ordering). + expect(closure.has('h0.h')).toBe(true); + expect(closure.has('h1.h')).toBe(true); + expect(closure.has('h9999.h')).toBe(false); + }); + + it('deduplicates when a file is reachable through multiple paths (diamond)', () => { + // A + // / \ + // B C + // \ / + // D + const importMap = new Map>([ + ['A.h', new Set(['B.h', 'C.h'])], + ['B.h', new Set(['D.h'])], + ['C.h', new Set(['D.h'])], + ]); + const closure = expandTransitiveIncludeClosure(new Set(['A.h']), importMap, EMPTY); + expect([...closure].sort()).toEqual(['A.h', 'B.h', 'C.h', 'D.h']); + expect(closure.size).toBe(4); // D.h appears once + }); +}); From b340c5d87ae1443a6ac1c3c42c65f098c0c6136a Mon Sep 17 00:00:00 2001 From: "Md. Mekayel Anik" <32511246+MekayelAnik@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:26:38 +0600 Subject: [PATCH 34/67] fix: prevent drain listener leak in relationship CSV streaming (#818) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: add setMaxListeners(50) to relationship pair WriteStreams Dynamically-created per-pair WriteStreams for relationship CSV splitting default to Node.js's maxListeners limit of 10. On large repositories with many relationship types, readline backpressure causes repeated ws.once('drain', ...) calls that exceed this limit, flooding stderr with MaxListenersExceededWarning messages. This matches the existing pattern in csv-generator.ts where BufferedCSVWriter already calls this.ws.setMaxListeners(50). * fix: address all 3 stream bugs in relationship CSV splitting Addresses review feedback from @magyargergo and Claude CI analysis: Bug 1 (High): Add error handlers to per-pair WriteStreams. Previously, if a WriteStream errored (disk full, EMFILE) while rl was paused waiting for drain, the drain callback never fired, rl.resume() was never called, and the outer Promise hung forever — leaking all open file descriptors until process kill. Now each WriteStream gets an error handler that destroys all streams, closes the readline interface + its input ReadStream, and rejects the Promise. Bug 2 (Medium): Add waitingForDrain Set to prevent drain listener accumulation. rl.pause() is not synchronous — buffered line events continue firing after pause(), and multiple lines targeting the same pairKey each added another ws.once('drain', ...) listener. This was the root cause of MaxListenersExceededWarning. Now a Set tracks which streams are already waiting for drain. Only the first backpressure event registers the listener; subsequent lines for the same stream are silently skipped (they're already written to the stream buffer). This eliminates listener accumulation entirely and makes setMaxListeners(50) a safety net rather than a band-aid. Bug 3 (Low): Close readline and destroy input ReadStream in error handler. Previously only the WriteStreams were destroyed on error, leaving the ReadStream FD to linger until GC. * fix: address review feedback — remove setMaxListeners, harden cleanup - Remove setMaxListeners(50) entirely. The waitingForDrain guard guarantees at most 1 drain listener per stream at any time. Tested with 200 pairs x 500 lines (100k total) — max listeners was always 1, zero warnings. No hard-coded limit needed. - Wrap destroy() calls in cleanup() with try/catch so already-destroyed streams don't throw synchronously (addresses @xkonjin review point 1). - Add ws.once('error', reject) to the ws.end() phase so flush errors during stream close properly reject instead of hanging Promise.all (addresses Claude CI Bug 3b finding). * test: add 8 regression tests for relationship CSV stream fixes Covers all bugs fixed in this PR: - Bug 1: WriteStream error rejects Promise and destroys all streams - Bug 2: waitingForDrain guard keeps drain listeners at max 1 per stream - Bug 3: cleanup() handles already-destroyed streams safely Tests use a MockWriteStream with controllable backpressure and error injection to verify the exact patterns in loadGraphToLbug() without needing a real LadybugDB instance. * style: run prettier on changed files * fix(test): use backpressure to keep promise pending during error tests The error tests were racing — readline finished reading the tiny CSV and resolved the Promise before setTimeout fired the error. Now the mock streams use blocked=true to trigger backpressure, keeping the Promise pending so the error fires while the split is still in progress. * fix: use named error handler in ws.end() to prevent listener leak ws.once() wraps the callback, so removeListener with the original function reference won't match. Switch to ws.on() with a named onError function so removeListener correctly detaches it after successful close. * refactor: extract splitRelCsvByLabelPair, fix multi-stream drain 1. Extract splitRelCsvByLabelPair as an exported function with optional wsFactory parameter for dependency injection. loadGraphToLbug now delegates to it. Tests import and call the real function instead of a local reimplementation. 2. Fix multi-stream drain coordination: rl.resume() is now guarded by waitingForDrain.size === 0, so readline only resumes when ALL backpressured streams have drained. Previously, any single stream draining would resume readline while other streams were still full, allowing unbounded buffer growth. 3. Export WriteStreamFactory type and RelCsvSplitResult interface for test consumption. --- gitnexus/src/core/lbug/lbug-adapter.ts | 211 ++++++++++++------ gitnexus/test/unit/rel-csv-split.test.ts | 273 +++++++++++++++++++++++ 2 files changed, 421 insertions(+), 63 deletions(-) create mode 100644 gitnexus/test/unit/rel-csv-split.test.ts diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index 1c9fb32c3..fba92465c 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -13,6 +13,144 @@ import { } from './schema.js'; import { streamAllCSVsToDisk } from './csv-generator.js'; +// --------------------------------------------------------------------------- +// Relationship CSV splitting — extracted for testability (PR #818) +// --------------------------------------------------------------------------- + +/** Factory for creating WriteStreams — injectable for testing. */ +export type WriteStreamFactory = (filePath: string) => import('fs').WriteStream; + +/** Result of splitting the relationship CSV into per-label-pair files. */ +export interface RelCsvSplitResult { + relHeader: string; + relsByPairMeta: Map; + pairWriteStreams: Map; + skippedRels: number; + totalValidRels: number; +} + +/** + * Split a relationship CSV into per-label-pair files on disk. + * + * Streams the CSV line-by-line, routing each relationship to a file named + * `rel_{fromLabel}_{toLabel}.csv`. Handles backpressure correctly: only one + * drain listener per stream at a time, and readline resumes only when ALL + * backpressured streams have drained. + * + * @param csvPath Path to the combined relationship CSV + * @param csvDir Directory to write per-pair CSV files + * @param validTables Set of valid node table names + * @param getNodeLabel Function to extract the label from a node ID + * @param wsFactory Optional WriteStream factory (defaults to fs.createWriteStream) + */ +export const splitRelCsvByLabelPair = async ( + csvPath: string, + csvDir: string, + validTables: Set, + getNodeLabel: (id: string) => string, + wsFactory: WriteStreamFactory = (p) => createWriteStream(p, 'utf-8'), +): Promise => { + let relHeader = ''; + const relsByPairMeta = new Map(); + const pairWriteStreams = new Map(); + let skippedRels = 0; + let totalValidRels = 0; + + await new Promise((resolve, reject) => { + const inputStream = createReadStream(csvPath, 'utf-8'); + const rl = createInterface({ + input: inputStream, + crlfDelay: Infinity, + }); + + // Track which streams are already waiting for drain to prevent + // listener accumulation. rl.pause() is not synchronous — buffered + // line events continue firing after pause(), and without this guard + // each line targeting the same pairKey would add another drain listener. + const waitingForDrain = new Set(); + + let settled = false; + const cleanup = (err: Error) => { + if (settled) return; + settled = true; + try { + rl.close(); + } catch {} + try { + inputStream.destroy(); + } catch {} + for (const ws of pairWriteStreams.values()) { + try { + ws.destroy(); + } catch {} + } + reject(err); + }; + + let isFirst = true; + rl.on('line', (line) => { + if (isFirst) { + relHeader = line; + isFirst = false; + return; + } + if (!line.trim()) return; + const match = line.match(/"([^"]*)","([^"]*)"/); + if (!match) { + skippedRels++; + return; + } + const fromLabel = getNodeLabel(match[1]); + const toLabel = getNodeLabel(match[2]); + if (!validTables.has(fromLabel) || !validTables.has(toLabel)) { + skippedRels++; + return; + } + const pairKey = `${fromLabel}|${toLabel}`; + let ws = pairWriteStreams.get(pairKey); + if (!ws) { + const pairCsvPath = path.join(csvDir, `rel_${fromLabel}_${toLabel}.csv`); + ws = wsFactory(pairCsvPath); + // If any per-pair WriteStream errors (disk full, EMFILE, etc.), + // tear down everything and reject the Promise. Without this handler, + // a stream error while rl is paused waiting for drain would cause + // the drain callback to never fire and the Promise to hang forever. + ws.on('error', cleanup); + ws.write(relHeader + '\n'); + pairWriteStreams.set(pairKey, ws); + relsByPairMeta.set(pairKey, { csvPath: pairCsvPath, rows: 0 }); + } + const ok = ws.write(line + '\n'); + relsByPairMeta.get(pairKey)!.rows++; + totalValidRels++; + // Handle backpressure: pause reading when the write buffer is full, + // resume when the stream drains. Prevents unbounded memory growth + // on repos with millions of relationships. + // Guard with waitingForDrain to ensure only one drain listener is + // registered per stream at a time — rl.pause() doesn't stop buffered + // line events immediately. Only resume when ALL streams have drained + // to avoid writing into still-full streams. + if (!ok && !waitingForDrain.has(pairKey)) { + waitingForDrain.add(pairKey); + rl.pause(); + ws.once('drain', () => { + waitingForDrain.delete(pairKey); + if (waitingForDrain.size === 0) rl.resume(); + }); + } + }); + rl.on('close', () => { + if (!settled) { + settled = true; + resolve(); + } + }); + rl.on('error', cleanup); + }); + + return { relHeader, relsByPairMeta, pairWriteStreams, skippedRels, totalValidRels }; +}; + let db: lbug.Database | null = null; let conn: lbug.Connection | null = null; let currentDbPath: string | null = null; @@ -247,74 +385,21 @@ export const loadGraphToLbug = async ( } // Bulk COPY relationships — split by FROM→TO label pair (LadybugDB requires it) - // Stream-read the relation CSV line by line and write directly to per-pair - // temp files on disk. This avoids accumulating potentially millions of CSV - // lines in memory which could exceed V8 Map or array limits on large repos. - let relHeader = ''; - const relsByPairMeta = new Map(); - const pairWriteStreams = new Map(); - let skippedRels = 0; - let totalValidRels = 0; - - await new Promise((resolve, reject) => { - const rl = createInterface({ - input: createReadStream(csvResult.relCsvPath, 'utf-8'), - crlfDelay: Infinity, - }); - let isFirst = true; - rl.on('line', (line) => { - if (isFirst) { - relHeader = line; - isFirst = false; - return; - } - if (!line.trim()) return; - const match = line.match(/"([^"]*)","([^"]*)"/); - if (!match) { - skippedRels++; - return; - } - const fromLabel = getNodeLabel(match[1]); - const toLabel = getNodeLabel(match[2]); - if (!validTables.has(fromLabel) || !validTables.has(toLabel)) { - skippedRels++; - return; - } - const pairKey = `${fromLabel}|${toLabel}`; - let ws = pairWriteStreams.get(pairKey); - if (!ws) { - const pairCsvPath = path.join(csvDir, `rel_${fromLabel}_${toLabel}.csv`); - ws = createWriteStream(pairCsvPath, 'utf-8'); - ws.write(relHeader + '\n'); - pairWriteStreams.set(pairKey, ws); - relsByPairMeta.set(pairKey, { csvPath: pairCsvPath, rows: 0 }); - } - const ok = ws.write(line + '\n'); - relsByPairMeta.get(pairKey)!.rows++; - totalValidRels++; - // Handle backpressure: pause reading when the write buffer is full, - // resume when the stream drains. Prevents unbounded memory growth - // on repos with millions of relationships. - if (!ok) { - rl.pause(); - ws.once('drain', () => rl.resume()); - } - }); - rl.on('close', resolve); - rl.on('error', (err) => { - // Destroy all open write streams to avoid resource leaks - for (const ws of pairWriteStreams.values()) ws.destroy(); - reject(err); - }); - }); + const { relHeader, relsByPairMeta, pairWriteStreams, skippedRels, totalValidRels } = + await splitRelCsvByLabelPair(csvResult.relCsvPath, csvDir, validTables, getNodeLabel); // Close all per-pair write streams before COPY await Promise.all( Array.from(pairWriteStreams.values()).map( (ws) => - new Promise((resolve, reject) => - ws.end((err: Error | undefined) => (err ? reject(err) : resolve())), - ), + new Promise((resolve, reject) => { + const onError = (err: Error) => reject(err); + ws.on('error', onError); + ws.end(() => { + ws.removeListener('error', onError); + resolve(); + }); + }), ), ); diff --git a/gitnexus/test/unit/rel-csv-split.test.ts b/gitnexus/test/unit/rel-csv-split.test.ts new file mode 100644 index 000000000..dab38a913 --- /dev/null +++ b/gitnexus/test/unit/rel-csv-split.test.ts @@ -0,0 +1,273 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { EventEmitter } from 'events'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { splitRelCsvByLabelPair } from '../../src/core/lbug/lbug-adapter.js'; + +/** + * Regression tests for splitRelCsvByLabelPair (PR #818). + * + * These tests call the real exported function from lbug-adapter.ts with a + * mock WriteStream factory, exercising the actual backpressure, error + * handling, and drain-listener guard without touching LadybugDB. + */ + +// --------------------------------------------------------------------------- +// Mock WriteStream — controllable backpressure + error injection +// --------------------------------------------------------------------------- +class MockWriteStream extends EventEmitter { + public chunks: string[] = []; + public destroyed = false; + public ended = false; + public blocked = false; + public maxDrainListenersSeen = 0; + + write(chunk: string): boolean { + this.chunks.push(chunk); + this._trackDrainListeners(); + return !this.blocked; + } + + end(cb?: (err?: Error) => void): this { + this.ended = true; + if (cb) cb(); + return this; + } + + destroy(): this { + this.destroyed = true; + return this; + } + + unblock(): void { + this.blocked = false; + this.emit('drain'); + } + + triggerError(err: Error): void { + this.emit('error', err); + } + + private _trackDrainListeners(): void { + const count = this.listenerCount('drain'); + if (count > this.maxDrainListenersSeen) { + this.maxDrainListenersSeen = count; + } + } +} + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- +const HEADER = '"from","to","type","confidence","reason","step"'; + +function csvLine(from: string, to: string, type = 'CALLS'): string { + return `"${from}","${to}","${type}",1.0,"auto",0`; +} + +function getNodeLabel(id: string): string { + return id.split(':')[0]; +} + +/** Cast MockWriteStream factory to the real WriteStreamFactory type. */ +function mockFactory(streams: MockWriteStream[], opts?: { blocked?: boolean }) { + return (() => { + const ws = new MockWriteStream(); + if (opts?.blocked) ws.blocked = true; + streams.push(ws); + return ws; + }) as unknown as (filePath: string) => import('fs').WriteStream; +} + +let tmpDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rel-csv-test-')); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function writeCsv(lines: string[]): string { + const csvPath = path.join(tmpDir, 'relations.csv'); + fs.writeFileSync(csvPath, lines.join('\n') + '\n'); + return csvPath; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +describe('splitRelCsvByLabelPair', () => { + const validTables = new Set(['Function', 'Class', 'File', 'Method']); + + it('splits lines into per-pair files with correct row counts', async () => { + const csvPath = writeCsv([ + HEADER, + csvLine('Function:a', 'Class:b'), + csvLine('Function:c', 'Class:d'), + csvLine('File:e', 'Method:f'), + ]); + + const streams: MockWriteStream[] = []; + const result = await splitRelCsvByLabelPair( + csvPath, + tmpDir, + validTables, + getNodeLabel, + mockFactory(streams), + ); + + expect(result.totalValidRels).toBe(3); + expect(result.relsByPairMeta.get('Function|Class')?.rows).toBe(2); + expect(result.relsByPairMeta.get('File|Method')?.rows).toBe(1); + }); + + it('captures the CSV header in relHeader', async () => { + const csvPath = writeCsv([HEADER, csvLine('Function:a', 'Class:b')]); + + const streams: MockWriteStream[] = []; + const result = await splitRelCsvByLabelPair( + csvPath, + tmpDir, + validTables, + getNodeLabel, + mockFactory(streams), + ); + + expect(result.relHeader).toBe(HEADER); + }); + + it('skips lines with unknown labels and counts them', async () => { + const csvPath = writeCsv([ + HEADER, + csvLine('Function:a', 'Class:b'), + csvLine('Unknown:x', 'Class:y'), + csvLine('Function:c', 'Bogus:d'), + ]); + + const streams: MockWriteStream[] = []; + const result = await splitRelCsvByLabelPair( + csvPath, + tmpDir, + validTables, + getNodeLabel, + mockFactory(streams), + ); + + expect(result.totalValidRels).toBe(1); + expect(result.skippedRels).toBe(2); + }); + + it('ignores blank lines without counting them as skipped', async () => { + const csvPath = writeCsv([HEADER, '', csvLine('Function:a', 'Class:b'), '', '']); + + const streams: MockWriteStream[] = []; + const result = await splitRelCsvByLabelPair( + csvPath, + tmpDir, + validTables, + getNodeLabel, + mockFactory(streams), + ); + + expect(result.totalValidRels).toBe(1); + expect(result.skippedRels).toBe(0); + }); + + it('registers at most 1 drain listener per stream under heavy backpressure', async () => { + const lines = [HEADER]; + for (let i = 0; i < 50; i++) { + lines.push(csvLine(`Function:f${i}`, `Class:c${i}`)); + } + const csvPath = writeCsv(lines); + + const streams: MockWriteStream[] = []; + const promise = splitRelCsvByLabelPair( + csvPath, + tmpDir, + validTables, + getNodeLabel, + mockFactory(streams, { blocked: true }), + ); + + // Give readline time to buffer and fire lines + await new Promise((r) => setTimeout(r, 50)); + + // Unblock all streams so the Promise can resolve + for (const ws of streams) ws.unblock(); + await promise; + + // The guard should have kept drain listeners at 1 + for (const ws of streams) { + expect(ws.maxDrainListenersSeen).toBeLessThanOrEqual(1); + } + }); + + it('rejects the Promise when a WriteStream emits an error', async () => { + const csvPath = writeCsv([HEADER, csvLine('Function:a', 'Class:b')]); + + const streams: MockWriteStream[] = []; + const promise = splitRelCsvByLabelPair( + csvPath, + tmpDir, + validTables, + getNodeLabel, + mockFactory(streams, { blocked: true }), + ); + + // Wait for readline to process, then error while paused on drain + await new Promise((r) => setTimeout(r, 50)); + expect(streams.length).toBeGreaterThan(0); + streams[0].triggerError(new Error('disk full')); + + await expect(promise).rejects.toThrow('disk full'); + }); + + it('destroys all streams when one errors (no lingering FDs)', async () => { + const lines = [HEADER]; + for (let i = 0; i < 10; i++) { + lines.push(csvLine(`Function:f${i}`, `Class:c${i}`)); + lines.push(csvLine(`File:e${i}`, `Method:m${i}`)); + } + const csvPath = writeCsv(lines); + + const streams: MockWriteStream[] = []; + const promise = splitRelCsvByLabelPair( + csvPath, + tmpDir, + validTables, + getNodeLabel, + mockFactory(streams, { blocked: true }), + ); + + // Wait for readline to process and create streams + await new Promise((r) => setTimeout(r, 50)); + expect(streams.length).toBeGreaterThanOrEqual(2); + streams[0].triggerError(new Error('EMFILE')); + + await expect(promise).rejects.toThrow('EMFILE'); + + for (const ws of streams) { + expect(ws.destroyed).toBe(true); + } + }); + + it('handles empty CSV (header only) without errors', async () => { + const csvPath = writeCsv([HEADER]); + + const streams: MockWriteStream[] = []; + const result = await splitRelCsvByLabelPair( + csvPath, + tmpDir, + validTables, + getNodeLabel, + mockFactory(streams), + ); + + expect(result.totalValidRels).toBe(0); + expect(result.skippedRels).toBe(0); + expect(result.relHeader).toBe(HEADER); + }); +}); From baf3f9e37d3f05373ef73c273e80021c2fec75f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Tue, 14 Apr 2026 17:32:47 +0100 Subject: [PATCH 35/67] feat(ci): add release-candidate publish pipeline (#825) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ci): add release-candidate publish pipeline Auto-publishes gitnexus@rc on every merge to main. Version scheme is canonical semver X.Y.Z-rc.N where the base is the current npm 'latest' bumped by the 'bump' input (default patch) and N auto-increments by querying existing rc versions on the registry. First rc for a new base is rc.1; the counter resets naturally when the base advances after a stable release. - Reuses ci.yml via workflow_call so tests must pass before publish - SHA-pinned actions, per-job permission scoping, provenance enabled - Guard job dedupes duplicate dispatches against HEAD via v*-rc.* tags - Docs-only pushes skipped via paths-ignore - workflow_dispatch inputs: bump (patch/minor/major), force (override guard) - Publishes under the 'rc' dist-tag so 'latest' is never moved - Tags commits as v and creates GitHub prereleases * fix(ci): address release-candidate review feedback - Sort rc tags by creatordate (handles out-of-order pushes correctly) - Fail fast on npm registry errors; only fall back to package.json on E404 - Drop unused pull-requests: write permission on the reused CI job - Add secrets: inherit so any future CI secrets are available to sub-jobs - Remove unused reltag step output * fix(ci): address Copilot review comments - Correct concurrency comment (runs serialize on same ref, not overlap) - Apply E404-only fallback to 'npm view versions' query, matching the pattern used for the 'npm view version' query - README: clarify that docs-only merges don't trigger rc publish - CONTRIBUTING: drop 'from main' claim for publish.yml; the tag-push trigger does not enforce branch reachability * fix(ci): address adversarial review — idempotency, cycle continuity, tag integrity Codex adversarial review flagged three release-safety issues in the rc pipeline. Fixes: 1. Cycle continuity (H). Non-patch rc trains no longer collapse back to patch on the next push. 'bump' input accepts a new 'auto' value (default) that infers the active rc base from the registry: if any X.Y.Z-rc.* exists with X.Y.Z > latest, continue that base; otherwise patch-bump. Explicit patch/minor/major still forces a cycle reset and now also bypasses the dedup guard so an explicit dispatch on a tagged HEAD is honored. 2. Idempotency across post-publish failures (H). The guard marker ('rc/' lightweight tag) and the release tag ('v' annotated) are now pushed atomically *before* 'npm publish'. A publish failure leaves the marker in place and the guard refuses to re-publish. Added a defensive 'npm view @ version' check before publish to catch registry-level races. Recovery path documented in CONTRIBUTING.md. 3. Tag ↔ package integrity (M). 'v' now points at a detached release commit whose tree contains the rewritten package.json, so the tag's source archive matches the npm tarball exactly. 'main' stays pristine; the release commit is reachable only via the tag. * fix(ci): surface registry errors on defensive version check; drop actions: read - npm view @ version now distinguishes E404 (safe) from network failures (abort) via the same mktemp+grep pattern used for the other two npm view calls - Dropped actions: read on the ci workflow_call — no sub-workflow uses the Actions API --- .github/workflows/release-candidate.yml | 366 ++++++++++++++++++++++++ CONTRIBUTING.md | 39 +++ gitnexus/README.md | 23 ++ 3 files changed, 428 insertions(+) create mode 100644 .github/workflows/release-candidate.yml diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml new file mode 100644 index 000000000..ed9bb1780 --- /dev/null +++ b/.github/workflows/release-candidate.yml @@ -0,0 +1,366 @@ +name: Release Candidate + +on: + # Publish a release-candidate build whenever a merge/commit lands on main. + # Docs/README-only changes are filtered out so prose updates don't + # cut a release. + push: + branches: [main] + paths-ignore: + - '**.md' + - 'docs/**' + - 'LICENSE' + workflow_dispatch: + inputs: + bump: + description: >- + Cycle policy. 'auto' (default) continues the active rc cycle on + this branch if there is one, otherwise bumps patch from latest. + Choose 'patch' / 'minor' / 'major' to explicitly start or reset + an rc cycle. + required: false + default: 'auto' + type: choice + options: + - auto + - patch + - minor + - major + force: + description: 'Publish even when HEAD already has an rc marker' + required: false + default: 'false' + type: choice + options: + - 'false' + - 'true' + +# No workflow-level permissions — scoped per job below. +permissions: {} + +concurrency: + # Serialize all runs on the same ref (push + workflow_dispatch) to prevent + # two publishes racing on the rc counter. Do not cancel an in-progress run + # when a newer one is queued — we want the earlier merge to publish first. + group: release-candidate-${{ github.ref }} + cancel-in-progress: false + +jobs: + # ── Skip when HEAD already has an rc marker (retry / duplicate dispatch) ── + # The marker is a lightweight tag `rc/` pushed *before* `npm + # publish`, so a failed publish leaves the marker in place and the guard + # refuses to re-publish. Recovery path after a partial failure: + # git push --delete origin rc/ v + # then redispatch with force=true. + guard: + name: Check if release candidate should run + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + outputs: + should_run: ${{ steps.decide.outputs.should_run }} + head_sha: ${{ steps.decide.outputs.head_sha }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Decide + id: decide + shell: bash + env: + FORCE: ${{ inputs.force }} + BUMP_INPUT: ${{ inputs.bump }} + EVENT_NAME: ${{ github.event_name }} + run: | + set -euo pipefail + HEAD_SHA=$(git rev-parse HEAD) + echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT" + + if [ "$FORCE" = "true" ]; then + echo "Force flag set — running regardless of marker tag." + echo "should_run=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # An explicit cycle reset on dispatch (bump != auto) also bypasses + # the dedup guard — the maintainer is deliberately asking for a + # new rc from the same commit. + if [ "$EVENT_NAME" = "workflow_dispatch" ] \ + && [ -n "${BUMP_INPUT:-}" ] \ + && [ "${BUMP_INPUT:-auto}" != "auto" ]; then + echo "Explicit bump=$BUMP_INPUT — bypassing marker dedup." + echo "should_run=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Dedup: is there already an rc/ marker pointing at HEAD? + MARKER="rc/${HEAD_SHA}" + if git rev-parse "refs/tags/$MARKER" >/dev/null 2>&1; then + echo "HEAD already has marker $MARKER — skipping." + echo "should_run=false" >> "$GITHUB_OUTPUT" + else + echo "No marker on HEAD — proceeding." + echo "should_run=true" >> "$GITHUB_OUTPUT" + fi + + # ── Reuse the stable CI workflow ───────────────────────────────────── + ci: + needs: guard + if: needs.guard.outputs.should_run == 'true' + uses: ./.github/workflows/ci.yml + permissions: + contents: read + secrets: inherit + + # ── Publish the rc build to npm + create GitHub prerelease ─────────── + publish: + name: Publish release candidate to npm + needs: [guard, ci] + if: needs.guard.outputs.should_run == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: write # push rc tag + marker + id-token: write # npm provenance + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + fetch-tags: true + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + registry-url: https://registry.npmjs.org + cache: npm + cache-dependency-path: gitnexus/package-lock.json + + - name: Build gitnexus-shared + run: npm install && npm run build + working-directory: gitnexus-shared + + - name: Install gitnexus dependencies + run: npm ci + working-directory: gitnexus + + - name: Resolve rc version + id: version + shell: bash + working-directory: gitnexus + env: + BUMP_INPUT: ${{ inputs.bump }} + EVENT_NAME: ${{ github.event_name }} + PKG_NAME: gitnexus + run: | + set -euo pipefail + + # 1. Current published `latest` — the floor for any new rc base. + # Only E404 ("never published") falls back to package.json; any + # other error (network, auth, malformed response) fails fast. + NPM_STDERR_LATEST="$(mktemp)" + if CURRENT_LATEST="$(npm view "$PKG_NAME" version 2>"$NPM_STDERR_LATEST")"; then + : + else + if grep -q 'E404' "$NPM_STDERR_LATEST"; then + CURRENT_LATEST="$(node -p "require('./package.json').version")" + echo "Package not on registry (E404) — seeding from package.json: $CURRENT_LATEST" + else + echo "::error::npm registry unreachable for 'view version':" >&2 + cat "$NPM_STDERR_LATEST" >&2 + rm -f "$NPM_STDERR_LATEST" + exit 1 + fi + fi + rm -f "$NPM_STDERR_LATEST" + CURRENT_LATEST_CLEAN="${CURRENT_LATEST%%-*}" + + # 2. Full version list — needed for the counter and for active-cycle + # inference. Same E404-only fallback. + NPM_STDERR_VERSIONS="$(mktemp)" + if VERSIONS_JSON="$(npm view "$PKG_NAME" versions --json 2>"$NPM_STDERR_VERSIONS")"; then + : + else + if grep -q 'E404' "$NPM_STDERR_VERSIONS"; then + VERSIONS_JSON='[]' + echo "No published versions for $PKG_NAME yet (E404)." + else + echo "::error::npm registry unreachable for 'view versions':" >&2 + cat "$NPM_STDERR_VERSIONS" >&2 + rm -f "$NPM_STDERR_VERSIONS" + exit 1 + fi + fi + rm -f "$NPM_STDERR_VERSIONS" + + # 3. Base selection. + # - workflow_dispatch + bump ∈ {patch,minor,major} → explicit cycle + # reset from latest. + # - Everything else (push, or dispatch with bump=auto) → continue + # the highest active rc base > latest if one exists; else + # default to patch from latest. + if [ "$EVENT_NAME" = "workflow_dispatch" ] \ + && [ -n "${BUMP_INPUT:-}" ] \ + && [ "${BUMP_INPUT:-auto}" != "auto" ]; then + BASE="$(npx --yes -p semver@7 semver -i "$BUMP_INPUT" "$CURRENT_LATEST_CLEAN")" + echo "Explicit bump=$BUMP_INPUT → BASE=$BASE" + else + cat > /tmp/active_base.mjs <<'NODESCRIPT' + const latest = process.env.LATEST; + let v; + try { v = JSON.parse(process.env.VERSIONS_JSON); } catch { v = []; } + if (!Array.isArray(v)) v = [v]; + const parse = s => s.split(".").map(n => parseInt(n, 10)); + const gt = (a, b) => { + const [A, B] = [parse(a), parse(b)]; + for (let i = 0; i < 3; i++) if (A[i] !== B[i]) return A[i] > B[i]; + return false; + }; + const bases = new Set(); + for (const s of v) { + const m = /^(\d+\.\d+\.\d+)-rc\.\d+$/.exec(s); + if (m && gt(m[1], latest)) bases.add(m[1]); + } + if (!bases.size) { process.stdout.write(""); process.exit(0); } + const sorted = [...bases].sort((a, b) => gt(a, b) ? 1 : -1); + process.stdout.write(sorted[sorted.length - 1]); + NODESCRIPT + ACTIVE_BASE="$(LATEST="$CURRENT_LATEST_CLEAN" VERSIONS_JSON="$VERSIONS_JSON" node /tmp/active_base.mjs)" + if [ -n "$ACTIVE_BASE" ]; then + BASE="$ACTIVE_BASE" + echo "Continuing active rc cycle → BASE=$BASE" + else + BASE="$(npx --yes -p semver@7 semver -i patch "$CURRENT_LATEST_CLEAN")" + echo "No active rc cycle → patch bump from latest → BASE=$BASE" + fi + fi + + # 4. Counter: 1 + max existing N for `${BASE}-rc.*`, else 1. + cat > /tmp/next_rc.mjs <<'NODESCRIPT' + const base = process.env.BASE; + const prefix = base + "-rc."; + let v; + try { v = JSON.parse(process.env.VERSIONS_JSON); } catch { v = []; } + if (!Array.isArray(v)) v = [v]; + const ns = v + .filter(s => typeof s === "string" && s.startsWith(prefix)) + .map(s => parseInt(s.slice(prefix.length), 10)) + .filter(n => Number.isInteger(n) && n >= 0); + process.stdout.write(String(ns.length ? Math.max(...ns) + 1 : 1)); + NODESCRIPT + NEXT_N="$(BASE="$BASE" VERSIONS_JSON="$VERSIONS_JSON" node /tmp/next_rc.mjs)" + RC_VERSION="${BASE}-rc.${NEXT_N}" + echo "Computed rc: $RC_VERSION" + + # 5. Defensive: if the exact version already exists on the registry + # (e.g., race with another run), abort before re-publishing. + # Same E404-only pattern used above — a transient network + # failure must fail loudly, not pretend the version is missing. + NPM_STDERR_EXISTS="$(mktemp)" + if npm view "$PKG_NAME@$RC_VERSION" version 2>"$NPM_STDERR_EXISTS" >/dev/null; then + rm -f "$NPM_STDERR_EXISTS" + echo "::error::Version $RC_VERSION already exists on npm — aborting." + exit 1 + else + if grep -qiE 'E404|not found' "$NPM_STDERR_EXISTS"; then + rm -f "$NPM_STDERR_EXISTS" + # Version doesn't exist — safe to proceed. + else + echo "::error::npm registry unreachable for existence check:" >&2 + cat "$NPM_STDERR_EXISTS" >&2 + rm -f "$NPM_STDERR_EXISTS" + exit 1 + fi + fi + + echo "base=$BASE" >> "$GITHUB_OUTPUT" + echo "rc_n=$NEXT_N" >> "$GITHUB_OUTPUT" + echo "rc_version=$RC_VERSION" >> "$GITHUB_OUTPUT" + + - name: Apply rc version in-CI + shell: bash + working-directory: gitnexus + run: | + set -euo pipefail + npm version "${{ steps.version.outputs.rc_version }}" \ + --no-git-tag-version --allow-same-version + + - name: Build gitnexus + run: npm run build + working-directory: gitnexus + + - name: Dry-run publish + run: npm publish --dry-run --tag rc + working-directory: gitnexus + + # ── Acquire the "rc lock" BEFORE publishing (fixes idempotency) ───── + # We create two tags and push them atomically: + # v → annotated tag on a detached release commit + # whose tree contains the rewritten package.json + # (so the tag's source matches the npm tarball) + # rc/ → lightweight tag on HEAD; the guard's dedup key + # If this push fails, nothing is published — safe. + # If this push succeeds but npm publish fails, the marker stays on + # the remote and blocks retries until an operator manually cleans up. + - name: Create and push rc tags + id: reltag + shell: bash + working-directory: gitnexus + env: + RC_VERSION: ${{ steps.version.outputs.rc_version }} + HEAD_SHA: ${{ needs.guard.outputs.head_sha }} + run: | + set -euo pipefail + VTAG="v${RC_VERSION}" + MARKER="rc/${HEAD_SHA}" + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + + # Detached release commit with the version bump — keeps `main` + # pristine but gives the v-tag a tree that matches the published + # package contents exactly (fixes release-integrity gap). + git add package.json package-lock.json 2>/dev/null || git add package.json + git commit -m "release: ${VTAG}" --allow-empty + RELEASE_SHA="$(git rev-parse HEAD)" + echo "Detached release commit: $RELEASE_SHA" + + # Annotated release tag on the release commit. + git tag -a "$VTAG" "$RELEASE_SHA" -m "$VTAG" + # Lightweight marker on the user-visible HEAD for the guard. + git tag "$MARKER" "$HEAD_SHA" + + # Atomic push of both refs. If either would clobber an existing + # remote ref, the push fails and we stop before npm publish. + git push --atomic origin "refs/tags/$VTAG" "refs/tags/$MARKER" + + echo "vtag=$VTAG" >> "$GITHUB_OUTPUT" + echo "marker=$MARKER" >> "$GITHUB_OUTPUT" + echo "release_sha=$RELEASE_SHA" >> "$GITHUB_OUTPUT" + + - name: Publish to npm (rc dist-tag) + run: npm publish --provenance --access public --tag rc + working-directory: gitnexus + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Create GitHub prerelease + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 + with: + tag_name: ${{ steps.reltag.outputs.vtag }} + name: Release Candidate ${{ steps.reltag.outputs.vtag }} + prerelease: true + make_latest: 'false' + generate_release_notes: true + body: | + Automated release candidate build from `main`. + + **npm:** `npm install gitnexus@rc` + **Version:** `${{ steps.version.outputs.rc_version }}` + **Target base:** `${{ steps.version.outputs.base }}` (rc #${{ steps.version.outputs.rc_n }}) + **Source commit (main):** ${{ needs.guard.outputs.head_sha }} + **Release commit (versioned tree):** ${{ steps.reltag.outputs.release_sha }} + + Release candidates are pre-stable builds intended for early testing. + Stable releases remain on the `latest` dist-tag. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 868b2a580..7247750f5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,3 +48,42 @@ Maintainers may request changes for correctness, tests, performance, or consiste ## AI-assisted contributions If you use coding agents, follow project context files (e.g. `AGENTS.md`, `CLAUDE.md`) and avoid drive-by refactors unrelated to the issue. Prefer incremental, test-backed changes. + +## Releases + +Two publish workflows ship `gitnexus` to npm: + +- **Stable** (`.github/workflows/publish.yml`) — triggered by pushing any `v*` + tag. Publishes to the `latest` dist-tag with a changelog-backed GitHub + release. Maintainers are expected to tag from `main` as a convention; the + workflow itself does not enforce branch reachability. +- **Release Candidate** (`.github/workflows/release-candidate.yml`) — runs on + every push to `main` (typically a merged PR) plus manual dispatch. Docs-only + changes are skipped via `paths-ignore`. Publishes to the `rc` dist-tag with + version `X.Y.Z-rc.N` and a GitHub prerelease, where: + - `X.Y.Z` is selected automatically. On push (and on dispatch with + `bump: auto`, the default) the workflow **continues the active rc cycle**: + if the registry already has `X.Y.Z-rc.*` versions with `X.Y.Z` > current + `latest`, it reuses the highest such base; otherwise it patch-bumps + from `latest`. Dispatching with `bump: patch|minor|major` **resets** + the cycle from `latest`. + - `N` is auto-incremented against existing `X.Y.Z-rc.*` entries on the + registry. First rc for a given base is `rc.1`. + + Idempotency: the workflow pushes an `rc/` marker tag and a + `v` release tag **atomically, before** calling `npm publish`. The guard + refuses to re-run once the marker exists, so a post-publish failure will + not mint a duplicate rc for the same commit. The `v` tag points at a + detached release commit whose `package.json` matches the npm tarball + exactly (traceable releases). Recovery after a partial failure: + + ```bash + git push --delete origin rc/ v + # then redispatch the workflow with force: true + ``` + +The rc workflow never moves `latest`. To verify after a change, inspect dist-tags: + +```bash +npm view gitnexus dist-tags +``` diff --git a/gitnexus/README.md b/gitnexus/README.md index 8c7888d66..ed27bf728 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -234,6 +234,29 @@ Installed automatically by both `gitnexus analyze` (per-repo) and `gitnexus setu - Node.js >= 18 - Git repository (uses git for commit tracking) +## Release candidates + +Stable releases publish to the default `latest` dist-tag. When a pull request +with non-documentation changes merges into `main`, an automated workflow also +publishes a prerelease build under the `rc` dist-tag, so early adopters can +try in-flight fixes without waiting for the next stable cut. (Docs-only +merges are skipped.) + +```bash +# Try the latest release candidate (pre-stable — may change at any time) +npm install -g gitnexus@rc +# — or — +npx gitnexus@rc analyze +``` + +Release-candidate versions follow the standard semver prerelease format +`X.Y.Z-rc.N`, where `X.Y.Z` is the next stable target (bumped from the +current `latest` by patch by default; `minor` or `major` when kicking off a +bigger cycle) and `N` increments per published rc. Example sequence: +`1.6.2-rc.1`, `1.6.2-rc.2`, …, then once `1.6.2` ships stable, +`1.6.3-rc.1`. See the [Releases page](https://github.com/abhigyanpatwari/GitNexus/releases) +for the full list; stable `latest` is unaffected. + ## Troubleshooting ### `Cannot destructure property 'package' of 'node.target' as it is null` From c100577e5ed3802c89b3c04fb9b6cbe439ea1f84 Mon Sep 17 00:00:00 2001 From: Jonas Vanderhaegen Date: Wed, 15 Apr 2026 09:05:11 +0200 Subject: [PATCH 36/67] fix(embeddings): prevent batch errors from CodeEmbedding PK violations and vector-index SET restriction (#823) * fix(csv-generator): deduplicate all node types, not just File nodes The pipeline can produce duplicate node IDs across all symbol types (Class, Method, Function, etc.). Only File nodes were guarded by a seenFileIds Set, leaving every other type unprotected. When the CSV was COPY'd into LadybugDB, duplicate PKs caused mass "Batch execution error: Found duplicated primary key value" warnings on gitnexus serve. Replace the per-type seenFileIds with a single seenNodeIds Set checked at the top of the iteration loop, before the switch, so every label is covered by the same O(1) deduplication guard. Fixes: #822 * fix(embeddings): use MERGE instead of CREATE for CodeEmbedding inserts CREATE fails with duplicate PK when a CodeEmbedding node already exists, which happens when: - A PostToolUse hook triggers a concurrent gitnexus analyze during an active analyze run (git commits fire the hook) - A partial prior run left some embeddings in the DB before a crash Switching to MERGE makes the insert idempotent: existing embeddings are updated in place, new ones are created, no PK violations. Fixes: #822 * fix(server): skip already-embedded nodes in POST /api/embed to avoid vector-index SET error Kuzu/LadybugDB forbids SET on a property that is part of a vector index. The /api/embed endpoint was calling runEmbeddingPipeline without skipNodeIds, causing it to attempt MERGE+SET on every node including those already embedded. Fix: query existing CodeEmbedding nodeIds before running the pipeline and pass them as skipNodeIds so only new (unembedded) nodes are processed. * fix(server): narrow catch to table-not-exist errors only in POST /api/embed Bare catch{} would silently swallow connection errors and proceed to re-embed all nodes, hiding infrastructure issues. Now only swallows errors where the CodeEmbedding table does not yet exist. * style: prettier format gitnexus/src/server/api.ts * fix(server): log skip-embedding count and table-not-found swallow path Addresses review feedback on PR #823: - Log count of already-embedded nodes when skipNodeIds is populated (aids debugging if Kuzu driver row shape changes). - Log when the 'table does not exist' swallow path fires so ops can catch it if Kuzu ever changes error wording. - Document the {} config positional argument with an inline comment referencing the runEmbeddingPipeline signature. --------- Co-authored-by: jonasvanderhaegen-xve <> Co-authored-by: Gergo Magyar --- .../src/core/embeddings/embedding-pipeline.ts | 4 +- gitnexus/src/core/lbug/csv-generator.ts | 10 ++- gitnexus/src/core/run-analyze.ts | 2 +- gitnexus/src/server/api.ts | 66 +++++++++++++------ 4 files changed, 57 insertions(+), 25 deletions(-) diff --git a/gitnexus/src/core/embeddings/embedding-pipeline.ts b/gitnexus/src/core/embeddings/embedding-pipeline.ts index d3dc0854e..cb1949144 100644 --- a/gitnexus/src/core/embeddings/embedding-pipeline.ts +++ b/gitnexus/src/core/embeddings/embedding-pipeline.ts @@ -100,8 +100,8 @@ const batchInsertEmbeddings = async ( ) => Promise, updates: Array<{ id: string; embedding: number[] }>, ): Promise => { - // INSERT into separate embedding table - much more memory efficient! - const cypher = `CREATE (e:CodeEmbedding {nodeId: $nodeId, embedding: $embedding})`; + // MERGE instead of CREATE — idempotent, handles concurrent analyzes and partial prior runs + const cypher = `MERGE (e:CodeEmbedding {nodeId: $nodeId}) SET e.embedding = $embedding`; const paramsList = updates.map((u) => ({ nodeId: u.id, embedding: u.embedding })); await executeWithReusedStatement(cypher, paramsList); }; diff --git a/gitnexus/src/core/lbug/csv-generator.ts b/gitnexus/src/core/lbug/csv-generator.ts index b3a53146e..63a1bb947 100644 --- a/gitnexus/src/core/lbug/csv-generator.ts +++ b/gitnexus/src/core/lbug/csv-generator.ts @@ -315,14 +315,18 @@ export const streamAllCSVsToDisk = async ( CodeElement: codeElemWriter, }; - const seenFileIds = new Set(); + // Deduplicate all node types — the pipeline can produce duplicate IDs across + // all symbol types (Class, Method, Function, etc.), not just File nodes. + // A single Set covering every label prevents PK violations on COPY. + const seenNodeIds = new Set(); // --- SINGLE PASS over all nodes --- for (const node of graph.iterNodes()) { + if (seenNodeIds.has(node.id)) continue; + seenNodeIds.add(node.id); + switch (node.label) { case 'File': { - if (seenFileIds.has(node.id)) break; - seenFileIds.add(node.id); const content = await extractContent(node, contentCache); await fileWriter.addRow( [ diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index f7b662705..07fb8ab69 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -222,7 +222,7 @@ export async function runFullAnalysis( const paramsList = batch.map((e) => ({ nodeId: e.nodeId, embedding: e.embedding })); try { await executeWithReusedStatement( - `CREATE (e:CodeEmbedding {nodeId: $nodeId, embedding: $embedding})`, + `MERGE (e:CodeEmbedding {nodeId: $nodeId}) SET e.embedding = $embedding`, paramsList, ); } catch { diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index 3d4cf9a6a..9afdbfe0e 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -1449,25 +1449,53 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => await withLbugDb(lbugPath, async () => { const { runEmbeddingPipeline } = await import('../core/embeddings/embedding-pipeline.js'); - await runEmbeddingPipeline(executeQuery, executeWithReusedStatement, (p) => { - embedJobManager.updateJob(job.id, { - progress: { - phase: - p.phase === 'ready' ? 'complete' : p.phase === 'error' ? 'failed' : p.phase, - percent: p.percent, - message: - p.phase === 'loading-model' - ? 'Loading embedding model...' - : p.phase === 'embedding' - ? `Embedding nodes (${p.percent}%)...` - : p.phase === 'indexing' - ? 'Creating vector index...' - : p.phase === 'ready' - ? 'Embeddings complete' - : `${p.phase} (${p.percent}%)`, - }, - }); - }); + // Skip nodes that already have embeddings — Kuzu forbids SET on vector-indexed properties. + let skipNodeIds: Set | undefined; + try { + const rows = await executeQuery('MATCH (e:CodeEmbedding) RETURN e.nodeId AS nodeId'); + if (rows && rows.length > 0) { + skipNodeIds = new Set(rows.map((r: any) => r.nodeId ?? r[0]).filter(Boolean)); + console.log( + `[embed] ${skipNodeIds.size} nodes already embedded — skipping in incremental run`, + ); + } + } catch (err: any) { + // Swallow only "table does not exist" — let real connection errors propagate. + // Log so ops can see this path fire if Kuzu ever changes error wording. + const msg = err?.message ?? ''; + if (msg.includes('does not exist') || msg.includes('not found')) { + console.log( + `[embed] CodeEmbedding table not yet present — full embedding run (${msg})`, + ); + } else { + throw err; + } + } + await runEmbeddingPipeline( + executeQuery, + executeWithReusedStatement, + (p) => { + embedJobManager.updateJob(job.id, { + progress: { + phase: + p.phase === 'ready' ? 'complete' : p.phase === 'error' ? 'failed' : p.phase, + percent: p.percent, + message: + p.phase === 'loading-model' + ? 'Loading embedding model...' + : p.phase === 'embedding' + ? `Embedding nodes (${p.percent}%)...` + : p.phase === 'indexing' + ? 'Creating vector index...' + : p.phase === 'ready' + ? 'Embeddings complete' + : `${p.phase} (${p.percent}%)`, + }, + }); + }, + {}, // config: use defaults (runEmbeddingPipeline signature: executeQuery, executeWithReusedStatement, onProgress, config, skipNodeIds) + skipNodeIds, + ); }); clearTimeout(embedTimeout); From 28ddbe5d5439352b30f51eadac76bc10c7e7208f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Wed, 15 Apr 2026 08:59:09 +0100 Subject: [PATCH 37/67] fix(lbug): wait for read stream close in splitRelCsvByLabelPair (Windows ENOTEMPTY) (#832) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(lbug): wait for read stream close in splitRelCsvByLabelPair (Windows ENOTEMPTY) The windows-latest CI job intermittently failed: FAIL test/unit/rel-csv-split.test.ts > splitRelCsvByLabelPair > handles empty CSV (header only) without errors Error: ENOTEMPTY: directory not empty, rmdir 'C:\Users\RUNNER~1\AppData\Local\Temp\rel-csv-test-XW5KOu' Cause: splitRelCsvByLabelPair resolved its Promise on readline's 'close' event, but the underlying fs.ReadStream's file descriptor is released asynchronously after that — especially on Windows. For the empty-CSV test the function returns so quickly that afterEach fires rmSync while the relations.csv fd is still held, so Windows reports ENOTEMPTY on the directory. Fixes: - Production: after readline 'close', wait for inputStream 'close' (or resolve immediately if already closed/destroyed). Call inputStream .destroy() defensively so we never hang if the fd never emits 'close'. - Test: afterEach now retries rmSync up to 5 times on ENOTEMPTY/EBUSY/ EPERM with a brief back-off — defense-in-depth so the test doesn't flake on slow CI runners independent of the production change. The production fix benefits every caller, not just the test: any code that deletes the CSV's parent directory right after the Promise resolves previously hit the same race on Windows. * refactor(lbug): replace custom stream state machines with stdlib primitives Full audit of splitRelCsvByLabelPair's stream usage after the original ENOTEMPTY fix. Replaced three hand-rolled mechanisms with their standard-library equivalents — 147 -> 71 lines in the function, and the caller's WriteStream closure dropped from 13 lines to 5. - readline: 'on(line)' + pause/resume/waitingForDrain state machine -> 'for await (const line of rl)'. Async-iterator delivery naturally serializes line processing with our awaits, so at most one ws is in backpressure at a time. We just 'await once(ws, "drain")' when 'write()' returns false — the custom Set, the settled flag and the 'only resume when all streams have drained' logic all go away. - Multi-stream error coordination: hand-rolled cleanup() that had to be entered exactly once and had to destroy the inputStream and every pair ws -> single AbortController shared across every 'once(ws, 'drain', { signal })'. Any stream error aborts every pending wait. - 'stream/promises.finished(inputStream)' in the 'finally' block replaces the manual 'rl.on('close', () => inputStream.once('close', ...))' dance, and covers both the success and error paths with the same primitive. This closes the Windows ENOTEMPTY race root cause — we never return while the fd might still be in flight. - Caller closure: 'new Promise((res, rej) => ws.end(cb) + remove listener on error)' -> 'ws.end(); await finished(ws)'. - Test 'afterEach': custom retry loop -> 'fs.rmSync(..., { maxRetries: 5, retryDelay: 50 })' (Node added these options specifically for cross-platform tmpdir cleanup). - Test 'destroys all streams when one errors': old code leaked backpressure and created multiple pair streams before the first blocked; new strict serial backpressure doesn't, so the test now unblocks the first stream once to advance the loop and create the second stream before triggering the error. --- gitnexus/src/core/lbug/lbug-adapter.ts | 134 ++++++++++------------- gitnexus/test/unit/rel-csv-split.test.ts | 16 ++- 2 files changed, 70 insertions(+), 80 deletions(-) diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index fba92465c..0298d5f7c 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -1,6 +1,8 @@ import fs from 'fs/promises'; import { createReadStream, createWriteStream } from 'fs'; import { createInterface } from 'readline'; +import { once } from 'events'; +import { finished } from 'stream/promises'; import path from 'path'; import lbug from '@ladybugdb/core'; import { KnowledgeGraph } from '../graph/types.js'; @@ -56,97 +58,80 @@ export const splitRelCsvByLabelPair = async ( let skippedRels = 0; let totalValidRels = 0; - await new Promise((resolve, reject) => { - const inputStream = createReadStream(csvPath, 'utf-8'); - const rl = createInterface({ - input: inputStream, - crlfDelay: Infinity, - }); + const inputStream = createReadStream(csvPath, 'utf-8'); + const rl = createInterface({ input: inputStream, crlfDelay: Infinity }); - // Track which streams are already waiting for drain to prevent - // listener accumulation. rl.pause() is not synchronous — buffered - // line events continue firing after pause(), and without this guard - // each line targeting the same pairKey would add another drain listener. - const waitingForDrain = new Set(); - - let settled = false; - const cleanup = (err: Error) => { - if (settled) return; - settled = true; - try { - rl.close(); - } catch {} - try { - inputStream.destroy(); - } catch {} - for (const ws of pairWriteStreams.values()) { - try { - ws.destroy(); - } catch {} - } - reject(err); - }; + // If any pair WriteStream errors (disk full, EMFILE, etc.) or the input + // stream fails, we need to abort the pending `once(ws, 'drain')` await. + // An AbortController gives us one signal to cancel all pending waits + // without a custom state machine. + const abortOnError = new AbortController(); + let streamError: Error | null = null; + const markStreamError = (err: Error): void => { + streamError ??= err; + abortOnError.abort(err); + }; + try { + // `for await (const line of rl)` replaces the old manual + // on('line')/pause()/resume()/waitingForDrain state machine: readline's + // async iterator naturally serializes line delivery with our awaits, so + // at most one ws can be in backpressure at a time and we just await its + // 'drain' event. let isFirst = true; - rl.on('line', (line) => { + for await (const line of rl) { + if (streamError) throw streamError; if (isFirst) { relHeader = line; isFirst = false; - return; + continue; } - if (!line.trim()) return; + if (!line.trim()) continue; const match = line.match(/"([^"]*)","([^"]*)"/); if (!match) { skippedRels++; - return; + continue; } const fromLabel = getNodeLabel(match[1]); const toLabel = getNodeLabel(match[2]); if (!validTables.has(fromLabel) || !validTables.has(toLabel)) { skippedRels++; - return; + continue; } + const pairKey = `${fromLabel}|${toLabel}`; let ws = pairWriteStreams.get(pairKey); if (!ws) { const pairCsvPath = path.join(csvDir, `rel_${fromLabel}_${toLabel}.csv`); ws = wsFactory(pairCsvPath); - // If any per-pair WriteStream errors (disk full, EMFILE, etc.), - // tear down everything and reject the Promise. Without this handler, - // a stream error while rl is paused waiting for drain would cause - // the drain callback to never fire and the Promise to hang forever. - ws.on('error', cleanup); - ws.write(relHeader + '\n'); + ws.on('error', markStreamError); pairWriteStreams.set(pairKey, ws); relsByPairMeta.set(pairKey, { csvPath: pairCsvPath, rows: 0 }); + if (!ws.write(relHeader + '\n')) { + await once(ws, 'drain', { signal: abortOnError.signal }); + } + } + + if (!ws.write(line + '\n')) { + await once(ws, 'drain', { signal: abortOnError.signal }); } - const ok = ws.write(line + '\n'); relsByPairMeta.get(pairKey)!.rows++; totalValidRels++; - // Handle backpressure: pause reading when the write buffer is full, - // resume when the stream drains. Prevents unbounded memory growth - // on repos with millions of relationships. - // Guard with waitingForDrain to ensure only one drain listener is - // registered per stream at a time — rl.pause() doesn't stop buffered - // line events immediately. Only resume when ALL streams have drained - // to avoid writing into still-full streams. - if (!ok && !waitingForDrain.has(pairKey)) { - waitingForDrain.add(pairKey); - rl.pause(); - ws.once('drain', () => { - waitingForDrain.delete(pairKey); - if (waitingForDrain.size === 0) rl.resume(); - }); - } - }); - rl.on('close', () => { - if (!settled) { - settled = true; - resolve(); - } - }); - rl.on('error', cleanup); - }); + } + if (streamError) throw streamError; + } catch (err) { + // Tear down everything so no fd is left dangling. If the abort was caused + // by a stream error, rethrow that error (more actionable than AbortError). + for (const ws of pairWriteStreams.values()) ws.destroy(); + inputStream.destroy(); + throw streamError ?? err; + } finally { + // Readline 'close' fires before the underlying fs.ReadStream releases its + // fd — on Windows that race caused ENOTEMPTY on the parent dir. + // stream/promises.finished is the stdlib "wait until this stream is fully + // closed" primitive and handles both success and error paths. + await finished(inputStream).catch(() => {}); + } return { relHeader, relsByPairMeta, pairWriteStreams, skippedRels, totalValidRels }; }; @@ -388,19 +373,14 @@ export const loadGraphToLbug = async ( const { relHeader, relsByPairMeta, pairWriteStreams, skippedRels, totalValidRels } = await splitRelCsvByLabelPair(csvResult.relCsvPath, csvDir, validTables, getNodeLabel); - // Close all per-pair write streams before COPY + // Close all per-pair write streams before COPY. `stream/promises.finished` + // resolves on the stream's 'finish' event and rejects on 'error' — replaces + // a hand-rolled promisification with the stdlib primitive. await Promise.all( - Array.from(pairWriteStreams.values()).map( - (ws) => - new Promise((resolve, reject) => { - const onError = (err: Error) => reject(err); - ws.on('error', onError); - ws.end(() => { - ws.removeListener('error', onError); - resolve(); - }); - }), - ), + Array.from(pairWriteStreams.values()).map(async (ws) => { + ws.end(); + await finished(ws); + }), ); const insertedRels = totalValidRels; diff --git a/gitnexus/test/unit/rel-csv-split.test.ts b/gitnexus/test/unit/rel-csv-split.test.ts index dab38a913..5b0a81199 100644 --- a/gitnexus/test/unit/rel-csv-split.test.ts +++ b/gitnexus/test/unit/rel-csv-split.test.ts @@ -87,7 +87,12 @@ beforeEach(() => { }); afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); + // fs.rmSync's built-in retry loop handles Windows EBUSY/ENOTEMPTY/EPERM + // when a just-closed fd hasn't been released yet (Node added this exactly + // for cross-platform tmpdir cleanup — see Node.js fs docs). The production + // function also waits for the input stream's 'close' event, so this is + // defense-in-depth. + fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); }); function writeCsv(lines: string[]): string { @@ -242,8 +247,13 @@ describe('splitRelCsvByLabelPair', () => { mockFactory(streams, { blocked: true }), ); - // Wait for readline to process and create streams - await new Promise((r) => setTimeout(r, 50)); + // The first pair stream is created immediately and blocks on its header + // write. Unblock it once so the loop advances and creates the second + // pair stream (also blocked). Now both streams exist — trigger the error. + await new Promise((r) => setTimeout(r, 20)); + expect(streams.length).toBe(1); + streams[0].unblock(); + await new Promise((r) => setTimeout(r, 20)); expect(streams.length).toBeGreaterThanOrEqual(2); streams[0].triggerError(new Error('EMFILE')); From 385ee037bd23a96849588686471dc9c991dd93cb Mon Sep 17 00:00:00 2001 From: Jonas Vanderhaegen Date: Wed, 15 Apr 2026 10:14:09 +0200 Subject: [PATCH 38/67] =?UTF-8?q?[group/sync]=20Fix=20ManifestExtractor=20?= =?UTF-8?q?never=20called=20=E2=80=94=20config.links=20always=20produced?= =?UTF-8?q?=200=20cross-links=20(#827)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(group/sync): wire ManifestExtractor into syncGroup pipeline ManifestExtractor was fully implemented in extractors/manifest-extractor.ts but never imported or called in sync.ts. As a result, any links declared in group.yaml were parsed and validated by config-parser.ts but silently dropped — config.links was always an empty dead-end as far as syncGroup was concerned. Changes: - Import ManifestExtractor in sync.ts - Call extractFromManifest(config.links, dbExecutors) inside the outer try block, after all repos are processed but before the finally closes the DB pools (symbol resolution via resolveSymbol requires open executors) - Collect the resulting contracts into autoContracts and the cross-links into a separate manifestCrossLinks array - Merge manifestCrossLinks into the final crossLinks alongside runExactMatch results Without this fix, users who declare explicit service dependencies in group.yaml links (the documented workaround for HTTP clients that use absolute URLs and are invisible to the auto-extractors) get 0 cross-links regardless of what they configure. * test(group/sync): cover manifest links producing cross-links Add a unit test that asserts config.links entries produce contract pairs and a manifest cross-link (matchType: 'manifest') via syncGroup. Also refactors the manifest extraction call to sit outside the else/try block so it runs regardless of extractorOverride arity — makes the code testable without mocked DB pools and ensures links work when callers supply a zero-arity override (e.g. in tests or programmatic usage). * style: prettier format sync.ts and sync.test.ts Also removes the stray empty line in the finally block (noted in review). * fix(group/sync): dedupe cross-links and warn on dangling manifest repos Addresses review feedback on PR #827: 1. Dedupe cross-links. Manifest contracts participate in runExactMatch, so a manifest-declared link also emitted a duplicate matchType:'exact' CrossLink for the same endpoint pair. Dedupe by (from, to, type, contractId) and prefer manifest (operator-declared intent). 2. Warn on dangling repos. When a manifest link references a repo not in config.repos, log a warning. Synthetic UIDs keep the cross-link deterministic, but the operator probably meant something else. 3. Tests: - Assert no duplicate 'exact' CrossLink is emitted alongside the manifest one. - Assert synthetic UID format when no DB executors are available. - New test: dangling manifest repo still produces a cross-link + logs a warning. * perf(group/manifest): parallelize and memoize symbol resolution Previous implementation ran 2N sequential Cypher round-trips per manifest (one for provider side, one for consumer, awaited in-order per link). For manifests with tens of links this dominated syncGroup latency in groups with many declared cross-repo contracts. Changes: - Resolve provider + consumer in parallel per link (Promise.all). - Resolve all links in parallel (outer Promise.all over links.map). Each repo's executor pool is independent, so cross-repo fan-out scales with the number of distinct repos in the manifest. - Memoize by (repo, type, contract). Manifests frequently declare the same contract from both directions or across sibling groups, so duplicate triples now hit the DB once instead of 2× per link. Correctness: - resolveSymbol is a pure LIMIT 1 read, so caching + concurrent invocation is safe. - Iteration order over links is preserved in the final contracts / crossLinks arrays — result shape is identical. Test: - New test asserts that two links sharing (repo, type, contract) produce exactly one DB call per distinct repo-tuple. --------- Co-authored-by: jonasvanderhaegen-xve <> Co-authored-by: Gergo Magyar --- .../group/extractors/manifest-extractor.ts | 49 ++++++-- gitnexus/src/core/group/sync.ts | 57 ++++++++- .../unit/group/manifest-extractor.test.ts | 30 +++++ gitnexus/test/unit/group/sync.test.ts | 111 +++++++++++++++++- 4 files changed, 237 insertions(+), 10 deletions(-) diff --git a/gitnexus/src/core/group/extractors/manifest-extractor.ts b/gitnexus/src/core/group/extractors/manifest-extractor.ts index 4c0d737b7..83f5cab5e 100644 --- a/gitnexus/src/core/group/extractors/manifest-extractor.ts +++ b/gitnexus/src/core/group/extractors/manifest-extractor.ts @@ -79,17 +79,50 @@ export class ManifestExtractor { links: GroupManifestLink[], dbExecutors?: Map, ): Promise { + // Resolve all (repo, link) pairs in parallel. The previous sequential + // await-per-link produced 2N round-trips; parallel resolution uses the + // per-repo executor pool directly and scales linearly with manifest size. + // + // Memoization: a manifest can list the same contract multiple times + // (e.g. a consumer and provider declaration, or cross-referenced groups). + // Key on (repo, type, contract) — the canonical input to the Cypher + // query — so duplicate links resolve to one DB hit. + type ResolvedSymbol = { filePath: string; name: string; uid: string } | null; + const resolveCache = new Map>(); + const resolveOnce = (repo: string, link: GroupManifestLink): Promise => { + const key = `${repo}\u0000${link.type}\u0000${link.contract}`; + let pending = resolveCache.get(key); + if (!pending) { + pending = this.resolveSymbol(repo, link, dbExecutors); + resolveCache.set(key, pending); + } + return pending; + }; + + const perLink = await Promise.all( + links.map(async (link) => { + const contractId = this.buildContractId(link.type, link.contract); + const providerRepo = link.role === 'provider' ? link.from : link.to; + const consumerRepo = link.role === 'provider' ? link.to : link.from; + const [providerSymbol, consumerSymbol] = await Promise.all([ + resolveOnce(providerRepo, link), + resolveOnce(consumerRepo, link), + ]); + return { link, contractId, providerRepo, consumerRepo, providerSymbol, consumerSymbol }; + }), + ); + const contracts: StoredContract[] = []; const crossLinks: CrossLink[] = []; - for (const link of links) { - const contractId = this.buildContractId(link.type, link.contract); - - const providerRepo = link.role === 'provider' ? link.from : link.to; - const consumerRepo = link.role === 'provider' ? link.to : link.from; - - const providerSymbol = await this.resolveSymbol(providerRepo, link, dbExecutors); - const consumerSymbol = await this.resolveSymbol(consumerRepo, link, dbExecutors); + for (const { + link, + contractId, + providerRepo, + consumerRepo, + providerSymbol, + consumerSymbol, + } of perLink) { const providerRef = providerSymbol || { filePath: '', name: link.contract }; const consumerRef = consumerSymbol || { filePath: '', name: link.contract }; // When the resolver finds a real graph symbol we keep its uid, otherwise diff --git a/gitnexus/src/core/group/sync.ts b/gitnexus/src/core/group/sync.ts index 92cd9fe5f..af7c3e686 100644 --- a/gitnexus/src/core/group/sync.ts +++ b/gitnexus/src/core/group/sync.ts @@ -7,6 +7,7 @@ import type { GroupConfig, RepoHandle, RepoSnapshot, StoredContract, CrossLink } import { HttpRouteExtractor } from './extractors/http-route-extractor.js'; import { GrpcExtractor } from './extractors/grpc-extractor.js'; import { TopicExtractor } from './extractors/topic-extractor.js'; +import { ManifestExtractor } from './extractors/manifest-extractor.js'; import { runExactMatch } from './matching.js'; import { detectServiceBoundaries, assignService } from './service-boundary-detector.js'; import type { CypherExecutor } from './contract-extractor.js'; @@ -60,10 +61,28 @@ function defaultResolveHandle(allEntries: RegistryEntry[]) { }; } +/** + * Dedupe cross-links that point from the same consumer endpoint to the same + * provider endpoint for the same contract. Preserves first-seen order so the + * caller controls precedence (e.g., pass manifest links first). + */ +function dedupeCrossLinks(links: CrossLink[]): CrossLink[] { + const seen = new Set(); + const out: CrossLink[] = []; + for (const link of links) { + const key = `${link.from.repo}::${link.from.symbolUid}|${link.to.repo}::${link.to.symbolUid}|${link.type}|${link.contractId}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(link); + } + return out; +} + export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promise { const missingRepos: string[] = []; const repoSnapshots: Record = {}; let autoContracts: StoredContract[] = []; + let manifestCrossLinks: CrossLink[] = []; let dbExecutors: Map | undefined; const eo = opts?.extractorOverride; @@ -158,8 +177,44 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis } } + // Process manifest links declared in group.yaml. + // ManifestExtractor is fully implemented but was never wired into this + // pipeline — config.links were parsed and validated but silently dropped. + // Placed after the DB try/finally: resolveSymbol falls back to synthetic + // UIDs when dbExecutors is undefined or a pool is closed, so cross-links + // are always generated regardless of whether real DB executors are available. + if (config.links.length > 0) { + // Warn about dangling links that reference repos not declared in config.repos. + // They still generate cross-links via synthetic UIDs (determinism is preserved), + // but the operator probably meant something that now silently does nothing useful. + const knownRepos = new Set(Object.keys(config.repos)); + for (const link of config.links) { + const dangling = [link.from, link.to].filter((r) => !knownRepos.has(r)); + if (dangling.length > 0) { + console.warn( + `[group/sync] manifest link ${link.type}:${link.contract} references repos not in config.repos: ${dangling.join(', ')} — cross-links will use synthetic UIDs`, + ); + } + } + + const manifestEx = new ManifestExtractor(); + const manifestResult = await manifestEx.extractFromManifest(config.links, dbExecutors); + autoContracts.push(...manifestResult.contracts); + manifestCrossLinks = manifestResult.crossLinks; + if (opts?.verbose) { + console.log( + ` manifest: ${manifestCrossLinks.length} cross-links from ${config.links.length} declared links`, + ); + } + } + const { matched, unmatched } = runExactMatch(autoContracts); - const crossLinks: CrossLink[] = matched; + + // Dedupe cross-links. Manifest contracts participate in runExactMatch, so a + // manifest-declared link can also emit a matchType:'exact' CrossLink with the + // same endpoints. Prefer the manifest version — it reflects operator intent + // and carries matchType:'manifest' which downstream consumers may rely on. + const crossLinks = dedupeCrossLinks([...manifestCrossLinks, ...matched]); const allContracts: StoredContract[] = autoContracts; const registry: ContractRegistry = { diff --git a/gitnexus/test/unit/group/manifest-extractor.test.ts b/gitnexus/test/unit/group/manifest-extractor.test.ts index 42ed86b84..59725dc86 100644 --- a/gitnexus/test/unit/group/manifest-extractor.test.ts +++ b/gitnexus/test/unit/group/manifest-extractor.test.ts @@ -583,4 +583,34 @@ describe('ManifestExtractor', () => { expect(result.contracts).toHaveLength(0); expect(result.crossLinks).toHaveLength(0); }); + + it('memoizes repeated (repo, type, contract) resolutions so each tuple hits the DB once', async () => { + const calls: Array<{ repo: string; cypher: string }> = []; + const execFor = (repo: string) => async (cypher: string) => { + calls.push({ repo, cypher }); + return [{ uid: `uid::${repo}`, name: 'handler', filePath: 'src/h.ts' }]; + }; + + const dbExecutors = new Map Promise[]>>([ + ['svc/a', execFor('svc/a')], + ['svc/b', execFor('svc/b')], + ]); + + // Two links declare the same (repo, type, contract) triple on each side, + // so naive sequential resolution would run 4 queries; memoization collapses + // to 2 (one per distinct repo tuple). + const link: GroupManifestLink = { + from: 'svc/b', + to: 'svc/a', + type: 'http', + contract: 'GET::/api/orders', + role: 'consumer', + }; + + await extractor.extractFromManifest([link, { ...link }], dbExecutors); + + // One resolution per distinct (repo, type, contract) — not per (link × side). + expect(calls).toHaveLength(2); + expect(new Set(calls.map((c) => c.repo))).toEqual(new Set(['svc/a', 'svc/b'])); + }); }); diff --git a/gitnexus/test/unit/group/sync.test.ts b/gitnexus/test/unit/group/sync.test.ts index 50c9093b9..5aa586c25 100644 --- a/gitnexus/test/unit/group/sync.test.ts +++ b/gitnexus/test/unit/group/sync.test.ts @@ -3,7 +3,12 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { syncGroup, stableRepoPoolId } from '../../../src/core/group/sync.js'; -import type { GroupConfig, StoredContract, RepoHandle } from '../../../src/core/group/types.js'; +import type { + GroupConfig, + StoredContract, + RepoHandle, + GroupManifestLink, +} from '../../../src/core/group/types.js'; import type { RegistryEntry } from '../../../src/storage/repo-manager.js'; describe('syncGroup', () => { @@ -202,6 +207,110 @@ describe('syncGroup', () => { } }); + it('manifest links in config.links produce cross-links with matchType manifest', async () => { + const links: GroupManifestLink[] = [ + { + from: 'app/consumer', + to: 'app/provider', + type: 'http', + contract: 'GET::/api/orders', + role: 'consumer', + }, + ]; + + const config: GroupConfig = { + version: 1, + name: 'test', + description: '', + repos: { 'app/consumer': 'consumer-repo', 'app/provider': 'provider-repo' }, + links, + packages: {}, + detect: { + http: true, + grpc: false, + topics: false, + shared_libs: false, + embedding_fallback: false, + }, + matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, + }; + + const result = await syncGroup(config, { + extractorOverride: async () => [], + skipWrite: true, + }); + + // ManifestExtractor should inject 2 contracts (provider + consumer) and 1 cross-link + expect(result.contracts).toHaveLength(2); + const manifestLinks = result.crossLinks.filter((cl) => cl.matchType === 'manifest'); + expect(manifestLinks).toHaveLength(1); + expect(manifestLinks[0].contractId).toBe('http::GET::/api/orders'); + expect(manifestLinks[0].from.repo).toBe('app/consumer'); + expect(manifestLinks[0].to.repo).toBe('app/provider'); + expect(manifestLinks[0].confidence).toBe(1.0); + + // With no DB executors available, UIDs fall back to the deterministic + // synthetic form `manifest::::`. + expect(manifestLinks[0].from.symbolUid).toBe('manifest::app/consumer::http::GET::/api/orders'); + expect(manifestLinks[0].to.symbolUid).toBe('manifest::app/provider::http::GET::/api/orders'); + + // Manifest contracts also participate in runExactMatch; we must not emit a + // duplicate matchType:'exact' cross-link for the same endpoint pair. + const exactForSameContract = result.crossLinks.filter( + (cl) => cl.matchType === 'exact' && cl.contractId === 'http::GET::/api/orders', + ); + expect(exactForSameContract).toHaveLength(0); + expect(result.crossLinks).toHaveLength(1); + }); + + it('manifest links referencing unknown repos still produce cross-links via synthetic UIDs', async () => { + const links: GroupManifestLink[] = [ + { + from: 'app/known', + to: 'app/dangling', // not present in config.repos + type: 'http', + contract: 'POST::/api/missing', + role: 'consumer', + }, + ]; + + const config: GroupConfig = { + version: 1, + name: 'test', + description: '', + repos: { 'app/known': 'known-repo' }, + links, + packages: {}, + detect: { + http: true, + grpc: false, + topics: false, + shared_libs: false, + embedding_fallback: false, + }, + matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, + }; + + const warnings: string[] = []; + const origWarn = console.warn; + console.warn = (msg: string) => warnings.push(String(msg)); + try { + const result = await syncGroup(config, { + extractorOverride: async () => [], + skipWrite: true, + }); + + expect(result.crossLinks).toHaveLength(1); + expect(result.crossLinks[0].matchType).toBe('manifest'); + expect(result.crossLinks[0].to.symbolUid).toBe( + 'manifest::app/dangling::http::POST::/api/missing', + ); + expect(warnings.some((w) => w.includes('app/dangling'))).toBe(true); + } finally { + console.warn = origWarn; + } + }); + it('writes registry to groupDir when skipWrite is false', async () => { const tmpDir = path.join(os.tmpdir(), `gitnexus-sync-write-${Date.now()}`); fs.mkdirSync(tmpDir, { recursive: true }); From 32c9ddaf32c54be4c84568ed7b4bda0331d41927 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 10:41:28 +0100 Subject: [PATCH 39/67] fix(deps): pin tree-sitter-c-sharp to 0.23.1 (#834) * Initial plan * fix: pin tree-sitter-c-sharp to 0.23.1 to resolve peer dependency conflict with tree-sitter@0.21.1 Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/fc416867-7239-4840-9b67-c681d00fa231 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --- gitnexus/package-lock.json | 2 +- gitnexus/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index bc4fc7b20..bf8b5bb35 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -30,7 +30,7 @@ "pandemonium": "^2.4.0", "tree-sitter": "^0.21.1", "tree-sitter-c": "0.23.2", - "tree-sitter-c-sharp": "^0.23.1", + "tree-sitter-c-sharp": "0.23.1", "tree-sitter-cpp": "^0.23.4", "tree-sitter-go": "^0.23.0", "tree-sitter-java": "^0.23.5", diff --git a/gitnexus/package.json b/gitnexus/package.json index 571a341c5..6448e716a 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -71,7 +71,7 @@ "pandemonium": "^2.4.0", "tree-sitter": "^0.21.1", "tree-sitter-c": "0.23.2", - "tree-sitter-c-sharp": "^0.23.1", + "tree-sitter-c-sharp": "0.23.1", "tree-sitter-cpp": "^0.23.4", "tree-sitter-go": "^0.23.0", "tree-sitter-java": "^0.23.5", From 1df79c2eab4935682f92027fe359dcba07bcfc8d Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:20:48 +0100 Subject: [PATCH 40/67] fix: content-hash staleness detection for embeddings and vector index creation on zero-node path (#831) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * fix: stale vectors preserved on content edits and vector index missing after zero-node run Issue 1: Add contentHash to EMBEDDING_SCHEMA and embedding pipeline. - contentHash column persisted per CodeEmbedding row - POST /api/embed queries nodeId+contentHash, compares per-node hash - Stale rows (hash mismatch) are DELETE'd before re-embedding - Legacy DBs without contentHash treated as stale (full re-embed) - loadCachedEmbeddings and run-analyze cache restore include contentHash Issue 2: createVectorIndex called unconditionally before zero-node early return. Regression tests: - contentHashForNode determinism and content-change detection - EMBEDDING_SCHEMA includes contentHash STRING column - Pipeline exports verified Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/1581c0c0-f359-4376-b47e-62d24a28fd2d Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: use parameterized query for stale embedding DELETE, revert package-lock.json Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/1581c0c0-f359-4376-b47e-62d24a28fd2d Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: address review feedback — config consistency, narrow catches, extract DB logic Bug #1: Use finalConfig consistently in contentHashForNode (line 224 was using raw `config` while line 307 used `finalConfig`). Cache precomputed hashes in filter phase to avoid double computation (Perf #5). Bug #2: Narrow catch in loadCachedEmbeddings to only fall back on column/table-missing errors. Rethrow transient/connection errors. Bug #3: Log non-trivial DELETE failures instead of silently swallowing. Arch Violation #3: Extract fetchExistingEmbeddingHashes from api.ts into lbug-adapter.ts. Server layer now calls a single adapter function instead of re-implementing the DB query logic with nested try-catch. Tests: Add config consistency test, note that fetchExistingEmbeddingHashes tests require native module (run in CI). Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b8c4f6b0-4095-4507-a15d-d8469793efac Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: narrow Column error match to 'contentHash' in lbug-adapter fallback checks Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b8c4f6b0-4095-4507-a15d-d8469793efac Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: address production-readiness review — eliminate competing state, use schema constants, hard-fail on stale DELETE, add incremental filter tests Gap A / Arch Violation 1: Remove duplicate vectorExtensionLoaded flag from embedding-pipeline.ts — delegate to lbug-adapter's loadVectorExtension() which owns the VECTOR extension lifecycle and resets on DB reconnect. Arch Violation 2: Replace all hardcoded 'CodeEmbedding' and 'code_embedding_idx' strings in embedding-pipeline.ts and run-analyze.ts with EMBEDDING_TABLE_NAME, EMBEDDING_INDEX_NAME, and CREATE_VECTOR_INDEX_QUERY imported from schema.ts. Add EMBEDDING_INDEX_NAME export to schema.ts. Gap B: Make DELETE failure for stale vectors a hard throw (not just a warning). Continuing after failed DELETE risks Kuzu vector-index corruption since the constraint requires DELETE-before-INSERT for vector-indexed properties. "not found" / "does not exist" errors are still safe to ignore. STALE_HASH_SENTINEL: Define a named constant in embedding types.ts for the empty-string sentinel convention. Used consistently in lbug-adapter.ts and run-analyze.ts so the invariant is self-documenting. Tests: Add comprehensive unit tests for the incremental filter logic with mocked embedder: - New node → embedded - Unchanged node (hash matches) → skipped - Stale node (hash mismatch) → DELETE + re-embed - STALE_HASH_SENTINEL → treated as stale - Zero nodes after filter → createVectorIndex still called - DELETE failure with non-trivial error → throws Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b21edee7-c9c5-4742-947b-d0def4fb26aa Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: tighten error classification — extract isMissingColumnOrTableError helper, remove broad pattern matching - Extract isMissingColumnOrTableError() helper in lbug-adapter for consistent schema-error detection (replaces duplicate inline checks) - Tighten 'contentHash' match: now requires 'property' AND 'contentHash' (Kuzu-specific pattern) instead of broad 'contentHash' substring - Tighten DELETE error check: only ignore 'does not exist' (Kuzu's actual message), not broad 'not found' which could mask connection errors - Fix test node ID/name/filePath consistency Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b21edee7-c9c5-4742-947b-d0def4fb26aa Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: CI failures and final review — move STALE_HASH_SENTINEL to schema, tighten error matching, fix test mocking, format - Move STALE_HASH_SENTINEL from embeddings/types.ts to lbug/schema.ts (fixes inverted layer dependency: lbug should not import from embeddings) - Tighten isMissingColumnOrTableError: replace broad msg.includes('not found') with /(table|column|property).*not found/i regex to avoid matching transient errors - Add vi.resetModules() in test beforeEach for explicit module isolation (fixes vi.doMock not intercepting loadVectorExtension in CI) - Skip precomputedHashes.set() on unchanged (return false) path - Run prettier on all 5 files flagged by CI format check Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/e20311fd-4361-47b4-a137-9adc3e533b35 * fix: address remaining review nits — rename precomputedHashes, generalize error matcher, revert package-lock - Rename precomputedHashes → computedStaleHashes (hashes are computed on-demand during filter, only cached for stale nodes being re-embedded) - Remove contentHash-specific clause from isMissingColumnOrTableError — the regex /(table|column|property).*not found/i already covers it - Revert package-lock.json ssh→https protocol change Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/e20311fd-4361-47b4-a137-9adc3e533b35 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --- .../src/core/embeddings/embedding-pipeline.ts | 124 ++++-- gitnexus/src/core/lbug/lbug-adapter.ts | 96 ++++- gitnexus/src/core/lbug/schema.ts | 12 +- gitnexus/src/core/run-analyze.ts | 26 +- gitnexus/src/server/api.ts | 33 +- gitnexus/test/unit/embedding-pipeline.test.ts | 377 ++++++++++++++++++ 6 files changed, 604 insertions(+), 64 deletions(-) create mode 100644 gitnexus/test/unit/embedding-pipeline.test.ts diff --git a/gitnexus/src/core/embeddings/embedding-pipeline.ts b/gitnexus/src/core/embeddings/embedding-pipeline.ts index cb1949144..cb28ca945 100644 --- a/gitnexus/src/core/embeddings/embedding-pipeline.ts +++ b/gitnexus/src/core/embeddings/embedding-pipeline.ts @@ -9,6 +9,7 @@ * 5. Create vector index for semantic search */ +import { createHash } from 'crypto'; import { initEmbedder, embedBatch, @@ -16,7 +17,7 @@ import { embeddingToArray, isEmbedderReady, } from './embedder.js'; -import { generateBatchEmbeddingTexts } from './text-generator.js'; +import { generateEmbeddingText, generateBatchEmbeddingTexts } from './text-generator.js'; import { type EmbeddingProgress, type EmbeddingConfig, @@ -26,9 +27,29 @@ import { DEFAULT_EMBEDDING_CONFIG, EMBEDDABLE_LABELS, } from './types.js'; +import { + EMBEDDING_TABLE_NAME, + EMBEDDING_INDEX_NAME, + CREATE_VECTOR_INDEX_QUERY, +} from '../lbug/schema.js'; +import { loadVectorExtension } from '../lbug/lbug-adapter.js'; const isDev = process.env.NODE_ENV === 'development'; +/** + * Compute a stable content fingerprint for an embeddable node. + * Used to detect when the underlying text has changed so stale vectors + * can be replaced (DELETE-then-INSERT, the Kuzu-sanctioned pattern for + * vector-indexed rows). + */ +export const contentHashForNode = ( + node: EmbeddableNode, + config: Partial = {}, +): string => { + const text = generateEmbeddingText(node, config); + return createHash('sha1').update(text).digest('hex'); +}; + /** * Progress callback type */ @@ -98,41 +119,32 @@ const batchInsertEmbeddings = async ( cypher: string, paramsList: Array>, ) => Promise, - updates: Array<{ id: string; embedding: number[] }>, + updates: Array<{ id: string; embedding: number[]; contentHash: string }>, ): Promise => { // MERGE instead of CREATE — idempotent, handles concurrent analyzes and partial prior runs - const cypher = `MERGE (e:CodeEmbedding {nodeId: $nodeId}) SET e.embedding = $embedding`; - const paramsList = updates.map((u) => ({ nodeId: u.id, embedding: u.embedding })); + const cypher = `MERGE (e:${EMBEDDING_TABLE_NAME} {nodeId: $nodeId}) SET e.embedding = $embedding, e.contentHash = $contentHash`; + const paramsList = updates.map((u) => ({ + nodeId: u.id, + embedding: u.embedding, + contentHash: u.contentHash, + })); await executeWithReusedStatement(cypher, paramsList); }; /** * Create the vector index for semantic search - * Now indexes the separate CodeEmbedding table + * Now indexes the separate CodeEmbedding table. + * Delegates extension loading to lbug-adapter's loadVectorExtension(), + * which owns the VECTOR extension lifecycle and state tracking. */ -let vectorExtensionLoaded = false; - const createVectorIndex = async ( executeQuery: (cypher: string) => Promise, ): Promise => { - // LadybugDB v0.15+ requires explicit VECTOR extension loading (once per session) - if (!vectorExtensionLoaded) { - try { - await executeQuery('INSTALL VECTOR'); - await executeQuery('LOAD EXTENSION VECTOR'); - vectorExtensionLoaded = true; - } catch { - // Extension may already be loaded — CREATE_VECTOR_INDEX will fail clearly if not - vectorExtensionLoaded = true; - } - } - - const cypher = ` - CALL CREATE_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx', 'embedding', metric := 'cosine') - `; + // Delegate to the adapter which tracks loaded state and handles DB reconnect resets + await loadVectorExtension(); try { - await executeQuery(cypher); + await executeQuery(CREATE_VECTOR_INDEX_QUERY); } catch (error) { // Index might already exist if (isDev) { @@ -148,7 +160,9 @@ const createVectorIndex = async ( * @param executeWithReusedStatement - Function to execute with reused prepared statement * @param onProgress - Callback for progress updates * @param config - Optional configuration override - * @param skipNodeIds - Optional set of node IDs that already have embeddings (incremental mode) + * @param existingEmbeddings - Optional map of nodeId → contentHash for incremental mode. + * Nodes whose hash matches are skipped; nodes with a changed hash are DELETE'd + * and re-embedded; nodes not in the map are embedded fresh. */ export const runEmbeddingPipeline = async ( executeQuery: (cypher: string) => Promise, @@ -158,7 +172,7 @@ export const runEmbeddingPipeline = async ( ) => Promise, onProgress: EmbeddingProgressCallback, config: Partial = {}, - skipNodeIds?: Set, + existingEmbeddings?: Map, ): Promise => { const finalConfig = { ...DEFAULT_EMBEDDING_CONFIG, ...config }; @@ -194,13 +208,57 @@ export const runEmbeddingPipeline = async ( // Phase 2: Query embeddable nodes let nodes = await queryEmbeddableNodes(executeQuery); - // Incremental mode: filter out nodes that already have embeddings - if (skipNodeIds && skipNodeIds.size > 0) { + // Incremental mode: compare content hashes, delete stale rows, skip fresh ones. + // Computed hashes for stale nodes are cached so batchInsertEmbeddings can reuse them + // (avoids double computation). + const computedStaleHashes = new Map(); + if (existingEmbeddings && existingEmbeddings.size > 0) { const beforeCount = nodes.length; - nodes = nodes.filter((n) => !skipNodeIds.has(n.id)); + const staleNodeIds: string[] = []; + nodes = nodes.filter((n) => { + const existingHash = existingEmbeddings.get(n.id); + if (existingHash === undefined) { + // New node — needs embedding + return true; + } + const currentHash = contentHashForNode(n, finalConfig); + if (currentHash !== existingHash) { + // Content changed — cache hash for reuse during insert, mark for DELETE + re-embed + computedStaleHashes.set(n.id, currentHash); + staleNodeIds.push(n.id); + return true; + } + // Hash matches — skip (fresh); no need to cache hash for skipped nodes + return false; + }); + + // DELETE stale embedding rows so they can be re-inserted + // (Kuzu forbids SET on vector-indexed properties; DELETE-then-INSERT is the sanctioned pattern) + if (staleNodeIds.length > 0) { + if (isDev) { + console.log(`🔄 Deleting ${staleNodeIds.length} stale embedding rows for re-embed`); + } + try { + await executeWithReusedStatement( + `MATCH (e:${EMBEDDING_TABLE_NAME} {nodeId: $nodeId}) DELETE e`, + staleNodeIds.map((nodeId) => ({ nodeId })), + ); + } catch (err) { + // "does not exist" = rows already gone — safe to proceed. + // All other errors risk vector-index corruption (Kuzu requires DELETE-before-INSERT + // for vector-indexed properties) — propagate so the pipeline aborts cleanly. + const msg = err instanceof Error ? err.message : String(err); + if (!msg.includes('does not exist')) { + throw new Error( + `[embed] Failed to delete stale embedding rows — aborting to prevent vector-index corruption: ${msg}`, + ); + } + } + } + if (isDev) { console.log( - `📦 Incremental embeddings: ${beforeCount} total, ${skipNodeIds.size} cached, ${nodes.length} to embed`, + `📦 Incremental embeddings: ${beforeCount} total, ${existingEmbeddings.size} cached, ${staleNodeIds.length} stale, ${nodes.length} to embed`, ); } } @@ -212,6 +270,11 @@ export const runEmbeddingPipeline = async ( } if (totalNodes === 0) { + // Ensure the vector index exists even when no new nodes need embedding. + // A prior crash or first-time incremental run may have left CodeEmbedding + // rows without ever reaching index creation. + await createVectorIndex(executeQuery); + onProgress({ phase: 'ready', percent: 100, @@ -250,6 +313,7 @@ export const runEmbeddingPipeline = async ( const updates = batch.map((node, i) => ({ id: node.id, embedding: embeddingToArray(embeddings[i]), + contentHash: computedStaleHashes.get(node.id) ?? contentHashForNode(node, finalConfig), })); await batchInsertEmbeddings(executeWithReusedStatement, updates); @@ -338,7 +402,7 @@ export const semanticSearch = async ( // Query the vector index on CodeEmbedding to get nodeIds and distances const vectorQuery = ` - CALL QUERY_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx', + CALL QUERY_VECTOR_INDEX('${EMBEDDING_TABLE_NAME}', '${EMBEDDING_INDEX_NAME}', CAST(${queryVecStr} AS FLOAT[${queryVec.length}]), ${k}) YIELD node AS emb, distance WITH emb, distance diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index 0298d5f7c..b5cd3e2ad 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -11,6 +11,7 @@ import { REL_TABLE_NAME, SCHEMA_QUERIES, EMBEDDING_TABLE_NAME, + STALE_HASH_SENTINEL, NodeTableName, } from './schema.js'; import { streamAllCSVsToDisk } from './csv-generator.js'; @@ -142,6 +143,16 @@ let currentDbPath: string | null = null; let ftsLoaded = false; let vectorExtensionLoaded = false; +/** + * Check if an error indicates a missing column or table (schema-level problem) + * rather than a transient/connection error. Used for legacy DB fallback logic. + */ +const isMissingColumnOrTableError = (msg: string): boolean => + msg.includes('does not exist') || + // Kuzu-specific: "(table|column|property) ... not found" — narrow enough to avoid + // matching transient errors like "connection not found" or "key not found". + /(table|column|property).*not found/i.test(msg); + /** Expose the current Database for pool adapter reuse in tests. */ export const getDatabase = (): lbug.Database | null => db; @@ -873,18 +884,35 @@ export const getLbugStats = async (): Promise<{ nodes: number; edges: number }> */ export const loadCachedEmbeddings = async (): Promise<{ embeddingNodeIds: Set; - embeddings: Array<{ nodeId: string; embedding: number[] }>; + embeddings: Array<{ nodeId: string; embedding: number[]; contentHash?: string }>; }> => { if (!conn) { return { embeddingNodeIds: new Set(), embeddings: [] }; } const embeddingNodeIds = new Set(); - const embeddings: Array<{ nodeId: string; embedding: number[] }> = []; + const embeddings: Array<{ nodeId: string; embedding: number[]; contentHash?: string }> = []; try { - const rows = await conn.query( - `MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN e.nodeId AS nodeId, e.embedding AS embedding`, - ); + // Try to read contentHash alongside the embedding + let rows: any; + let hasContentHash = true; + try { + rows = await conn.query( + `MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN e.nodeId AS nodeId, e.embedding AS embedding, e.contentHash AS contentHash`, + ); + } catch (err: any) { + // Only fall back for missing-column errors (legacy DBs without contentHash). + // Rethrow transient / connection errors so callers see them. + const msg = err?.message ?? ''; + if (isMissingColumnOrTableError(msg)) { + hasContentHash = false; + rows = await conn.query( + `MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN e.nodeId AS nodeId, e.embedding AS embedding`, + ); + } else { + throw err; + } + } const result = Array.isArray(rows) ? rows[0] : rows; for (const row of await result.getAll()) { const nodeId = String(row.nodeId ?? row[0] ?? ''); @@ -897,6 +925,7 @@ export const loadCachedEmbeddings = async (): Promise<{ embedding: Array.isArray(embedding) ? embedding.map(Number) : Array.from(embedding as any).map(Number), + contentHash: hasContentHash ? (row.contentHash ?? row[2] ?? undefined) : undefined, }); } } @@ -907,6 +936,63 @@ export const loadCachedEmbeddings = async (): Promise<{ return { embeddingNodeIds, embeddings }; }; +/** + * Fetch existing embedding hashes from CodeEmbedding table for incremental embedding. + * Returns a Map suitable for passing to `runEmbeddingPipeline`. + * Handles legacy DBs without the `contentHash` column (all rows treated as stale with empty hash). + * Returns undefined if the CodeEmbedding table does not exist. + * + * @param execQuery - Cypher query executor (typically pool-adapter's `executeQuery`) + */ +export const fetchExistingEmbeddingHashes = async ( + execQuery: (cypher: string) => Promise, +): Promise | undefined> => { + try { + const rows = await execQuery( + `MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN e.nodeId AS nodeId, e.contentHash AS contentHash`, + ); + if (!rows || rows.length === 0) return undefined; + const map = new Map(); + for (const r of rows) { + const nodeId = r.nodeId ?? r[0]; + const hash = r.contentHash ?? r[1] ?? STALE_HASH_SENTINEL; + if (nodeId) { + // Empty/null contentHash means legacy row — treat as stale so it gets re-embedded + map.set(nodeId, hash || STALE_HASH_SENTINEL); + } + } + return map; + } catch (err: any) { + const msg = err?.message ?? ''; + if (isMissingColumnOrTableError(msg)) { + // Column or table missing — try fallback without contentHash + try { + const rows = await execQuery(`MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN e.nodeId AS nodeId`); + if (!rows || rows.length === 0) return undefined; + const map = new Map(); + for (const r of rows) { + const nodeId = r.nodeId ?? r[0]; + if (nodeId) map.set(nodeId, STALE_HASH_SENTINEL); // no contentHash — treat as stale + } + console.log( + `[embed] ${map.size} nodes in legacy DB (no contentHash) — all treated as stale`, + ); + return map; + } catch (fallbackErr: any) { + const fallbackMsg = fallbackErr?.message ?? ''; + if (isMissingColumnOrTableError(fallbackMsg)) { + console.log( + `[embed] CodeEmbedding table not yet present — full embedding run (${fallbackMsg})`, + ); + return undefined; + } + throw fallbackErr; + } + } + throw err; + } +}; + export const closeLbug = async (): Promise => { if (conn) { try { diff --git a/gitnexus/src/core/lbug/schema.ts b/gitnexus/src/core/lbug/schema.ts index 257938a01..c0ba10d4d 100644 --- a/gitnexus/src/core/lbug/schema.ts +++ b/gitnexus/src/core/lbug/schema.ts @@ -436,10 +436,20 @@ if (Number.isNaN(_rawDims) || _rawDims <= 0) { } export const EMBEDDING_DIMS = _rawDims; +/** HNSW vector index name for the CodeEmbedding table. */ +export const EMBEDDING_INDEX_NAME = 'code_embedding_idx'; + +/** + * Sentinel value for "no content hash available" — used in legacy DBs and null rows. + * Nodes with this hash are always treated as stale and re-embedded. + */ +export const STALE_HASH_SENTINEL = ''; + export const EMBEDDING_SCHEMA = ` CREATE NODE TABLE ${EMBEDDING_TABLE_NAME} ( nodeId STRING, embedding FLOAT[${EMBEDDING_DIMS}], + contentHash STRING, PRIMARY KEY (nodeId) )`; @@ -448,7 +458,7 @@ CREATE NODE TABLE ${EMBEDDING_TABLE_NAME} ( * Uses HNSW (Hierarchical Navigable Small World) algorithm with cosine similarity */ export const CREATE_VECTOR_INDEX_QUERY = ` -CALL CREATE_VECTOR_INDEX('${EMBEDDING_TABLE_NAME}', 'code_embedding_idx', 'embedding', metric := 'cosine') +CALL CREATE_VECTOR_INDEX('${EMBEDDING_TABLE_NAME}', '${EMBEDDING_INDEX_NAME}', 'embedding', metric := 'cosine') `; // ============================================================================ diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 07fb8ab69..7c72fb592 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -32,6 +32,8 @@ import { } from '../storage/repo-manager.js'; import { getCurrentCommit, hasGitDir } from '../storage/git.js'; import { generateAIContextFiles } from '../cli/ai-context.js'; +import { EMBEDDING_TABLE_NAME } from './lbug/schema.js'; +import { STALE_HASH_SENTINEL } from './lbug/schema.js'; // --------------------------------------------------------------------------- // Public types @@ -138,7 +140,7 @@ export async function runFullAnalysis( // ── Cache embeddings from existing index before rebuild ──────────── let cachedEmbeddingNodeIds = new Set(); - let cachedEmbeddings: Array<{ nodeId: string; embedding: number[] }> = []; + let cachedEmbeddings: Array<{ nodeId: string; embedding: number[]; contentHash?: string }> = []; if (options.embeddings && existingMeta && !options.force) { try { @@ -219,10 +221,14 @@ export async function runFullAnalysis( const EMBED_BATCH = 200; for (let i = 0; i < cachedEmbeddings.length; i += EMBED_BATCH) { const batch = cachedEmbeddings.slice(i, i + EMBED_BATCH); - const paramsList = batch.map((e) => ({ nodeId: e.nodeId, embedding: e.embedding })); + const paramsList = batch.map((e) => ({ + nodeId: e.nodeId, + embedding: e.embedding, + contentHash: e.contentHash ?? STALE_HASH_SENTINEL, + })); try { await executeWithReusedStatement( - `MERGE (e:CodeEmbedding {nodeId: $nodeId}) SET e.embedding = $embedding`, + `MERGE (e:${EMBEDDING_TABLE_NAME} {nodeId: $nodeId}) SET e.embedding = $embedding, e.contentHash = $contentHash`, paramsList, ); } catch { @@ -251,6 +257,14 @@ export async function runFullAnalysis( httpMode ? 'Connecting to embedding endpoint...' : 'Loading embedding model...', ); const { runEmbeddingPipeline } = await import('./embeddings/embedding-pipeline.js'); + // Build a Map from cached embeddings for incremental mode + let existingEmbeddings: Map | undefined; + if (cachedEmbeddingNodeIds.size > 0) { + existingEmbeddings = new Map(); + for (const e of cachedEmbeddings) { + existingEmbeddings.set(e.nodeId, e.contentHash ?? STALE_HASH_SENTINEL); + } + } await runEmbeddingPipeline( executeQuery, executeWithReusedStatement, @@ -265,7 +279,7 @@ export async function runFullAnalysis( progress('embeddings', scaled, label); }, {}, - cachedEmbeddingNodeIds.size > 0 ? cachedEmbeddingNodeIds : undefined, + existingEmbeddings, ); } @@ -275,7 +289,9 @@ export async function runFullAnalysis( // Count embeddings in the index (cached + newly generated) let embeddingCount = 0; try { - const embResult = await executeQuery(`MATCH (e:CodeEmbedding) RETURN count(e) AS cnt`); + const embResult = await executeQuery( + `MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN count(e) AS cnt`, + ); embeddingCount = embResult?.[0]?.cnt ?? 0; } catch { /* table may not exist if embeddings never ran */ diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index 9afdbfe0e..b0e47e019 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -1449,27 +1449,14 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => await withLbugDb(lbugPath, async () => { const { runEmbeddingPipeline } = await import('../core/embeddings/embedding-pipeline.js'); - // Skip nodes that already have embeddings — Kuzu forbids SET on vector-indexed properties. - let skipNodeIds: Set | undefined; - try { - const rows = await executeQuery('MATCH (e:CodeEmbedding) RETURN e.nodeId AS nodeId'); - if (rows && rows.length > 0) { - skipNodeIds = new Set(rows.map((r: any) => r.nodeId ?? r[0]).filter(Boolean)); - console.log( - `[embed] ${skipNodeIds.size} nodes already embedded — skipping in incremental run`, - ); - } - } catch (err: any) { - // Swallow only "table does not exist" — let real connection errors propagate. - // Log so ops can see this path fire if Kuzu ever changes error wording. - const msg = err?.message ?? ''; - if (msg.includes('does not exist') || msg.includes('not found')) { - console.log( - `[embed] CodeEmbedding table not yet present — full embedding run (${msg})`, - ); - } else { - throw err; - } + // Fetch existing content hashes for incremental embedding. + // Delegated to lbug-adapter which owns the DB query logic and legacy-fallback handling. + const { fetchExistingEmbeddingHashes } = await import('../core/lbug/lbug-adapter.js'); + const existingEmbeddings = await fetchExistingEmbeddingHashes(executeQuery); + if (existingEmbeddings && existingEmbeddings.size > 0) { + console.log( + `[embed] ${existingEmbeddings.size} nodes already embedded — incremental run with content-hash comparison`, + ); } await runEmbeddingPipeline( executeQuery, @@ -1493,8 +1480,8 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => }, }); }, - {}, // config: use defaults (runEmbeddingPipeline signature: executeQuery, executeWithReusedStatement, onProgress, config, skipNodeIds) - skipNodeIds, + {}, // config: use defaults + existingEmbeddings, ); }); diff --git a/gitnexus/test/unit/embedding-pipeline.test.ts b/gitnexus/test/unit/embedding-pipeline.test.ts new file mode 100644 index 000000000..7597d48ce --- /dev/null +++ b/gitnexus/test/unit/embedding-pipeline.test.ts @@ -0,0 +1,377 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createHash } from 'crypto'; +import { contentHashForNode } from '../../src/core/embeddings/embedding-pipeline.js'; +import { generateEmbeddingText } from '../../src/core/embeddings/text-generator.js'; +import type { EmbeddableNode, EmbeddingProgress } from '../../src/core/embeddings/types.js'; +import { DEFAULT_EMBEDDING_CONFIG } from '../../src/core/embeddings/types.js'; +import { STALE_HASH_SENTINEL } from '../../src/core/lbug/schema.js'; + +// ──────────────────────────────────────────────────────────────────────────── +// contentHashForNode +// ──────────────────────────────────────────────────────────────────────────── +describe('contentHashForNode', () => { + const makeNode = (overrides: Partial = {}): EmbeddableNode => ({ + id: 'Function:foo:src/main.ts', + name: 'foo', + label: 'Function', + filePath: 'src/main.ts', + content: 'function foo() { return 1; }', + ...overrides, + }); + + it('returns a 40-char hex SHA-1 digest', () => { + const hash = contentHashForNode(makeNode()); + expect(hash).toMatch(/^[0-9a-f]{40}$/); + }); + + it('is deterministic — same node always produces the same hash', () => { + const node = makeNode(); + expect(contentHashForNode(node)).toBe(contentHashForNode(node)); + }); + + it('matches sha1(generateEmbeddingText(node))', () => { + const node = makeNode(); + const expected = createHash('sha1').update(generateEmbeddingText(node)).digest('hex'); + expect(contentHashForNode(node)).toBe(expected); + }); + + it('changes when node content is edited', () => { + const original = makeNode({ content: 'function foo() { return 1; }' }); + const edited = makeNode({ content: 'function foo() { return 42; }' }); + expect(contentHashForNode(original)).not.toBe(contentHashForNode(edited)); + }); + + it('changes when filePath differs', () => { + const a = makeNode({ filePath: 'src/a.ts' }); + const b = makeNode({ filePath: 'src/b.ts' }); + // Different filePaths lead to different embedding text ⇒ different hashes + expect(contentHashForNode(a)).not.toBe(contentHashForNode(b)); + }); + + it('produces identical hash regardless of config vs finalConfig when config is empty', () => { + const node = makeNode(); + const hashWithEmptyConfig = contentHashForNode(node, {}); + const hashWithFullDefaults = contentHashForNode(node, DEFAULT_EMBEDDING_CONFIG); + expect(hashWithEmptyConfig).toBe(hashWithFullDefaults); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// STALE_HASH_SENTINEL +// ──────────────────────────────────────────────────────────────────────────── +describe('STALE_HASH_SENTINEL', () => { + it('is the empty string', () => { + expect(STALE_HASH_SENTINEL).toBe(''); + }); + + it('is falsy — enables consistent `hash || STALE_HASH_SENTINEL` patterns', () => { + expect(!STALE_HASH_SENTINEL).toBe(true); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// runEmbeddingPipeline — exports +// ──────────────────────────────────────────────────────────────────────────── +describe('runEmbeddingPipeline incremental mode', () => { + it('exports contentHashForNode as a named export', async () => { + const mod = await import('../../src/core/embeddings/embedding-pipeline.js'); + expect(typeof mod.contentHashForNode).toBe('function'); + }); + + it('exports runEmbeddingPipeline as a named export', async () => { + const mod = await import('../../src/core/embeddings/embedding-pipeline.js'); + expect(typeof mod.runEmbeddingPipeline).toBe('function'); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// EMBEDDING_SCHEMA includes contentHash column +// ──────────────────────────────────────────────────────────────────────────── +describe('EMBEDDING_SCHEMA', () => { + it('includes contentHash STRING column', async () => { + const { EMBEDDING_SCHEMA } = await import('../../src/core/lbug/schema.js'); + expect(EMBEDDING_SCHEMA).toContain('contentHash STRING'); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// EMBEDDING_INDEX_NAME export +// ──────────────────────────────────────────────────────────────────────────── +describe('EMBEDDING_INDEX_NAME', () => { + it('is exported from schema.ts', async () => { + const { EMBEDDING_INDEX_NAME } = await import('../../src/core/lbug/schema.js'); + expect(EMBEDDING_INDEX_NAME).toBe('code_embedding_idx'); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// runEmbeddingPipeline — incremental filter logic with mocked embedder +// +// Tests the three incremental-mode code paths: +// 1. New node (not in existingEmbeddings) → embedded +// 2. Unchanged node (hash matches) → skipped +// 3. Stale node (hash mismatch) → DELETE old → re-embed +// 4. Zero nodes after filter → createVectorIndex still called +// ──────────────────────────────────────────────────────────────────────────── +describe('runEmbeddingPipeline incremental filter', () => { + // Track mocked calls + let queryCalls: string[]; + let stmtCalls: Array<{ cypher: string; params: Array> }>; + let progressUpdates: EmbeddingProgress[]; + + // Helper node + const makeNode = (overrides: Partial = {}): EmbeddableNode => ({ + id: 'Function:foo:src/main.ts', + name: 'foo', + label: 'Function', + filePath: 'src/main.ts', + content: 'function foo() { return 1; }', + ...overrides, + }); + + beforeEach(() => { + queryCalls = []; + stmtCalls = []; + progressUpdates = []; + vi.restoreAllMocks(); + vi.resetModules(); + }); + + // Mock the embedder module so we never need a real model + const mockEmbedderSetup = () => { + vi.doMock('../../src/core/embeddings/embedder.js', () => ({ + initEmbedder: vi.fn().mockResolvedValue(undefined), + embedBatch: vi + .fn() + .mockImplementation((texts: string[]) => + Promise.resolve(texts.map(() => new Float32Array(384))), + ), + embedText: vi.fn().mockResolvedValue(new Float32Array(384)), + embeddingToArray: vi.fn().mockImplementation((emb: Float32Array) => Array.from(emb)), + isEmbedderReady: vi.fn().mockReturnValue(true), + })); + + // Mock loadVectorExtension (avoids needing the native lbug module) + vi.doMock('../../src/core/lbug/lbug-adapter.js', () => ({ + loadVectorExtension: vi.fn().mockResolvedValue(undefined), + })); + }; + + const mockExecuteQuery = (nodes: EmbeddableNode[]) => { + return vi.fn().mockImplementation(async (cypher: string) => { + queryCalls.push(cypher); + // Respond to node queries based on label + for (const label of ['Function', 'Class', 'Method', 'Interface', 'File']) { + if (cypher.includes(`MATCH (n:${label})`)) { + return nodes + .filter((n) => n.label === label) + .map((n) => ({ + id: n.id, + name: n.name, + label: n.label, + filePath: n.filePath, + content: n.content, + startLine: n.startLine, + endLine: n.endLine, + })); + } + } + return []; + }); + }; + + const mockExecuteWithReusedStatement = () => { + return vi + .fn() + .mockImplementation(async (cypher: string, params: Array>) => { + stmtCalls.push({ cypher, params }); + }); + }; + + const onProgress = (p: EmbeddingProgress) => { + progressUpdates.push({ ...p }); + }; + + it('skips unchanged nodes when hash matches', async () => { + mockEmbedderSetup(); + + const node = makeNode(); + const hash = contentHashForNode(node, DEFAULT_EMBEDDING_CONFIG); + const existingEmbeddings = new Map([[node.id, hash]]); + + const executeQuery = mockExecuteQuery([node]); + const executeWithReusedStatement = mockExecuteWithReusedStatement(); + + const { runEmbeddingPipeline } = + await import('../../src/core/embeddings/embedding-pipeline.js'); + + await runEmbeddingPipeline( + executeQuery, + executeWithReusedStatement, + onProgress, + {}, + existingEmbeddings, + ); + + // No MERGE calls — node was skipped because hash matched + const mergeCalls = stmtCalls.filter((c) => c.cypher.includes('MERGE')); + expect(mergeCalls).toHaveLength(0); + + // Pipeline should reach 'ready' state + const readyProgress = progressUpdates.find((p) => p.phase === 'ready'); + expect(readyProgress).toBeDefined(); + expect(readyProgress!.percent).toBe(100); + }); + + it('embeds new nodes not in existingEmbeddings', async () => { + mockEmbedderSetup(); + + const node = makeNode({ + id: 'Function:newFn:src/new.ts', + name: 'newFn', + filePath: 'src/new.ts', + }); + const existingEmbeddings = new Map(); // empty — no prior embeddings + + const executeQuery = mockExecuteQuery([node]); + const executeWithReusedStatement = mockExecuteWithReusedStatement(); + + const { runEmbeddingPipeline } = + await import('../../src/core/embeddings/embedding-pipeline.js'); + + await runEmbeddingPipeline( + executeQuery, + executeWithReusedStatement, + onProgress, + {}, + existingEmbeddings, + ); + + // Should have a MERGE call to insert the embedding + const mergeCalls = stmtCalls.filter((c) => c.cypher.includes('MERGE')); + expect(mergeCalls.length).toBeGreaterThanOrEqual(1); + + // The inserted row should contain the node id and a contentHash + const insertParams = mergeCalls[0].params; + expect(insertParams.some((p: any) => p.nodeId === node.id)).toBe(true); + expect(insertParams[0].contentHash).toMatch(/^[0-9a-f]{40}$/); + }); + + it('deletes and re-embeds stale nodes (hash mismatch)', async () => { + mockEmbedderSetup(); + + const node = makeNode({ content: 'function foo() { return 42; }' }); + const staleHash = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; // wrong hash + const existingEmbeddings = new Map([[node.id, staleHash]]); + + const executeQuery = mockExecuteQuery([node]); + const executeWithReusedStatement = mockExecuteWithReusedStatement(); + + const { runEmbeddingPipeline } = + await import('../../src/core/embeddings/embedding-pipeline.js'); + + await runEmbeddingPipeline( + executeQuery, + executeWithReusedStatement, + onProgress, + {}, + existingEmbeddings, + ); + + // Should have a DELETE call for the stale node + const deleteCalls = stmtCalls.filter((c) => c.cypher.includes('DELETE')); + expect(deleteCalls.length).toBeGreaterThanOrEqual(1); + expect(deleteCalls[0].params.some((p: any) => p.nodeId === node.id)).toBe(true); + + // Should also have a MERGE call to re-insert with new hash + const mergeCalls = stmtCalls.filter((c) => c.cypher.includes('MERGE')); + expect(mergeCalls.length).toBeGreaterThanOrEqual(1); + }); + + it('treats STALE_HASH_SENTINEL as stale — triggers re-embed', async () => { + mockEmbedderSetup(); + + const node = makeNode(); + // Legacy row: nodeId present but contentHash is STALE_HASH_SENTINEL + const existingEmbeddings = new Map([[node.id, STALE_HASH_SENTINEL]]); + + const executeQuery = mockExecuteQuery([node]); + const executeWithReusedStatement = mockExecuteWithReusedStatement(); + + const { runEmbeddingPipeline } = + await import('../../src/core/embeddings/embedding-pipeline.js'); + + await runEmbeddingPipeline( + executeQuery, + executeWithReusedStatement, + onProgress, + {}, + existingEmbeddings, + ); + + // Should have a DELETE call (stale) + const deleteCalls = stmtCalls.filter((c) => c.cypher.includes('DELETE')); + expect(deleteCalls.length).toBeGreaterThanOrEqual(1); + + // Should also have a MERGE (re-embed) + const mergeCalls = stmtCalls.filter((c) => c.cypher.includes('MERGE')); + expect(mergeCalls.length).toBeGreaterThanOrEqual(1); + }); + + it('calls createVectorIndex even when zero nodes need embedding after filter', async () => { + mockEmbedderSetup(); + + const node = makeNode(); + const hash = contentHashForNode(node, DEFAULT_EMBEDDING_CONFIG); + // All existing hashes match — zero nodes to embed + const existingEmbeddings = new Map([[node.id, hash]]); + + const executeQuery = mockExecuteQuery([node]); + const executeWithReusedStatement = mockExecuteWithReusedStatement(); + + const { runEmbeddingPipeline } = + await import('../../src/core/embeddings/embedding-pipeline.js'); + + await runEmbeddingPipeline( + executeQuery, + executeWithReusedStatement, + onProgress, + {}, + existingEmbeddings, + ); + + // The CREATE_VECTOR_INDEX query should have been called via executeQuery + const vectorIndexCalls = queryCalls.filter((c) => c.includes('CREATE_VECTOR_INDEX')); + expect(vectorIndexCalls.length).toBeGreaterThanOrEqual(1); + }); + + it('throws when DELETE for stale nodes fails with non-trivial error', async () => { + mockEmbedderSetup(); + + const node = makeNode({ content: 'function foo() { return 42; }' }); + const staleHash = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const existingEmbeddings = new Map([[node.id, staleHash]]); + + const executeQuery = mockExecuteQuery([node]); + const executeWithReusedStatement = vi.fn().mockRejectedValue(new Error('Connection lost')); + + const { runEmbeddingPipeline } = + await import('../../src/core/embeddings/embedding-pipeline.js'); + + await expect( + runEmbeddingPipeline( + executeQuery, + executeWithReusedStatement, + onProgress, + {}, + existingEmbeddings, + ), + ).rejects.toThrow('vector-index corruption'); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// fetchExistingEmbeddingHashes — tested in integration tests (requires native module) +// The function is tested via lbug-core-adapter integration tests which have the +// native @ladybugdb/core module available. +// ──────────────────────────────────────────────────────────────────────────── From 109a3c694667369cdc985c0310f31fd1dca9860f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Wed, 15 Apr 2026 13:24:53 +0100 Subject: [PATCH 41/67] ci: standardize workflow concurrency and automate release-note labeling (#837) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: standardize workflow concurrency and automate release-note labeling Concurrency — prevent racing CI jobs - Every top-level workflow now declares an explicit concurrency block. - PR runs cancel-in-progress on supersede; main/push/workflow_call/publish runs queue instead of cancelling so every commit and every release is validated end-to-end. - ci.yml uses a literal `CI-` prefix (not `${{ github.workflow }}`) and a per-run nested group for workflow_call invocations, avoiding a potential deadlock with publish.yml and release-candidate.yml callers whose own concurrency groups could otherwise collide with the called workflow. - ci-report.yml falls back to `/` for fork PRs (stable across reruns) instead of the per-run-unique workflow_run.id which did not actually serialize anything. - ci-quality.yml enforces the convention: fails CI if any non-reusable workflow lacks a concurrency block or a reusable workflow declares one. Release-note automation - New pr-labeler.yml: amannn/action-semantic-pull-request enforces conventional-commit PR titles on pull_request (fork-safe, read-only); release-drafter/release-drafter with disable-releaser: true applies the matching label under pull_request_target (write-scoped). sync-labels in .github/release-drafter.yml removes managed autolabels that no longer match (e.g. when `!` or `BREAKING CHANGE:` is dropped from a PR). - .github/release.yml (unchanged) continues to map labels to categorized release-notes sections. - dependabot.yml added for the github-actions ecosystem so pinned SHAs auto-refresh on a weekly cadence. Docs - CONTRIBUTING.md documents the concurrency convention, the conventional-commit PR-title rules, and the reusable-workflow exception. Follow-up to verify before relying on the labeler in anger - gh api repos/amannn/action-semantic-pull-request/git/refs/tags/v5.5.3 - gh api repos/release-drafter/release-drafter/git/refs/tags/v6.0.0 - Confirm release-drafter reads its config from the base ref (not fork head) when invoked via pull_request_target. * ci: address PR review feedback on concurrency and labeler workflows Two blocking fixes - pr-labeler.yml: separate concurrency slots for pull_request and pull_request_target. Previously both triggers shared a single group with cancel-in-progress: true, so the privileged autolabel run could cancel the title-validation check mid-run and leave a required status in a permanent cancelled state. - pr-labeler.yml autolabel job: add contents: read. release-drafter's context.config() reads .github/release-drafter.yml from the default branch via the repo-contents API and 403s without the scope. Job-level permissions nullify all unlisted scopes so an explicit grant is needed. Two non-blocking improvements - Replace the hardcoded reusable-workflow allowlist in ci-quality.yml with dynamic on:-block parsing. New workflow_call-only workflows no longer produce false-positive convention failures. - Implement actual group-key validation. The check now also asserts that every concurrency.group expression references either ${{ github.workflow }} or the literal CI- prefix (the documented ci.yml exception). - Script extracted to .github/scripts/check-workflow-concurrency.py so it is runnable locally and independently testable. --- .github/dependabot.yml | 16 ++ .github/release-drafter.yml | 53 ++++++ .github/scripts/check-workflow-concurrency.py | 173 ++++++++++++++++++ .github/workflows/ci-quality.yml | 23 +++ .github/workflows/ci-report.yml | 10 + .github/workflows/ci.yml | 15 +- .github/workflows/claude-code-review.yml | 3 +- .github/workflows/claude.yml | 3 +- .github/workflows/pr-description-check.yml | 3 +- .github/workflows/pr-labeler.yml | 113 ++++++++++++ .github/workflows/publish.yml | 11 +- .github/workflows/release-candidate.yml | 8 +- .github/workflows/triage-sweep.yml | 4 +- CONTRIBUTING.md | 78 +++++++- 14 files changed, 492 insertions(+), 21 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/release-drafter.yml create mode 100644 .github/scripts/check-workflow-concurrency.py create mode 100644 .github/workflows/pr-labeler.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..292cb435d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 +updates: + # Keep third-party Actions SHA pins current. See CONTRIBUTING.md — when + # reviewing these bumps, verify the SHA corresponds to the claimed tag by + # running `gh api repos///git/refs/tags/` before merge. + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + commit-message: + prefix: chore + include: scope + labels: + - dependencies + - ci diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 000000000..378a60e2c --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,53 @@ +# release-drafter config — used only for PR autolabeling by +# `.github/workflows/pr-labeler.yml` (the workflow passes `disable-releaser: true`, +# so the draft-release side of release-drafter never runs). +# +# The labels applied here are the same ones `.github/release.yml` maps to +# categorized release-notes sections. +# +# `sync-labels: true` removes managed autolabels that no longer match the PR — +# critical for the breaking-change case: if a PR title drops the `!` or the body +# drops `BREAKING CHANGE:`, the `breaking` label is pulled off automatically. + +# Required by release-drafter; not used because releaser is disabled. +name-template: 'unused' +tag-template: 'unused' +template: | + $CHANGES + +sync-labels: true + +autolabeler: + - label: enhancement + title: + - '/^feat(\([^)]+\))?!?:/i' + - label: bug + title: + - '/^fix(\([^)]+\))?!?:/i' + - label: performance + title: + - '/^perf(\([^)]+\))?!?:/i' + - label: refactor + title: + - '/^refactor(\([^)]+\))?!?:/i' + - label: documentation + title: + - '/^docs(\([^)]+\))?!?:/i' + - label: test + title: + - '/^test(\([^)]+\))?!?:/i' + - label: ci + title: + - '/^ci(\([^)]+\))?!?:/i' + - label: dependencies + title: + - '/^(build|deps)(\([^)]+\))?!?:/i' + - label: chore + title: + - '/^(chore|revert)(\([^)]+\))?!?:/i' + # Breaking-change marker: either `!` in the type prefix or `BREAKING CHANGE:` in body. + - label: breaking + title: + - '/^[a-z]+(\([^)]+\))?!:/i' + body: + - '/BREAKING[ -]CHANGE:/i' diff --git a/.github/scripts/check-workflow-concurrency.py b/.github/scripts/check-workflow-concurrency.py new file mode 100644 index 000000000..300cc7f4b --- /dev/null +++ b/.github/scripts/check-workflow-concurrency.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Enforce the GitHub Actions concurrency convention. + +See CONTRIBUTING.md -> "GitHub Actions — Concurrency Convention" for the rules. + +Invoked from .github/workflows/ci-quality.yml. Runs locally too: + python3 .github/scripts/check-workflow-concurrency.py .github/workflows + +Rules: + 1. Every entry-point (non-reusable) workflow declares a top-level + `concurrency:` block. + 2. Reusable workflows (on: workflow_call ONLY) do NOT declare one. + 3. The `concurrency.group` expression MUST reference either + `${{ github.workflow }}` or a literal `CI-` prefix (the documented + ci.yml reusable-workflow-safe exception). This is checked by substring + containment rather than prefix match because ci.yml's group is a + conditional expression that resolves to a `CI-…` literal at runtime. + +We deliberately do not use a YAML library — keeps the script dependency-free +on any vanilla runner. `on:` block parsing is line-based and handles both the +flat (`on: workflow_call`) and mapping (`on:\n workflow_call:`) forms. +""" + +from __future__ import annotations + +import pathlib +import re +import sys + + +REQUIRED_TOKENS = ("${{ github.workflow }}", "CI-") + + +def is_reusable(lines: list[str]) -> bool: + """Return True iff the workflow's `on:` block names only `workflow_call`.""" + in_on = False + on_indent: int | None = None + keys: list[str] = [] + + for raw in lines: + # Skip blank lines and comments + stripped = raw.strip() + if not stripped or stripped.startswith("#"): + continue + + indent = len(raw) - len(raw.lstrip(" ")) + + if not in_on: + if raw.startswith("on:"): + remainder = raw[len("on:"):].strip() + if not remainder: + # `on:` followed by indented mapping on next lines + in_on = True + on_indent = indent + continue + if remainder.startswith("[") and remainder.endswith("]"): + # Flow-style list: on: [workflow_call] + items = [ + item.strip() for item in remainder.strip("[]").split(",") + ] + return items == ["workflow_call"] + # Scalar form: on: workflow_call (or a single other event) + return remainder == "workflow_call" + continue + + # Inside the `on:` block; stop when indentation returns to <= on_indent + if on_indent is not None and indent <= on_indent: + break + + # Only consider keys at on_indent + indentation step (anything deeper + # is nested config like `types:`) + if ":" not in stripped: + continue + # Heuristic: first-level event keys are those with indent == on_indent + 2 + # (the canonical step for a 2-space YAML doc). We collect all first-level + # keys by tracking the smallest indent seen inside the block. + keys.append((indent, stripped.split(":", 1)[0].strip())) + + if not keys: + return False + + # Take only the outermost-indented keys as the event list + min_indent = min(i for i, _ in keys) + events = [name for i, name in keys if i == min_indent] + return events == ["workflow_call"] + + +CONCURRENCY_RE = re.compile(r"^concurrency:\s*$") +GROUP_RE = re.compile(r"^\s+group:\s*(.+?)\s*$") + + +def extract_group_key(lines: list[str]) -> str | None: + """Return the `group:` value of the top-level `concurrency:` block, or None.""" + for idx, raw in enumerate(lines): + if CONCURRENCY_RE.match(raw): + # Scan forward until we leave the concurrency block (next top-level key + # is at column 0 and ends with `:`). + for follow in lines[idx + 1:]: + if follow and not follow.startswith(" ") and follow.rstrip().endswith(":"): + break + m = GROUP_RE.match(follow) + if m: + return m.group(1).strip().strip("'").strip('"') + break + return None + + +def has_top_level_concurrency(lines: list[str]) -> bool: + return any(CONCURRENCY_RE.match(raw) for raw in lines) + + +def check(workflows_dir: pathlib.Path) -> int: + fail = 0 + files = sorted( + list(workflows_dir.glob("*.yml")) + list(workflows_dir.glob("*.yaml")) + ) + for path in files: + lines = path.read_text(encoding="utf-8").splitlines() + reusable = is_reusable(lines) + has_conc = has_top_level_concurrency(lines) + + if reusable: + if has_conc: + print( + f"::error file={path}::Reusable workflow (on: workflow_call) " + "must NOT declare its own concurrency block — it inherits " + "from the caller. See CONTRIBUTING.md -> GitHub Actions — " + "Concurrency Convention." + ) + fail = 1 + continue + + if not has_conc: + print( + f"::error file={path}::Missing top-level concurrency block. " + "See CONTRIBUTING.md -> GitHub Actions — Concurrency Convention." + ) + fail = 1 + continue + + group = extract_group_key(lines) + if group is None: + print( + f"::error file={path}::concurrency block is missing a " + "`group:` key." + ) + fail = 1 + continue + + if not any(token in group for token in REQUIRED_TOKENS): + print( + f"::error file={path}::concurrency.group `{group}` must " + f"reference one of {REQUIRED_TOKENS}. See CONTRIBUTING.md -> " + "GitHub Actions — Concurrency Convention." + ) + fail = 1 + + return fail + + +def main(argv: list[str]) -> int: + if len(argv) != 2: + print(f"usage: {argv[0]} ", file=sys.stderr) + return 2 + workflows_dir = pathlib.Path(argv[1]) + if not workflows_dir.is_dir(): + print(f"not a directory: {workflows_dir}", file=sys.stderr) + return 2 + return check(workflows_dir) + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/.github/workflows/ci-quality.yml b/.github/workflows/ci-quality.yml index 9a5b9fedd..6c3132336 100644 --- a/.github/workflows/ci-quality.yml +++ b/.github/workflows/ci-quality.yml @@ -47,3 +47,26 @@ jobs: - uses: ./.github/actions/setup-gitnexus-web - run: npx tsc -b --noEmit working-directory: gitnexus-web + + # Enforces the convention documented in CONTRIBUTING.md → "GitHub Actions — + # Concurrency Convention": + # 1. Every entry-point (non-reusable) workflow declares a top-level + # `concurrency:` block. + # 2. Reusable workflows (`on: workflow_call` only) do NOT declare one — + # they inherit concurrency from the caller. + # 3. The concurrency group key starts with `${{ github.workflow }}` or + # the literal `CI-` prefix (the documented ci.yml exception for + # reusable-workflow-safe grouping). + # Reusability is detected by parsing each workflow's `on:` block, not an + # allowlist, so new reusable workflows never produce false positives. + workflow-convention: + name: Workflow concurrency convention + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Validate workflow concurrency convention + shell: bash + run: | + set -euo pipefail + python3 .github/scripts/check-workflow-concurrency.py .github/workflows diff --git a/.github/workflows/ci-report.yml b/.github/workflows/ci-report.yml index 2a6e5cea8..fa5023f26 100644 --- a/.github/workflows/ci-report.yml +++ b/.github/workflows/ci-report.yml @@ -14,6 +14,16 @@ permissions: contents: read # needed for sparse checkout of vitest.config.ts pull-requests: write # needed to post sticky PR comment +# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". +# Serialize sticky-comment writes per PR so two rapid CI completions don't race. +# Internal PRs surface in `pull_requests[0].number`. Fork PRs leave that array empty, +# so we fall back to `/`, which is stable across +# reruns and subsequent pushes for the same fork PR (unlike `workflow_run.id` which +# is unique per run and therefore does not serialize anything). +concurrency: + group: ${{ github.workflow }}-${{ github.event.workflow_run.pull_requests[0].number || format('{0}/{1}', github.event.workflow_run.head_repository.full_name, github.event.workflow_run.head_branch) }} + cancel-in-progress: false + jobs: pr-report: name: PR Report diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba7184ace..1cb3576fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,9 +9,20 @@ on: paths-ignore: ['**.md', 'docs/**', 'LICENSE'] workflow_call: +# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". +# Hardcoded `CI-` prefix (not `${{ github.workflow }}`) because this workflow is +# invoked as a reusable workflow from publish.yml and release-candidate.yml. In +# called-workflow context `github.workflow` evaluation is ambiguous across GitHub +# Actions versions, and a prefix that could resolve to the caller's name would +# share a concurrency group with the caller → deadlock. A literal prefix is +# immune. Direct `push`/`pull_request` invocations use `CI-`; invocations +# from a reusable-workflow caller fall into a per-run-unique group that never +# serializes with the caller. +# cancel-in-progress is event-aware: cancel superseded PR runs, queue every other +# event (push to main, workflow_call from publish.yml, etc.). concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true + group: ${{ (github.event_name == 'pull_request' || github.event_name == 'push') && format('CI-{0}', github.ref) || format('CI-nested-{0}', github.run_id) }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} # ── Reusable workflow orchestration ───────────────────────────────── # Each concern lives in its own workflow file for maintainability: diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 82a65f844..aaf2f10ec 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -16,9 +16,10 @@ on: issue_comment: types: [created] +# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". # Serialize per-PR to avoid racing review comments. concurrency: - group: claude-review-${{ github.event.issue.number || github.event.pull_request.number }} + group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number }} cancel-in-progress: false jobs: diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 407b2fcf8..dd30b72a9 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -10,9 +10,10 @@ on: pull_request_review: types: [submitted] +# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". # Serialize per-PR/issue to avoid racing comments. concurrency: - group: claude-code-${{ github.event.issue.number || github.event.pull_request.number || github.event.issue.id }} + group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.event.issue.id }} cancel-in-progress: false jobs: diff --git a/.github/workflows/pr-description-check.yml b/.github/workflows/pr-description-check.yml index d722dfff3..de562fd35 100644 --- a/.github/workflows/pr-description-check.yml +++ b/.github/workflows/pr-description-check.yml @@ -8,8 +8,9 @@ on: permissions: pull-requests: write +# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". concurrency: - group: pr-desc-${{ github.event.pull_request.number }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} cancel-in-progress: true jobs: diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml new file mode 100644 index 000000000..aab2fcaef --- /dev/null +++ b/.github/workflows/pr-labeler.yml @@ -0,0 +1,113 @@ +name: PR Conventional Labeler + +# Two workflows in one file with different triggers, matched to the minimum +# privilege each needs: +# +# validate-title (on: pull_request) +# Fork-safe. Runs with the PR-head's read-only GITHUB_TOKEN. Uses +# `amannn/action-semantic-pull-request` to fail the check when the PR +# title doesn't follow the conventional-commit format. Because the +# action only reads the event payload, no fork-controlled code runs. +# +# autolabel (on: pull_request_target) +# Needs `pull-requests: write` to apply labels, so must be +# pull_request_target. Uses `release-drafter/release-drafter` with +# `disable-releaser: true` to only run the autolabeler against the +# `.github/release-drafter.yml` config from the BASE ref (release- +# drafter reads the config from the repository's default branch, NOT +# the PR head — verify with `gh api repos/release-drafter/release-drafter/contents/...` +# or a fork-test PR before merging if the repo is high-value). +# `sync-labels: true` in the config removes managed autolabels that no +# longer match (e.g. when `!` or `BREAKING CHANGE:` is dropped). +# +# Title format: [(scope)][!]: +# Allowed types: feat, fix, perf, refactor, docs, test, ci, build, chore, revert, deps +# Trailing `!` on the type marks a breaking change. +# See CONTRIBUTING.md → "Pull request titles". + +on: + pull_request: + # Title-only changes fire `edited`. `opened` and `reopened` cover creation. + # `synchronize` (push to the PR branch) is intentionally excluded — titles + # don't change on push, so it only wastes CI minutes and broadens the + # privileged-token exposure window on the autolabel job. + types: [opened, edited, reopened] + pull_request_target: + types: [opened, edited, reopened] + +# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". +# Include `github.event_name` so `pull_request` (validate-title) and +# `pull_request_target` (autolabel) runs for the same PR do NOT share a slot +# and therefore cannot cancel each other — a cancelled required-check would +# permanently block merge until the next title edit. +# Within each trigger the latest title edit still supersedes the prior run. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + validate-title: + # Fork-safe job — only runs on `pull_request` (not `pull_request_target`). + # Token is read-only; writes a commit status that branch protection can + # require before merge. + name: Validate PR title + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + pull-requests: read + steps: + # Pinned to v5.5.3. Verify SHA via: + # gh api repos/amannn/action-semantic-pull-request/git/refs/tags/v5.5.3 + - uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: | + feat + fix + perf + refactor + docs + test + ci + build + chore + revert + deps + requireScope: false + # Subject must be non-empty. We DO allow capitalized proper nouns + # (MCP, GitHub, API, etc.) — the old `^(?![A-Z]).+$` pattern + # rejected legitimate titles like `fix: MCP tool schema`. + subjectPattern: ^\S.{2,}$ + subjectPatternError: | + The subject "{subject}" in PR title "{title}" is invalid. + Subjects must be at least 3 characters and must not start with whitespace. + wip: false + + autolabel: + # Privileged job — runs only on `pull_request_target` so it can write labels. + # Never checks out fork code, never executes fork-controlled input; only + # reads the PR metadata (title, body, labels) and calls the GitHub API. + name: Apply conventional label + if: github.event_name == 'pull_request_target' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + # `contents: read` is required — release-drafter's context.config() reads + # `.github/release-drafter.yml` from the repo's default branch via the + # repo-contents API. Without it the job silently 403s and no labels are + # applied. Job-level permissions nullify all unlisted scopes, so an + # explicit grant is necessary here. + contents: read + pull-requests: write + steps: + # Pinned to v6.0.0. Verify SHA via: + # gh api repos/release-drafter/release-drafter/git/refs/tags/v6.0.0 + # Note: dependabot will likely propose a bump to v6.x on first run. + - uses: release-drafter/release-drafter@3f0f87098bd6b5c5b9a36d49c41d998ea58f9348 # v6.0.0 + with: + config-name: release-drafter.yml + disable-releaser: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8a0ee6ebc..f21c4a7ba 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,13 +7,22 @@ on: # No workflow-level permissions — scoped per job below. +# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". +# Tag refs are unique per release, so distinct tags run in parallel. Re-pushes of the +# same tag serialize. cancel-in-progress: false — never cancel a publish mid-flight. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: ci: uses: ./.github/workflows/ci.yml permissions: contents: read actions: read - pull-requests: write + # No pull-requests:write — `ci.yml`'s save-pr-meta job is gated on + # `github.event_name == 'pull_request'`, so it never runs during a + # tag-triggered publish. Least-privilege for release-critical paths. publish: needs: ci diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index ed9bb1780..7b73080de 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -38,11 +38,11 @@ on: # No workflow-level permissions — scoped per job below. permissions: {} +# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". +# Serialize all runs on the same ref (push + workflow_dispatch) to prevent two publishes +# racing on the rc counter. cancel-in-progress: false — the earlier merge publishes first. concurrency: - # Serialize all runs on the same ref (push + workflow_dispatch) to prevent - # two publishes racing on the rc counter. Do not cancel an in-progress run - # when a newer one is queued — we want the earlier merge to publish first. - group: release-candidate-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false jobs: diff --git a/.github/workflows/triage-sweep.yml b/.github/workflows/triage-sweep.yml index ba5514dbf..052e2ca96 100644 --- a/.github/workflows/triage-sweep.yml +++ b/.github/workflows/triage-sweep.yml @@ -47,8 +47,10 @@ permissions: issues: write pull-requests: write +# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". +# Single global slot — newest manual dispatch supersedes any in-flight run. concurrency: - group: triage-sweep + group: ${{ github.workflow }} cancel-in-progress: true jobs: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7247750f5..d2d48f017 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,18 +21,41 @@ This project uses the [PolyForm Noncommercial License 1.0.0](https://polyformpro ## Branch and pull requests - Use short-lived branches off the default branch of the repo you are targeting. -- Prefer **conventional commits** (short prefix + description), for example: - - ```text - feat: add graph export option - fix: correct MCP tool schema for query - test: cover cluster merge edge case - docs: clarify analyze flags - ``` - -- **PR title:** `[area] Short description` (e.g. `[cli] Fix index refresh race`). +- **PR titles MUST follow the conventional-commit format** — `pr-labeler.yml` enforces this on every PR and auto-applies the matching label so release notes group the change correctly. - **PR description:** what changed, why, how to verify (commands), and any risk or rollback notes. +### Pull request titles + +Format: `[(scope)][!]: ` + +Allowed types and the release-notes section each one lands in (defined in `.github/release.yml`): + +| Type | Label applied | Release-notes section | +|------|---------------|-----------------------| +| `feat` | `enhancement` | 🚀 Features | +| `fix` | `bug` | 🐛 Bug Fixes | +| `perf` | `performance` | 🏎️ Performance | +| `refactor` | `refactor` | 🔄 Refactoring | +| `test` | `test` | 🧪 Tests | +| `ci` | `ci` | 👷 CI/CD | +| `build` / `deps` | `dependencies` | 📦 Dependencies | +| `docs` | `documentation` | (grouped under Other Changes unless a Docs section is added) | +| `chore` / `revert` | `chore` | (excluded from release notes) | + +Append `!` to the type (e.g. `feat(api)!: drop /v1 endpoint`) or include `BREAKING CHANGE:` in the PR body to flag a breaking change — the labeler then adds the `breaking` label and the 💥 Breaking Changes section is rendered first. + +Examples: + +```text +feat(web): add smart chat scroll +fix(extractors): resolve silent contract mis-resolution +perf: avoid O(n²) traversal in heritage walker +chore(deps): bump vitest to 3.0.0 +ci: standardize workflow concurrency +``` + +Commits within a PR may use any style — only the **merged PR title** shows up in release notes, so that's the one the convention applies to. + ## Before you open a PR - [ ] Tests pass for the packages you touched (`gitnexus` and/or `gitnexus-web`). @@ -45,6 +68,41 @@ This project uses the [PolyForm Noncommercial License 1.0.0](https://polyformpro Maintainers may request changes for correctness, tests, performance, or consistency with existing patterns. Keeping diffs focused makes review faster. +## GitHub Actions — Concurrency Convention + +Every workflow under `.github/workflows/` MUST declare a top-level `concurrency:` block using this convention: + +- **Group key** starts with `${{ github.workflow }}` so no two workflows can collide on the same group name. The discriminator that follows is chosen per event shape: + - Branch/tag scope: `${{ github.workflow }}-${{ github.ref }}` + - Per-PR scope (for `issue_comment`, `pull_request_review*`, `pull_request` meta events): `${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number }}` + - `workflow_run` scope (e.g. `ci-report.yml`): `${{ github.workflow }}-${{ github.event.workflow_run.pull_requests[0].number || format('{0}/{1}', github.event.workflow_run.head_repository.full_name, github.event.workflow_run.head_branch) }}` — the fork fallback must be stable across reruns (never `workflow_run.id`, which is per-run-unique and defeats serialization). + - Global single-slot (manual dispatch utilities): `${{ github.workflow }}` + - **Reusable workflows invoked via `workflow_call`:** do NOT use `${{ github.workflow }}` in the group key — in called-workflow context its evaluation is ambiguous and can resolve to the caller's name, which would deadlock against the caller's own group. Use a hardcoded literal prefix and a `github.event_name`-aware expression that falls through to `github.run_id` for reusable invocations (see `ci.yml` for the canonical form). + - **Merge queue (`merge_group`)**: when this event is added, use `${{ github.workflow }}-${{ github.event.merge_group.head_ref }}` with `cancel-in-progress: false` (every queue entry is a distinct ref; never cancel). +- **`cancel-in-progress` policy:** + + | Event | `cancel-in-progress` | Why | + |-------|----------------------|-----| + | `pull_request` CI run | `true` | New push supersedes old run | + | `push` to `main` | `false` | Every main commit gets validated | + | Tag push (`v*` publish) | `false` | Never cancel mid-publish | + | `push` to `main` for release-candidate | `false` | Never cancel mid-RC publish | + | `workflow_dispatch` (release/publish) | `false` | Manual runs are intentional | + | `workflow_run` (sticky-comment reports) | `false` | Serialize, don't race | + | Per-PR bot workflows (`@claude`, review) | `false` | Serialize comments per PR | + | PR-meta re-checks (pr-description-check) | `true` | Cheap, latest wins | + | Single-slot utilities (triage sweep) | `true` | Latest dispatch supersedes | + +- For workflows that serve multiple events at once (e.g. `ci.yml` handles `pull_request`, `push`, and `workflow_call`), make `cancel-in-progress` event-aware: + + ```yaml + concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + ``` + +- When adding a new workflow, copy the concurrency block from an existing workflow of the same event shape. + ## AI-assisted contributions If you use coding agents, follow project context files (e.g. `AGENTS.md`, `CLAUDE.md`) and avoid drive-by refactors unrelated to the issue. Prefer incremental, test-backed changes. From 93cdfb27fa0c726aca87e45342beadedf39036c7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:36:38 +0100 Subject: [PATCH 42/67] chore(deps): bump actions/cache from 5.0.4 to 5.0.5 (#840) Bumps [actions/cache](https://github.com/actions/cache) from 5.0.4 to 5.0.5. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/668228422ae6a00e4ad889ee87cd7109ec5666a7...27d5ce7f107fe9357f9df03efb73ab90386fccae) --- updated-dependencies: - dependency-name: actions/cache dependency-version: 5.0.5 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/triage-sweep.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/triage-sweep.yml b/.github/workflows/triage-sweep.yml index 052e2ca96..43d67828d 100644 --- a/.github/workflows/triage-sweep.yml +++ b/.github/workflows/triage-sweep.yml @@ -76,7 +76,7 @@ jobs: run: pip install -r .github/scripts/triage/requirements.txt - name: Cache FastEmbed model weights - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: ${{ github.workspace }}/.fastembed_cache key: fastembed-bge-small-en-v1.5 From f3df8ab7ba2010c7445e1fecf0cab6fbc528087d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:36:45 +0100 Subject: [PATCH 43/67] chore(deps): bump dorny/paths-filter from 3.0.2 to 4.0.1 (#839) Bumps [dorny/paths-filter](https://github.com/dorny/paths-filter) from 3.0.2 to 4.0.1. - [Release notes](https://github.com/dorny/paths-filter/releases) - [Changelog](https://github.com/dorny/paths-filter/blob/master/CHANGELOG.md) - [Commits](https://github.com/dorny/paths-filter/compare/de90cc6fb38fc0963ad72b210f1f284cd68cea36...fbd0ab8f3e69293af611ebaee6363fc25e6d187d) --- updated-dependencies: - dependency-name: dorny/paths-filter dependency-version: 4.0.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-e2e.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yml index 675d9b382..027d7fdf7 100644 --- a/.github/workflows/ci-e2e.yml +++ b/.github/workflows/ci-e2e.yml @@ -12,7 +12,7 @@ jobs: web_changed: ${{ steps.filter.outputs.web }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 + - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v3 id: filter with: filters: | From 8cb2f278cca6951e37f0c42c2651690810f1b6a6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:36:48 +0100 Subject: [PATCH 44/67] chore(deps): bump actions/setup-node from 4.4.0 to 6.3.0 (#841) Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4.4.0 to 6.3.0. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/49933ea5288caeca8642d1e84afbd3f7d6820020...53b83947a5a98c8d113130e565377fae1a50d02f) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: 6.3.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-quality.yml | 4 ++-- .github/workflows/publish.yml | 2 +- .github/workflows/release-candidate.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-quality.yml b/.github/workflows/ci-quality.yml index 6c3132336..b88712f80 100644 --- a/.github/workflows/ci-quality.yml +++ b/.github/workflows/ci-quality.yml @@ -9,7 +9,7 @@ jobs: timeout-minutes: 5 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: 20 cache: npm @@ -22,7 +22,7 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: 20 cache: npm diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f21c4a7ba..d51af710a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -33,7 +33,7 @@ jobs: id-token: write steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: 20 registry-url: https://registry.npmjs.org diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 7b73080de..0c37c1ac3 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -131,7 +131,7 @@ jobs: fetch-depth: 0 fetch-tags: true - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: 20 registry-url: https://registry.npmjs.org From c2734cd25e3dc009f6b22c6a2ba9e4e51747f58f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:37:10 +0100 Subject: [PATCH 45/67] chore(deps): bump actions/upload-artifact from 4.6.2 to 7.0.1 (#838) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.2 to 7.0.1. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/ea165f8d65b6e75b540449e92b4886f43607fa02...043fb46d1a93c77aae656e7c1c64a875d1fc6a0a) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: 7.0.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-e2e.yml | 2 +- .github/workflows/ci-tests.yml | 2 +- .github/workflows/ci.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yml index 027d7fdf7..190fc9197 100644 --- a/.github/workflows/ci-e2e.yml +++ b/.github/workflows/ci-e2e.yml @@ -74,7 +74,7 @@ jobs: - name: Upload test results if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: e2e-results path: | diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 6a20032c6..31b48e5c1 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -43,7 +43,7 @@ jobs: - name: Upload test reports if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: test-reports path: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1cb3576fa..a1f3abcd6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,7 +85,7 @@ jobs: cp pr-meta/e2e_result pr-meta/e2e-result - name: Upload PR metadata - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: pr-meta path: pr-meta/ From 3fd4346bcb0b99ad68e364fd2c56599281cb21ad Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:52:01 +0100 Subject: [PATCH 46/67] chore(deps): bump actions/checkout from 4.3.1 to 6.0.2 (#842) Bumps [actions/checkout](https://github.com/actions/checkout) from 4.3.1 to 6.0.2. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4.3.1...de0fac2e4500dabe0009e67214ff5f5447ce83dd) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 6.0.2 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-e2e.yml | 4 ++-- .github/workflows/ci-quality.yml | 10 +++++----- .github/workflows/ci-report.yml | 2 +- .github/workflows/ci-tests.yml | 4 ++-- .github/workflows/claude-code-review.yml | 2 +- .github/workflows/claude.yml | 2 +- .github/workflows/publish.yml | 2 +- .github/workflows/release-candidate.yml | 4 ++-- 8 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yml index 190fc9197..a017af628 100644 --- a/.github/workflows/ci-e2e.yml +++ b/.github/workflows/ci-e2e.yml @@ -11,7 +11,7 @@ jobs: outputs: web_changed: ${{ steps.filter.outputs.web }} steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v3 id: filter with: @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/setup-gitnexus-web diff --git a/.github/workflows/ci-quality.yml b/.github/workflows/ci-quality.yml index b88712f80..5a0da5fd1 100644 --- a/.github/workflows/ci-quality.yml +++ b/.github/workflows/ci-quality.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: 20 @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: 20 @@ -34,7 +34,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/setup-gitnexus - run: npx tsc --noEmit working-directory: gitnexus @@ -43,7 +43,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/setup-gitnexus-web - run: npx tsc -b --noEmit working-directory: gitnexus-web @@ -64,7 +64,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Validate workflow concurrency convention shell: bash run: | diff --git a/.github/workflows/ci-report.yml b/.github/workflows/ci-report.yml index fa5023f26..a1fa33219 100644 --- a/.github/workflows/ci-report.yml +++ b/.github/workflows/ci-report.yml @@ -123,7 +123,7 @@ jobs: - name: Checkout (for vitest config) if: steps.meta.outputs.skip != 'true' - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: sparse-checkout: gitnexus/vitest.config.ts sparse-checkout-cone-mode: false diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 31b48e5c1..27eb75383 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 25 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/setup-gitnexus with: build: 'true' @@ -63,7 +63,7 @@ jobs: runs-on: ${{ matrix.os }} timeout-minutes: 25 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/setup-gitnexus with: build: 'true' diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index aaf2f10ec..d9d9decf6 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -77,7 +77,7 @@ jobs: core.setOutput('branch', pr.head.ref); - name: Checkout PR head - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: ${{ steps.pr.outputs.repo }} ref: ${{ steps.pr.outputs.sha }} diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index dd30b72a9..c4a2e9450 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -91,7 +91,7 @@ jobs: core.setOutput('branch', pr.head.ref); - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: ${{ steps.pr.outputs.is_pr == 'true' && steps.pr.outputs.repo || github.repository }} ref: ${{ steps.pr.outputs.is_pr == 'true' && steps.pr.outputs.sha || '' }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d51af710a..03898a267 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -32,7 +32,7 @@ jobs: contents: write id-token: write steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: 20 diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 0c37c1ac3..cc22f4749 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -62,7 +62,7 @@ jobs: should_run: ${{ steps.decide.outputs.should_run }} head_sha: ${{ steps.decide.outputs.head_sha }} steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 fetch-tags: true @@ -126,7 +126,7 @@ jobs: contents: write # push rc tag + marker id-token: write # npm provenance steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 fetch-tags: true From 7a5ab57bd3c48645eec536db315a9e0f6c56c655 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:10:49 +0100 Subject: [PATCH 47/67] fix: add preinstall cleanup to prevent ENOTEMPTY on global upgrade (#843) * Initial plan * fix: add preinstall cleanup for vendor/tree-sitter-proto to prevent ENOTEMPTY on upgrade When upgrading gitnexus globally, npm may fail with ENOTEMPTY because it cannot cleanly remove node_modules/ and build/ directories that a previous installation's file: dependency resolution created inside vendor/tree-sitter-proto/. Add a preinstall script that removes those leftover directories before npm resolves dependencies. Also add .npmignore entries for vendor build artifacts as a belt-and-suspenders measure. Fixes #836 Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/8b7c1fdd-0c20-4cf4-a64a-9e9d1c0b20ed Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: log warnings in preinstall cleanup catch block Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/8b7c1fdd-0c20-4cf4-a64a-9e9d1c0b20ed Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --- gitnexus/.npmignore | 4 +++ gitnexus/package.json | 1 + gitnexus/scripts/preinstall-cleanup.cjs | 34 +++++++++++++++++++++++++ 3 files changed, 39 insertions(+) create mode 100644 gitnexus/scripts/preinstall-cleanup.cjs diff --git a/gitnexus/.npmignore b/gitnexus/.npmignore index 6a4cfb118..cf403314a 100644 --- a/gitnexus/.npmignore +++ b/gitnexus/.npmignore @@ -9,6 +9,10 @@ tsconfig.json .gitignore node_modules/ +# Vendor build artifacts (created during install, not shipped) +vendor/**/node_modules +vendor/**/build + # Package lock (consumers use their own) package-lock.json diff --git a/gitnexus/package.json b/gitnexus/package.json index 6448e716a..a09ff4f41 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -46,6 +46,7 @@ "test:integration": "vitest run test/integration", "test:watch": "vitest", "test:coverage": "vitest run --coverage", + "preinstall": "node scripts/preinstall-cleanup.cjs", "postinstall": "node scripts/patch-tree-sitter-swift.cjs", "prepare": "node scripts/build.js", "prepack": "node scripts/build.js" diff --git a/gitnexus/scripts/preinstall-cleanup.cjs b/gitnexus/scripts/preinstall-cleanup.cjs new file mode 100644 index 000000000..a46de8ac0 --- /dev/null +++ b/gitnexus/scripts/preinstall-cleanup.cjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node +/** + * Preinstall cleanup script. + * + * When upgrading gitnexus globally (`npm install -g gitnexus@`), + * npm may fail with ENOTEMPTY because it cannot cleanly remove the + * `node_modules/` and `build/` directories that a *previous* + * installation's `file:` dependency resolution created inside + * `vendor/tree-sitter-proto/`. + * + * This script runs as a `preinstall` hook — before npm resolves + * dependencies — and removes those leftover directories so npm can + * proceed without errors. + * + * See: https://github.com/abhigyanpatwari/GitNexus/issues/836 + */ +const fs = require('fs'); +const path = require('path'); + +const vendorDirs = [ + path.join(__dirname, '..', 'vendor', 'tree-sitter-proto', 'node_modules'), + path.join(__dirname, '..', 'vendor', 'tree-sitter-proto', 'build'), +]; + +for (const dir of vendorDirs) { + try { + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } catch (err) { + // Best-effort cleanup — warn but don't fail the install. + console.warn(`[preinstall] Could not remove ${dir}:`, err.message); + } +} From eb0d9c51a0f43bab01c7e519980c54ec3898e6ad Mon Sep 17 00:00:00 2001 From: enih Date: Wed, 15 Apr 2026 23:03:43 +0800 Subject: [PATCH 48/67] fix: set env.cacheDir to user-writable location (#845) When @huggingface/transformers is installed globally (e.g. via npm install -g), it defaults its cache directory to ./node_modules/.cache inside its own install dir, which is unwritable by non-root users. This causes EACCES errors on first use when the model is downloaded: EACCES: permission denied, mkdir '/usr/lib/node_modules/gitnexus/node_modules/@huggingface/transformers/.cache' Set env.cacheDir before pipeline() is called in both embedders (CLI and MCP). Respects HF_HOME env var if set, falls back to ~/.cache/huggingface. Co-authored-by: Sisyphus --- gitnexus/src/core/embeddings/embedder.ts | 5 +++++ gitnexus/src/mcp/core/embedder.ts | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/gitnexus/src/core/embeddings/embedder.ts b/gitnexus/src/core/embeddings/embedder.ts index e2d26c9e7..d27718ce5 100644 --- a/gitnexus/src/core/embeddings/embedder.ts +++ b/gitnexus/src/core/embeddings/embedder.ts @@ -157,6 +157,11 @@ export const initEmbedder = async ( try { // Configure transformers.js environment env.allowLocalModels = false; + // Default cache to user-writable location. transformers.js defaults to + // ./node_modules/.cache inside its own install dir, which is unwritable + // when gitnexus is installed globally (e.g. /usr/lib/node_modules/). + // Respect HF_HOME if set, otherwise fall back to ~/.cache/huggingface. + env.cacheDir = process.env.HF_HOME ?? `${process.env.HOME}/.cache/huggingface`; const isDev = process.env.NODE_ENV === 'development'; if (isDev) { diff --git a/gitnexus/src/mcp/core/embedder.ts b/gitnexus/src/mcp/core/embedder.ts index e53a46542..592c2bba9 100644 --- a/gitnexus/src/mcp/core/embedder.ts +++ b/gitnexus/src/mcp/core/embedder.ts @@ -42,6 +42,11 @@ export const initEmbedder = async (): Promise => { initPromise = (async () => { try { env.allowLocalModels = false; + // Default cache to user-writable location. transformers.js defaults to + // ./node_modules/.cache inside its own install dir, which is unwritable + // when gitnexus is installed globally (e.g. /usr/lib/node_modules/). + // Respect HF_HOME if set, otherwise fall back to ~/.cache/huggingface. + env.cacheDir = process.env.HF_HOME ?? `${process.env.HOME}/.cache/huggingface`; console.error('GitNexus: Loading embedding model (first search may take a moment)...'); From fec06b823cd891b32fe3d94f6f51f95ca19c0cc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Wed, 15 Apr 2026 17:38:47 +0100 Subject: [PATCH 49/67] fix: devendor tree-sitter-proto install lifecycle to prevent ENOTEMPTY on global upgrade (#846) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: devendor tree-sitter-proto install lifecycle to fix ENOTEMPTY on global upgrade PR #843's preinstall cleanup hook cannot address the reported bug because it runs on the NEW package's staging tree, not the OLD install being removed. Issue #836 still reproduces on 1.6.2-rc.8. Root cause: vendor/tree-sitter-proto was declared as `file:` dep with its own `dependencies` and `install` script, so npm created `vendor/tree-sitter-proto/node_modules/node-addon-api/` at install time, which blocked npm's rmdir on global upgrade. Changes: - Strip `dependencies` and `install` script from the vendored sub-package's package.json so npm no longer creates a nested node_modules or runs a lifecycle script under vendor/. - Hoist `node-addon-api` and `node-gyp-build` into gitnexus optionalDependencies; npm resolves them at the consumer's top level. - Add scripts/build-tree-sitter-proto.cjs modeled on patch-tree-sitter-swift.cjs. Runs at gitnexus postinstall, best-effort: skips cleanly on missing toolchain or --ignore-scripts so non-proto functionality keeps working. - Remove scripts/preinstall-cleanup.cjs — dead code; cannot run against the old install being removed. - Keep .npmignore entries from PR #843 (tarball hygiene, still correct). - Add explicit .gitignore rules for gitnexus/vendor/**/build and gitnexus/vendor/**/node_modules (closes the repo-side hygiene gap). - Add .github/workflows/ci-global-upgrade.yml: matrix smoke test that installs the previously-published rc globally, upgrades to the packed current branch, and verifies no vendor install-time artifacts survive. Runs on macOS (reporter's platform), Linux, and Windows. Also includes an --ignore-scripts degraded-mode lane. Wired into ci.yml gate. Plan: docs/plans/2026-04-15-002-fix-tree-sitter-proto-vendor-deps-plan.md Phase 1 (this commit) addresses the reported `node_modules/node-addon-api` hazard. Phase 2 (follow-up) will migrate to prebuildify + prebuilt .node binaries in the tarball — the 2026 canonical shape for tree-sitter grammars, which eliminates the postinstall compile path entirely. Refs #836 * fix(ci): ci-global-upgrade should be reusable-only and use setup-gitnexus Three issues caught by CI on PR #846: 1. Concurrency linter rejected the `CIGU-` prefix (allowlist is `${{ github.workflow }}` or substring `CI-`). The literal-prefix guidance in ci.yml is specifically about disambiguating when reusable workflows run in nested contexts, and ci-global-upgrade doesn't need its own concurrency block at all — the caller (ci.yml) already governs concurrency for nested invocations. 2. `npm install` in gitnexus/ runs `prepare: node scripts/build.js`, which depends on gitnexus-shared/dist being built first. Other CI jobs handle this via the setup-gitnexus composite action. Use it here too (with build: 'false' — we only need the dep graph, then npm pack runs prepack which builds gitnexus itself). 3. Removed `pull_request` and `workflow_dispatch` triggers. The workflow is now pure `workflow_call` — invoked once from ci.yml via `uses:`. This avoids the duplicate-run problem where both the top-level pull_request trigger AND the nested workflow_call would fire on every PR. * fix(ci): relax vendor build/ guard and use bash shell on Windows Two fixes for ci-global-upgrade failures on PR #846: 1. The guard after the upgrade step was rejecting vendor/tree-sitter-proto/build/ in the global install. That was too strict. The original #836 bug was about vendor/tree-sitter-proto/node_modules/ specifically, not build/. The build/ directory appears because node-gyp-build compiles through the symlink npm creates at node_modules/gitnexus/node_modules/tree-sitter-proto, and its contents are plain .node, .obj, .lib files that rmdir handles without trouble. We know this empirically because the test got past the upgrade step in the run where the old vendor/node_modules was present. The guard now only flags nested node_modules, which is what the fix actually removes. 2. The Windows --ignore-scripts lane failed with ENOENT when npm tried to open the tarball. The path was computed in a bash step using $(pwd), which on Windows returns /d/a/... form, but npm install ran in the default cmd shell and received a mangled Windows path. Adding shell: bash to the install steps keeps path handling consistent. --- .github/workflows/ci-global-upgrade.yml | 113 +++++++++++++ .github/workflows/ci.yml | 22 ++- .gitignore | 5 + gitnexus/package-lock.json | 154 ++---------------- gitnexus/package.json | 5 +- gitnexus/scripts/build-tree-sitter-proto.cjs | 82 ++++++++++ gitnexus/scripts/preinstall-cleanup.cjs | 34 ---- .../vendor/tree-sitter-proto/package.json | 8 +- 8 files changed, 237 insertions(+), 186 deletions(-) create mode 100644 .github/workflows/ci-global-upgrade.yml create mode 100644 gitnexus/scripts/build-tree-sitter-proto.cjs delete mode 100644 gitnexus/scripts/preinstall-cleanup.cjs diff --git a/.github/workflows/ci-global-upgrade.yml b/.github/workflows/ci-global-upgrade.yml new file mode 100644 index 000000000..e181f46ad --- /dev/null +++ b/.github/workflows/ci-global-upgrade.yml @@ -0,0 +1,113 @@ +name: Global Install Upgrade Smoke + +# Catches regressions where `npm install -g gitnexus@` fails to upgrade +# cleanly over a prior global install. Prior precedent: issue #836 and PR #843's +# incomplete fix slipped past CI because no global-upgrade test existed. +# +# Reusable workflow — only callable from ci.yml. Concurrency is governed by the +# caller (ci.yml), so no `concurrency:` block here. + +on: + workflow_call: + +jobs: + global-upgrade: + name: ${{ matrix.os }} / upgrade over ${{ matrix.prior }} + strategy: + fail-fast: false + matrix: + # macOS is the reporter's platform (issue #836) and the highest-risk + # surface for npm global-install rmdir behavior. Linux and Windows + # provide cross-platform regression coverage. + os: [macos-latest, ubuntu-latest, windows-latest] + # Prior version that must be upgraded OVER. Should be a published rc + # that preceded the fix. Bump when a known-bad version changes. + prior: ['1.6.2-rc.8'] + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: ./.github/actions/setup-gitnexus + with: + build: 'false' + + - name: Install prior published version globally + run: npm install -g gitnexus@${{ matrix.prior }} + + - name: Verify prior version installed + run: gitnexus --version + + - name: Pack current branch + working-directory: gitnexus + run: npm pack + shell: bash + + - name: Compute packed tarball path + id: tarball + working-directory: gitnexus + run: | + TARBALL=$(ls gitnexus-*.tgz | head -1) + echo "path=$(pwd)/$TARBALL" >> "$GITHUB_OUTPUT" + shell: bash + + - name: Upgrade over prior version (the actual regression test) + run: npm install -g "${{ steps.tarball.outputs.path }}" + shell: bash + + - name: Verify upgraded version runs + run: gitnexus --version + + - name: Verify vendor/ has no nested node_modules after install + shell: bash + run: | + # The original #836 bug was about vendor/tree-sitter-proto/node_modules/ + # blocking rmdir on upgrade. That is what the fix eliminates. A + # vendor/tree-sitter-proto/build/ directory can still appear because + # node-gyp-build compiles through the npm-created symlink; the + # contents are plain object files and .node binaries that rmdir + # handles fine, evidenced by this test getting past the upgrade step. + GLOBAL_PREFIX=$(npm root -g) + if [ -d "$GLOBAL_PREFIX/gitnexus/vendor/tree-sitter-proto" ]; then + echo "=== Contents of global vendor/tree-sitter-proto/ ===" + ls -la "$GLOBAL_PREFIX/gitnexus/vendor/tree-sitter-proto/" + if [ -d "$GLOBAL_PREFIX/gitnexus/vendor/tree-sitter-proto/node_modules" ]; then + echo "::error::vendor/tree-sitter-proto/node_modules/ was created — this is the #836 hazard" + exit 1 + fi + fi + + ignore-scripts: + name: ${{ matrix.os }} / --ignore-scripts degraded mode + strategy: + fail-fast: false + matrix: + os: [macos-latest, ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: ./.github/actions/setup-gitnexus + with: + build: 'false' + + - name: Pack current branch + working-directory: gitnexus + run: npm pack + shell: bash + + - name: Compute packed tarball path + id: tarball + working-directory: gitnexus + run: | + TARBALL=$(ls gitnexus-*.tgz | head -1) + echo "path=$(pwd)/$TARBALL" >> "$GITHUB_OUTPUT" + shell: bash + + - name: Install globally with --ignore-scripts + run: npm install -g --ignore-scripts "${{ steps.tarball.outputs.path }}" + shell: bash + + - name: Verify CLI boots without postinstall (proto parsing may be unavailable) + run: gitnexus --version diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1f3abcd6..e2eeeaab2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,11 @@ jobs: permissions: contents: read + global-upgrade: + uses: ./.github/workflows/ci-global-upgrade.yml + permissions: + contents: read + # ── Save PR metadata for the reporting workflow ───────────────── # The ci-report.yml workflow (triggered by workflow_run) needs the # PR number and job results to post a comment. We save them as an @@ -56,7 +61,7 @@ jobs: save-pr-meta: name: Save PR Metadata if: always() && github.event_name == 'pull_request' - needs: [quality, tests, e2e] + needs: [quality, tests, e2e, global-upgrade] runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -67,6 +72,7 @@ jobs: QUALITY: ${{ needs.quality.result }} TESTS: ${{ needs.tests.result }} E2E: ${{ needs.e2e.result }} + GLOBAL_UPGRADE: ${{ needs.global-upgrade.result }} run: | mkdir -p pr-meta echo "$PR_NUMBER" > pr-meta/pr_number @@ -95,7 +101,7 @@ jobs: # Single required check for branch protection. ci-status: name: CI Gate - needs: [quality, tests, e2e] + needs: [quality, tests, e2e, global-upgrade] if: always() runs-on: ubuntu-latest timeout-minutes: 5 @@ -106,10 +112,12 @@ jobs: QUALITY: ${{ needs.quality.result }} TESTS: ${{ needs.tests.result }} E2E: ${{ needs.e2e.result }} + GLOBAL_UPGRADE: ${{ needs.global-upgrade.result }} run: | - echo "Quality: $QUALITY" - echo "Tests: $TESTS" - echo "E2E: $E2E" + echo "Quality: $QUALITY" + echo "Tests: $TESTS" + echo "E2E: $E2E" + echo "Global upgrade: $GLOBAL_UPGRADE" if [[ "$QUALITY" != "success" ]] || [[ "$TESTS" != "success" ]]; then echo "::error::Quality or test jobs failed" @@ -119,3 +127,7 @@ jobs: echo "::error::E2E job failed" exit 1 fi + if [[ "$GLOBAL_UPGRADE" != "success" && "$GLOBAL_UPGRADE" != "skipped" ]]; then + echo "::error::Global upgrade smoke failed" + exit 1 + fi diff --git a/.gitignore b/.gitignore index 4c2df272c..b769da0dc 100644 --- a/.gitignore +++ b/.gitignore @@ -81,6 +81,11 @@ GitNexus.sln # Git worktrees .worktrees/ +# Vendored tree-sitter grammar build artifacts (created at install time, +# never committed). See docs/plans/2026-04-15-002-fix-tree-sitter-proto-vendor-deps-plan.md +gitnexus/vendor/**/build/ +gitnexus/vendor/**/node_modules/ + /github/scripts/triage/__pycache__/ .claude-flow/ diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index bf8b5bb35..def88c93c 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -62,6 +62,8 @@ "node": ">=20.0.0" }, "optionalDependencies": { + "node-addon-api": "^8.0.0", + "node-gyp-build": "^4.8.0", "tree-sitter-dart": "git+https://github.com/UserNobody14/tree-sitter-dart.git#80e23c07b64494f7e21090bb3450223ef0b192f4", "tree-sitter-kotlin": "^0.3.8", "tree-sitter-proto": "file:./vendor/tree-sitter-proto", @@ -1226,6 +1228,12 @@ "win32" ] }, + "node_modules/@ladybugdb/core/node_modules/node-addon-api": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", + "license": "MIT" + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.28.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.28.0.tgz", @@ -4118,10 +4126,13 @@ } }, "node_modules/node-addon-api": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", - "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", - "license": "MIT" + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", + "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } }, "node_modules/node-api-headers": { "version": "1.8.0", @@ -5069,24 +5080,6 @@ } } }, - "node_modules/tree-sitter-c-sharp/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, - "node_modules/tree-sitter-c/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/tree-sitter-cli": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/tree-sitter-cli/-/tree-sitter-cli-0.23.2.tgz", @@ -5121,18 +5114,9 @@ } } }, - "node_modules/tree-sitter-cpp/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/tree-sitter-dart": { "version": "1.0.0", - "resolved": "git+https://github.com/UserNobody14/tree-sitter-dart.git#80e23c07b64494f7e21090bb3450223ef0b192f4", + "resolved": "git+ssh://git@github.com/UserNobody14/tree-sitter-dart.git#80e23c07b64494f7e21090bb3450223ef0b192f4", "integrity": "sha512-Bs/1wAOIJ2akPEXlE/XVpuES19Oo3NqoSJRJ/0N2r38qAd9nTXdqmaGHQ44/JXnA6QHcbgD2YzCCc4wUc98cyQ==", "hasInstallScript": true, "license": "ISC", @@ -5176,15 +5160,6 @@ } } }, - "node_modules/tree-sitter-go/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/tree-sitter-java": { "version": "0.23.5", "resolved": "https://registry.npmjs.org/tree-sitter-java/-/tree-sitter-java-0.23.5.tgz", @@ -5204,15 +5179,6 @@ } } }, - "node_modules/tree-sitter-java/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/tree-sitter-javascript": { "version": "0.23.1", "resolved": "https://registry.npmjs.org/tree-sitter-javascript/-/tree-sitter-javascript-0.23.1.tgz", @@ -5232,15 +5198,6 @@ } } }, - "node_modules/tree-sitter-javascript/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/tree-sitter-kotlin": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/tree-sitter-kotlin/-/tree-sitter-kotlin-0.3.8.tgz", @@ -5287,15 +5244,6 @@ } } }, - "node_modules/tree-sitter-php/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/tree-sitter-proto": { "resolved": "vendor/tree-sitter-proto", "link": true @@ -5319,15 +5267,6 @@ } } }, - "node_modules/tree-sitter-python/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/tree-sitter-ruby": { "version": "0.23.1", "resolved": "https://registry.npmjs.org/tree-sitter-ruby/-/tree-sitter-ruby-0.23.1.tgz", @@ -5347,15 +5286,6 @@ } } }, - "node_modules/tree-sitter-ruby/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/tree-sitter-rust": { "version": "0.23.1", "resolved": "https://registry.npmjs.org/tree-sitter-rust/-/tree-sitter-rust-0.23.1.tgz", @@ -5375,15 +5305,6 @@ } } }, - "node_modules/tree-sitter-rust/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/tree-sitter-swift": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tree-sitter-swift/-/tree-sitter-swift-0.6.0.tgz", @@ -5413,16 +5334,6 @@ "license": "ISC", "optional": true }, - "node_modules/tree-sitter-swift/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "optional": true, - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/tree-sitter-swift/node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5459,24 +5370,6 @@ } } }, - "node_modules/tree-sitter-typescript/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, - "node_modules/tree-sitter/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -5884,26 +5777,11 @@ }, "vendor/tree-sitter-proto": { "version": "0.4.1", - "hasInstallScript": true, "license": "MIT", "optional": true, - "dependencies": { - "node-addon-api": "^8.0.0", - "node-gyp-build": "^4.8.0" - }, "peerDependencies": { "tree-sitter": ">=0.21.0" } - }, - "vendor/tree-sitter-proto/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "optional": true, - "engines": { - "node": "^18 || ^20 || >= 21" - } } } } diff --git a/gitnexus/package.json b/gitnexus/package.json index a09ff4f41..ad075041d 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -46,8 +46,7 @@ "test:integration": "vitest run test/integration", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "preinstall": "node scripts/preinstall-cleanup.cjs", - "postinstall": "node scripts/patch-tree-sitter-swift.cjs", + "postinstall": "node scripts/patch-tree-sitter-swift.cjs && node scripts/build-tree-sitter-proto.cjs", "prepare": "node scripts/build.js", "prepack": "node scripts/build.js" }, @@ -85,6 +84,8 @@ "uuid": "^13.0.0" }, "optionalDependencies": { + "node-addon-api": "^8.0.0", + "node-gyp-build": "^4.8.0", "tree-sitter-dart": "git+https://github.com/UserNobody14/tree-sitter-dart.git#80e23c07b64494f7e21090bb3450223ef0b192f4", "tree-sitter-kotlin": "^0.3.8", "tree-sitter-proto": "file:./vendor/tree-sitter-proto", diff --git a/gitnexus/scripts/build-tree-sitter-proto.cjs b/gitnexus/scripts/build-tree-sitter-proto.cjs new file mode 100644 index 000000000..d2828d5ba --- /dev/null +++ b/gitnexus/scripts/build-tree-sitter-proto.cjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +/** + * Build tree-sitter-proto native binding. + * + * Why this script exists: + * tree-sitter-proto is vendored under gitnexus/vendor/tree-sitter-proto/ + * and declared as a `file:` optionalDependency. Previously, the vendored + * package had its own `dependencies` and `install` script, which caused + * npm to create `vendor/tree-sitter-proto/node_modules/` and + * `vendor/tree-sitter-proto/build/` during install. Those directories + * blocked `rmdir` on global-install upgrade, producing: + * + * ENOTEMPTY: directory not empty, rmdir + * '.../gitnexus/vendor/tree-sitter-proto/node_modules/node-addon-api' + * + * (See https://github.com/abhigyanpatwari/GitNexus/issues/836.) + * + * We stripped `dependencies` and the `install` script from the vendored + * package.json, hoisted `node-addon-api` and `node-gyp-build` into + * gitnexus's own optionalDependencies, and moved native compilation here. + * + * What this does: + * Runs `npx node-gyp rebuild` inside `node_modules/tree-sitter-proto/` + * (which npm creates as a copy of vendor/tree-sitter-proto/ when + * resolving the file: dep). Build output lands in + * `node_modules/tree-sitter-proto/build/Release/tree_sitter_proto_binding.node` + * — under npm-managed territory, safe on upgrade. + * + * Mirrors scripts/patch-tree-sitter-swift.cjs. Best-effort: if any + * precondition fails (optional dep absent, no toolchain, --ignore-scripts), + * warn and exit 0 so gitnexus install still succeeds. + */ +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +const protoDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-proto'); +const bindingGyp = path.join(protoDir, 'binding.gyp'); +const bindingNode = path.join(protoDir, 'build', 'Release', 'tree_sitter_proto_binding.node'); + +try { + if (!fs.existsSync(bindingGyp)) { + // tree-sitter-proto is an optionalDependency; absent when install + // skipped optional deps or the file: dep was not resolved. + process.exit(0); + } + + // Skip if the native binding already exists (idempotent re-run). + if (fs.existsSync(bindingNode)) { + process.exit(0); + } + + // Pre-flight: the hoisted build deps must be resolvable. + try { + require.resolve('node-addon-api'); + require.resolve('node-gyp-build'); + } catch (resolveErr) { + console.warn( + '[tree-sitter-proto] Skipping build: hoisted build deps not resolvable (%s).', + resolveErr.message, + ); + console.warn( + '[tree-sitter-proto] Proto parsing will be unavailable. Install without --no-optional and with scripts enabled to build.', + ); + process.exit(0); + } + + console.log('[tree-sitter-proto] Building native binding...'); + execSync('npx node-gyp rebuild', { + cwd: protoDir, + stdio: 'pipe', + timeout: 180000, + }); + console.log('[tree-sitter-proto] Native binding built successfully'); +} catch (err) { + console.warn('[tree-sitter-proto] Could not build native binding:', err.message); + console.warn( + '[tree-sitter-proto] Proto (.proto) parsing will be unavailable. Non-proto gitnexus functionality is unaffected.', + ); + // Exit 0: optionalDependency failures must not fail the gitnexus install. + process.exit(0); +} diff --git a/gitnexus/scripts/preinstall-cleanup.cjs b/gitnexus/scripts/preinstall-cleanup.cjs deleted file mode 100644 index a46de8ac0..000000000 --- a/gitnexus/scripts/preinstall-cleanup.cjs +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env node -/** - * Preinstall cleanup script. - * - * When upgrading gitnexus globally (`npm install -g gitnexus@`), - * npm may fail with ENOTEMPTY because it cannot cleanly remove the - * `node_modules/` and `build/` directories that a *previous* - * installation's `file:` dependency resolution created inside - * `vendor/tree-sitter-proto/`. - * - * This script runs as a `preinstall` hook — before npm resolves - * dependencies — and removes those leftover directories so npm can - * proceed without errors. - * - * See: https://github.com/abhigyanpatwari/GitNexus/issues/836 - */ -const fs = require('fs'); -const path = require('path'); - -const vendorDirs = [ - path.join(__dirname, '..', 'vendor', 'tree-sitter-proto', 'node_modules'), - path.join(__dirname, '..', 'vendor', 'tree-sitter-proto', 'build'), -]; - -for (const dir of vendorDirs) { - try { - if (fs.existsSync(dir)) { - fs.rmSync(dir, { recursive: true, force: true }); - } - } catch (err) { - // Best-effort cleanup — warn but don't fail the install. - console.warn(`[preinstall] Could not remove ${dir}:`, err.message); - } -} diff --git a/gitnexus/vendor/tree-sitter-proto/package.json b/gitnexus/vendor/tree-sitter-proto/package.json index 387f3d9bb..aea236ea3 100644 --- a/gitnexus/vendor/tree-sitter-proto/package.json +++ b/gitnexus/vendor/tree-sitter-proto/package.json @@ -5,14 +5,8 @@ "repository": "https://github.com/coder3101/tree-sitter-proto", "license": "MIT", "main": "bindings/node", - "scripts": { - "install": "node-gyp-build" - }, + "_vendoredBy": "gitnexus — build deps (node-addon-api, node-gyp-build) are hoisted into gitnexus/package.json optionalDependencies, and native compilation is performed by gitnexus/scripts/build-tree-sitter-proto.cjs at gitnexus postinstall. Do NOT re-add a dependencies block or an install script here — doing so reintroduces https://github.com/abhigyanpatwari/GitNexus/issues/836 (ENOTEMPTY on global upgrade).", "peerDependencies": { "tree-sitter": ">=0.21.0" - }, - "dependencies": { - "node-addon-api": "^8.0.0", - "node-gyp-build": "^4.8.0" } } From b43cb5ee5552900fee9db7cfe78e0ff99b242471 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:17:03 +0000 Subject: [PATCH 50/67] chore(deps): bump softprops/action-gh-release from 2.5.0 to 3.0.0 Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.5.0 to 3.0.0. - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/a06a81a03ee405af7f2048a818ed3f03bbf83c7b...b4309332981a82ec1c5618f44dd2e27cc8bfbfda) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-version: 3.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/publish.yml | 2 +- .github/workflows/release-candidate.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 03898a267..3d883425a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -91,7 +91,7 @@ jobs: fi - name: Create GitHub Release - uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v2 with: body_path: ${{ steps.changelog.outputs.fallback == 'false' && '/tmp/release-notes.md' || '' }} generate_release_notes: ${{ steps.changelog.outputs.fallback == 'true' }} diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index cc22f4749..d4db75db0 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -346,7 +346,7 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Create GitHub prerelease - uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v2 with: tag_name: ${{ steps.reltag.outputs.vtag }} name: Release Candidate ${{ steps.reltag.outputs.vtag }} From df429ea60c896719edf297fab43201b52ceef96e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:17:08 +0000 Subject: [PATCH 51/67] chore(deps): bump actions/github-script from 7.0.1 to 9.0.0 Bumps [actions/github-script](https://github.com/actions/github-script) from 7.0.1 to 9.0.0. - [Release notes](https://github.com/actions/github-script/releases) - [Commits](https://github.com/actions/github-script/compare/v7.0.1...3a2844b7e9c422d3c10d287c895573f7108da1b3) --- updated-dependencies: - dependency-name: actions/github-script dependency-version: 9.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci-report.yml | 4 ++-- .github/workflows/claude-code-review.yml | 2 +- .github/workflows/claude.yml | 2 +- .github/workflows/pr-description-check.yml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-report.yml b/.github/workflows/ci-report.yml index a1fa33219..42088dc04 100644 --- a/.github/workflows/ci-report.yml +++ b/.github/workflows/ci-report.yml @@ -36,7 +36,7 @@ jobs: steps: # ── Download artifacts from the CI run ──────────────────────── - name: Download artifacts - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v7 with: script: | const fs = require('fs'); @@ -132,7 +132,7 @@ jobs: - name: Fetch base branch coverage if: steps.meta.outputs.skip != 'true' id: base-coverage - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v7 with: script: | const fs = require('fs'); diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index d9d9decf6..e5642cb3e 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -57,7 +57,7 @@ jobs: # For issue_comment triggers, resolve the PR number, head SHA, and fork repo - name: Resolve PR context id: pr - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v7 with: script: | let pr; diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index c4a2e9450..553d3ab0d 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -59,7 +59,7 @@ jobs: # For PR-related triggers, resolve the fork repo so we can checkout correctly. - name: Resolve PR context id: pr - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v7 with: script: | // Determine if this event is PR-related diff --git a/.github/workflows/pr-description-check.yml b/.github/workflows/pr-description-check.yml index de562fd35..cec32de0f 100644 --- a/.github/workflows/pr-description-check.yml +++ b/.github/workflows/pr-description-check.yml @@ -19,7 +19,7 @@ jobs: timeout-minutes: 5 steps: - name: Check PR description quality - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const MIN_BODY_LENGTH = 50; From ed07c18b8d2bf66e6e230a795ebdf67b3adab7be Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:17:10 +0000 Subject: [PATCH 52/67] chore(deps): bump marocchino/sticky-pull-request-comment Bumps [marocchino/sticky-pull-request-comment](https://github.com/marocchino/sticky-pull-request-comment) from 2.9.4 to 3.0.4. - [Release notes](https://github.com/marocchino/sticky-pull-request-comment/releases) - [Commits](https://github.com/marocchino/sticky-pull-request-comment/compare/773744901bac0e8cbb5a0dc842800d45e9b2b405...0ea0beb66eb9baf113663a64ec522f60e49231c0) --- updated-dependencies: - dependency-name: marocchino/sticky-pull-request-comment dependency-version: 3.0.4 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci-report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-report.yml b/.github/workflows/ci-report.yml index a1fa33219..9deb62746 100644 --- a/.github/workflows/ci-report.yml +++ b/.github/workflows/ci-report.yml @@ -416,7 +416,7 @@ jobs: - name: Comment on PR if: steps.meta.outputs.skip != 'true' - uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2 + uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v2 with: header: ci-report number: ${{ steps.meta.outputs.pr_number }} From 7001e8e4b4ea13ac28c59fcbe636e03d18fa4787 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:17:15 +0000 Subject: [PATCH 53/67] chore(deps): bump release-drafter/release-drafter from 6.0.0 to 7.2.0 Bumps [release-drafter/release-drafter](https://github.com/release-drafter/release-drafter) from 6.0.0 to 7.2.0. - [Release notes](https://github.com/release-drafter/release-drafter/releases) - [Commits](https://github.com/release-drafter/release-drafter/compare/3f0f87098bd6b5c5b9a36d49c41d998ea58f9348...5de93583980a40bd78603b6dfdcda5b4df377b32) --- updated-dependencies: - dependency-name: release-drafter/release-drafter dependency-version: 7.2.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/pr-labeler.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index aab2fcaef..43aeba311 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -105,7 +105,7 @@ jobs: # Pinned to v6.0.0. Verify SHA via: # gh api repos/release-drafter/release-drafter/git/refs/tags/v6.0.0 # Note: dependabot will likely propose a bump to v6.x on first run. - - uses: release-drafter/release-drafter@3f0f87098bd6b5c5b9a36d49c41d998ea58f9348 # v6.0.0 + - uses: release-drafter/release-drafter@5de93583980a40bd78603b6dfdcda5b4df377b32 # v7.2.0 with: config-name: release-drafter.yml disable-releaser: true From 1d0fb782a3bb1ce7b429c42c53600d4d6e72fc23 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:17:22 +0000 Subject: [PATCH 54/67] chore(deps): bump amannn/action-semantic-pull-request Bumps [amannn/action-semantic-pull-request](https://github.com/amannn/action-semantic-pull-request) from 5.5.3 to 6.1.1. - [Release notes](https://github.com/amannn/action-semantic-pull-request/releases) - [Changelog](https://github.com/amannn/action-semantic-pull-request/blob/main/CHANGELOG.md) - [Commits](https://github.com/amannn/action-semantic-pull-request/compare/0723387faaf9b38adef4775cd42cfd5155ed6017...48f256284bd46cdaab1048c3721360e808335d50) --- updated-dependencies: - dependency-name: amannn/action-semantic-pull-request dependency-version: 6.1.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/pr-labeler.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index aab2fcaef..3896fcb26 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -59,7 +59,7 @@ jobs: steps: # Pinned to v5.5.3. Verify SHA via: # gh api repos/amannn/action-semantic-pull-request/git/refs/tags/v5.5.3 - - uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3 + - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: From 54d02fcc221e878aaa6a4622d822a4a3793f559e Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Thu, 16 Apr 2026 07:48:21 +0100 Subject: [PATCH 55/67] fix(ci): replace removed disable-releaser with dry-run for release-drafter v7 release-drafter v7 (merged in #852) removed the `disable-releaser` input, causing the autolabel job to attempt creating a release and fail with "Resource not accessible by integration". Replace with `dry-run: true` which achieves the same label-only behavior. Also update stale version comments for release-drafter and action-semantic-pull-request to match the actual pinned versions. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/pr-labeler.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index 384587a87..3c0c52725 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -12,7 +12,7 @@ name: PR Conventional Labeler # autolabel (on: pull_request_target) # Needs `pull-requests: write` to apply labels, so must be # pull_request_target. Uses `release-drafter/release-drafter` with -# `disable-releaser: true` to only run the autolabeler against the +# `dry-run: true` to only run the autolabeler against the # `.github/release-drafter.yml` config from the BASE ref (release- # drafter reads the config from the repository's default branch, NOT # the PR head — verify with `gh api repos/release-drafter/release-drafter/contents/...` @@ -57,8 +57,8 @@ jobs: permissions: pull-requests: read steps: - # Pinned to v5.5.3. Verify SHA via: - # gh api repos/amannn/action-semantic-pull-request/git/refs/tags/v5.5.3 + # Pinned to v6.1.1. Verify SHA via: + # gh api repos/amannn/action-semantic-pull-request/git/refs/tags/v6.1.1 - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -102,12 +102,12 @@ jobs: contents: read pull-requests: write steps: - # Pinned to v6.0.0. Verify SHA via: - # gh api repos/release-drafter/release-drafter/git/refs/tags/v6.0.0 - # Note: dependabot will likely propose a bump to v6.x on first run. + # Pinned to v7.2.0. Verify SHA via: + # gh api repos/release-drafter/release-drafter/git/refs/tags/v7.2.0 + # v7 removed `disable-releaser`; use `dry-run: true` to only autolabel. - uses: release-drafter/release-drafter@5de93583980a40bd78603b6dfdcda5b4df377b32 # v7.2.0 with: config-name: release-drafter.yml - disable-releaser: true + dry-run: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 0a4b31b3c52f94a9d20c456abfef3c8b50771dc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Thu, 16 Apr 2026 08:43:11 +0100 Subject: [PATCH 56/67] docs: optimize context files for LLM accuracy and token efficiency (#857) * docs: optimize context files for LLM accuracy and token efficiency Fix factual errors across all five root context files and optimize for LLM context window efficiency. Corrections: - Web UI: "runs entirely in WASM" -> thin client backed by HTTP API - Pre-commit hook: "typecheck + tests" -> formatting + typecheck only - MCP tools: 7 -> 16 (added api_impact, route_map, tool_map, shape_check, group_list/query/sync/contracts/status) - Default serve port: 3741 -> 4747 - E2E tests: "5 tests" -> 7 spec files - ESLint: "no config" -> eslint.config.mjs exists with TS/React rules - npm test: "vitest run test/unit" -> "vitest run" (full suite) - Removed nonexistent test:all script - ci-quality.yml: added missing format + lint job descriptions - Pipeline phase deps: added missing structure dep on mro/communities/processes - Ingestion entry: added missing run-analyze.ts intermediate orchestrator - Tools Quick Reference: added missing list_repos - Group tool examples: fixed param name (group -> name) - Removed stale vite-plugin-wasm gotcha - Added gitnexus-shared to repository layout tables New documentation: - ARCHITECTURE.md: language-agnostic graph feeding (provider pattern, unified capture tags, import resolution tiers, chunked parse, MRO) - ARCHITECTURE.md: full analysis flow (10 stages with progress %) - ARCHITECTURE.md: storage layout, LadybugDB schema, embeddings, search - ARCHITECTURE.md: DAG runner internals (Kahn's sort, dep isolation, error handling) Token optimization: - Removed filler prose, compressed descriptions into dense tables - Front-loaded key facts in every section - Eliminated redundancy between sections - AGENTS.md: 219 -> 201 lines. ARCHITECTURE.md: 192 -> 298 lines (more info in fewer tokens via tables and structure) * docs: optimize GUARDRAILS.md for LLM context efficiency Tighten prose without losing information: - Compressed intro, scope section, and Signs format labels - Shortened Sign headers (removed "Sign:" prefix) - Replaced verbose "Instruction/Reason" labels with "Do/Why" - Removed trailing whitespace and redundant emphasis --- AGENTS.md | 202 ++++++++++++++-------------- ARCHITECTURE.md | 349 ++++++++++++++++++++++++++++++++---------------- CONTRIBUTING.md | 2 +- GUARDRAILS.md | 89 ++++++------ TESTING.md | 13 +- 5 files changed, 384 insertions(+), 271 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 651657b02..f212fae0c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,117 +1,120 @@ - - + + -Last reviewed: 2026-04-13 +Last reviewed: 2026-04-16 **Project:** GitNexus · **Environment:** dev · **Maintainer:** repository maintainers (see GitHub) -This file uses a standard agent header (version, scope, model policy, reference docs, changelog), adapted for this **TypeScript/JavaScript monorepo**. - ## Scope -| | | -|--|--| -| **Reads** | Repository tree as needed for the task: `gitnexus/`, `gitnexus-web/`, `eval/`, plugin packages, `.github/`, `.gitnexus/` when present, and docs. | -| **Writes** | Only paths required for the requested change; keep diffs minimal. Update lockfiles when dependencies change. | -| **Executes** | `npm`, `npx`, `node` under `gitnexus/` and `gitnexus-web/`; `uv run` for Python under `eval/` when applicable; shell utilities for documented CI/dev workflows. | -| **Off-limits** | User secrets (e.g. real `.env`), production deployment credentials, unrelated repositories, destructive git history operations without explicit human confirmation. | +| Boundary | Rule | +|----------|------| +| **Reads** | `gitnexus/`, `gitnexus-web/`, `eval/`, plugin packages, `.github/`, `.gitnexus/`, docs. | +| **Writes** | Only paths required for the change; keep diffs minimal. Update lockfiles when deps change. | +| **Executes** | `npm`, `npx`, `node` under `gitnexus/` and `gitnexus-web/`; `uv run` for Python under `eval/`; documented CI/dev workflows. | +| **Off-limits** | Real `.env` / secrets, production credentials, unrelated repos, destructive git ops without confirmation. | ## Model Configuration -- **Primary:** Pin in **Cursor** (Settings → model). Use a **named** model (e.g. GPT-5.2, Claude Sonnet 4.x). Avoid relying on **Auto** when reproducibility or audit trail matters. -- **Fallback:** As configured in Cursor or your organization (do not encode `latest` or wildcards in automation configs). -- **Notes:** The open-source GitNexus CLI indexer does not call an LLM. Optional Nexus AI in the web UI uses end-user provider keys and models. +- **Primary:** Use a named model (e.g. Claude Sonnet 4.x). Avoid `Auto` or unversioned `latest` when reproducibility matters. +- **Notes:** The GitNexus CLI indexer does not call an LLM. ## Execution Sequence (complex tasks) -Long sessions dilute instructions. For **multi-step** work, state up front: - +For multi-step work, state up front: 1. Which rules in this file and **[GUARDRAILS.md](GUARDRAILS.md)** apply (and any relevant Signs). -2. Current **Scope** boundaries (Reads / Writes / Off-limits). -3. Which **validation commands** you will run (e.g. `cd gitnexus && npm test`, `npx tsc --noEmit`). +2. Current **Scope** boundaries. +3. Which **validation commands** you will run (`cd gitnexus && npm test`, `npx tsc --noEmit`). -On very long threads, the human may add *“Remember: apply all AGENTS.md rules”* to re-weight rule tokens against context dilution. +On long threads, *"Remember: apply all AGENTS.md rules"* re-weights these instructions against context dilution. ## Claude Code hooks -Hooks enforce gates that prompts cannot. In **Claude Code**, **PreToolUse** hooks can block tools such as `git_commit` until checks pass. Adapt to this repo: e.g. `cd gitnexus && npm test` before commit. +**PreToolUse** hooks can block tools (e.g. `git_commit`) until checks pass. Adapt to this repo: `cd gitnexus && npm test` before commit. -## Context budget (Cursor / standards) +## Context budget -Generic “core standards” playbooks are often long and stack-specific. For this monorepo, commands and gotchas live under **Cursor Cloud specific instructions** below and in **[CONTRIBUTING.md](CONTRIBUTING.md)**. If always-on rules grow, split domain rules into **`.cursor/rules/*.mdc`** (globs). **Cursor:** project-wide rules live in **`.cursor/index.mdc`** (YAML frontmatter with `alwaysApply: true`). **Claude Code:** optionally load a **`STANDARDS.md`** only when needed (e.g. *“When writing new code, read STANDARDS.md”*) to save context. +Commands and gotchas live under **Repo reference** below and in **[CONTRIBUTING.md](CONTRIBUTING.md)**. If always-on rules grow, split into **`.cursor/rules/*.mdc`** (globs). **Cursor:** project-wide rules in `.cursor/index.mdc`. **Claude Code:** load `STANDARDS.md` only when needed. -## Reference Documentation +## Reference docs -- **This repository:** **[ARCHITECTURE.md](ARCHITECTURE.md)**, **[CONTRIBUTING.md](CONTRIBUTING.md)**, **[GUARDRAILS.md](GUARDRAILS.md)**. -- **Cursor:** `.cursor/index.mdc` (always-on rules); optional `.cursor/rules/*.mdc` (glob-scoped). Legacy `.cursorrules` is deprecated — see `.cursor/index.mdc`. -- **Optional local files:** `NOTES.md` (short vendor-neutral project snapshot). For handoffs, keep notes local (e.g., a scratch file outside the repo) rather than committing `HANDOFF.md`. -- **GitNexus:** skills under `.claude/skills/gitnexus/`; machine-oriented rules in the `gitnexus:start` … `gitnexus:end` block below. +- **[ARCHITECTURE.md](ARCHITECTURE.md)**, **[CONTRIBUTING.md](CONTRIBUTING.md)**, **[GUARDRAILS.md](GUARDRAILS.md)** +- **Cursor:** `.cursor/index.mdc` (always-on); `.cursor/rules/*.mdc` (glob-scoped). Legacy `.cursorrules` deprecated. +- **GitNexus:** skills in `.claude/skills/gitnexus/`; MCP rules in `gitnexus:start` block below. ## Changelog | Date | Version | Change | |------|---------|--------| +| 2026-04-16 | 1.4.0 | Fixed: web UI description, pre-commit behavior, MCP tools (7->16), added gitnexus-shared, removed stale vite-plugin-wasm gotcha. | | 2026-04-13 | 1.3.0 | Updated GitNexus index stats after DAG refactor. | -| 2026-03-24 | 1.2.0 | Fixed gitnexus:start block duplication (was inlined in Reference Docs bullet). | -| 2026-03-23 | 1.1.0 | Updated agent instructions (sections, references, Cursor layout). | -| 2026-03-22 | 1.0.0 | Added structured agent header and changelog. | +| 2026-03-24 | 1.2.0 | Fixed gitnexus:start block duplication. | +| 2026-03-23 | 1.1.0 | Updated agent instructions, references, Cursor layout. | +| 2026-03-22 | 1.0.0 | Initial structured header and changelog. | --- # GitNexus — Code Intelligence -This project is indexed by GitNexus as **GitNexus** (4325 symbols, 10556 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +Indexed as **GitNexus** (4325 symbols, 10556 relationships, 300 execution flows). Use MCP tools to understand code, assess impact, and navigate safely. -> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. +> If any tool warns the index is stale, run `npx gitnexus analyze` first. ## Always Do -- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. -- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. -- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. -- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. -- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. +- **MUST run impact analysis before editing any symbol.** `gitnexus_impact({target: "symbolName", direction: "upstream"})` — report blast radius to the user. +- **MUST run `gitnexus_detect_changes()` before committing** — verify only expected symbols and flows are affected. +- **MUST warn the user** if impact returns HIGH or CRITICAL risk. +- Explore unfamiliar code with `gitnexus_query({query: "concept"})` (process-grouped, ranked) instead of grepping. +- Full context on a symbol: `gitnexus_context({name: "symbolName"})`. ## When Debugging -1. `gitnexus_query({query: ""})` — find execution flows related to the issue -2. `gitnexus_context({name: ""})` — see all callers, callees, and process participation -3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step -4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed +1. `gitnexus_query({query: ""})` — find related execution flows +2. `gitnexus_context({name: ""})` — callers, callees, process participation +3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace flow step by step +4. Regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` ## When Refactoring -- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`. -- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code. -- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed. +- **Rename:** `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Graph edits are safe; text_search edits need manual review. +- **Extract/Split:** `gitnexus_context` (incoming/outgoing refs) then `gitnexus_impact` (upstream callers) before moving code. +- **After any refactor:** `gitnexus_detect_changes({scope: "all"})` to verify scope. ## Never Do -- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. -- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. -- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. -- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. +- Edit a symbol without running `gitnexus_impact` first. +- Ignore HIGH/CRITICAL risk warnings. +- Rename with find-and-replace — use `gitnexus_rename`. +- Commit without `gitnexus_detect_changes()`. ## Tools Quick Reference -| Tool | When to use | Command | +| Tool | When to use | Example | |------|-------------|---------| +| `list_repos` | Discover indexed repos | `gitnexus_list_repos({})` | | `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | | `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | | `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | | `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | | `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | | `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | +| `api_impact` | Pre-change API route impact | `gitnexus_api_impact({route: "/api/users", method: "GET"})` | +| `route_map` | Route → handler → consumer map | `gitnexus_route_map({})` | +| `tool_map` | MCP/RPC tool definitions | `gitnexus_tool_map({})` | +| `shape_check` | Response shape vs consumer access | `gitnexus_shape_check({route: "/api/users"})` | +| `group_list` | List repo groups | `gitnexus_group_list({})` | +| `group_query` | Cross-repo search in a group | `gitnexus_group_query({name: "myGroup", query: "auth"})` | +| `group_sync` | Rebuild group Contract Registry | `gitnexus_group_sync({name: "myGroup"})` | +| `group_contracts` | Inspect group contracts | `gitnexus_group_contracts({name: "myGroup"})` | +| `group_status` | Group staleness report | `gitnexus_group_status({name: "myGroup"})` | ## Impact Risk Levels | Depth | Meaning | Action | |-------|---------|--------| -| d=1 | WILL BREAK — direct callers/importers | MUST update these | +| d=1 | WILL BREAK — direct callers/importers | MUST update | | d=2 | LIKELY AFFECTED — indirect deps | Should test | | d=3 | MAY NEED TESTING — transitive | Test if critical path | @@ -119,87 +122,80 @@ This project is indexed by GitNexus as **GitNexus** (4325 symbols, 10556 relatio | Resource | Use for | |----------|---------| -| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness | +| `gitnexus://repo/GitNexus/context` | Codebase overview, index freshness | | `gitnexus://repo/GitNexus/clusters` | All functional areas | | `gitnexus://repo/GitNexus/processes` | All execution flows | | `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace | ## Self-Check Before Finishing -Before completing any code modification task, verify: 1. `gitnexus_impact` was run for all modified symbols -2. No HIGH/CRITICAL risk warnings were ignored -3. `gitnexus_detect_changes()` confirms changes match expected scope -4. All d=1 (WILL BREAK) dependents were updated +2. No HIGH/CRITICAL warnings were ignored +3. `gitnexus_detect_changes()` confirms expected scope +4. All d=1 dependents were updated ## Keeping the Index Fresh -After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it: - ```bash -npx gitnexus analyze +npx gitnexus analyze # basic refresh +npx gitnexus analyze --embeddings # preserve embeddings ``` -If the index previously included embeddings, preserve them by adding `--embeddings`: +Check `.gitnexus/meta.json` `stats.embeddings` (0 = none). Running without `--embeddings` deletes existing vectors. -```bash -npx gitnexus analyze --embeddings -``` +> Claude Code: PostToolUse hook handles this after `git commit` and `git merge`. -To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.** +## CLI Skills -> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`. - -## CLI - -| Task | Read this skill file | -|------|---------------------| -| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | -| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | -| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | -| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | -| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | -| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | +| Task | Skill file | +|------|-----------| +| Architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | +| Blast radius / "What breaks?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | +| Debugging / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | +| Refactoring | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | +| Tools/resources/schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | +| CLI commands (index, status, clean, wiki) | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | -## Cursor Cloud specific instructions +## Repo reference -### Repository structure +### Packages -This is a monorepo with two main products and supporting config packages: - -| Component | Path | Purpose | -|-----------|------|---------| -| **GitNexus CLI/Core** | `gitnexus/` | Main product — TypeScript CLI, indexing pipeline, MCP server. Published to npm. | -| **GitNexus Web UI** | `gitnexus-web/` | React/Vite browser app — graph explorer + AI chat. Runs entirely in WASM. | -| Claude Plugin | `gitnexus-claude-plugin/` | Static config for Claude marketplace (no build). | -| Cursor Integration | `gitnexus-cursor-integration/` | Static config for Cursor editor (no build). | -| SWE-bench Eval | `eval/` | Python evaluation harness (optional; needs Docker + LLM API keys). | +| Package | Path | Purpose | +|---------|------|---------| +| **CLI/Core** | `gitnexus/` | TypeScript CLI, indexing pipeline, MCP server. Published to npm. | +| **Web UI** | `gitnexus-web/` | React/Vite thin client. All queries via `gitnexus serve` HTTP API. | +| **Shared** | `gitnexus-shared/` | Shared TypeScript types and constants. | +| Claude Plugin | `gitnexus-claude-plugin/` | Static config for Claude marketplace. | +| Cursor Integration | `gitnexus-cursor-integration/` | Static config for Cursor editor. | +| Eval | `eval/` | Python evaluation harness (Docker + LLM API keys). | ### Running services -- **CLI/Core**: `cd gitnexus && npm run dev` (tsx watch mode) or `npm run build && node dist/cli/index.js ` -- **Web UI**: `cd gitnexus-web && npm run dev` (Vite on port 5173) -- **Backend mode**: `cd && node /workspace/gitnexus/dist/cli/index.js serve` (HTTP API on port 3741 by default) +```bash +cd gitnexus && npm run dev # CLI: tsx watch mode +cd gitnexus-web && npm run dev # Web UI: Vite on port 5173 +npx gitnexus serve # HTTP API on port 4747 (from any indexed repo) +``` ### Testing **CLI / Core (`gitnexus/`)** -- **Unit tests**: `cd gitnexus && npm test` (vitest, ~2000 tests) -- **Integration tests**: `cd gitnexus && npm run test:integration` (vitest, ~1850 tests). Two LadybugDB file-locking tests (`lbug-core-adapter`, `search-core`) may fail in containerized environments due to `/tmp` locking limitations — this is a known environment issue, not a code bug. -- **TypeScript check**: `cd gitnexus && npx tsc --noEmit` +- `npm test` — full vitest suite (~2000 tests) +- `npm run test:unit` — unit tests only +- `npm run test:integration` — integration (~1850 tests). LadybugDB file-locking tests may fail in containers (known env issue). +- `npx tsc --noEmit` — typecheck **Web UI (`gitnexus-web/`)** -- **Unit tests**: `cd gitnexus-web && npm test` (vitest, ~200 tests) -- **E2E tests**: `cd gitnexus-web && E2E=1 npx playwright test` (Playwright, 5 tests — requires `gitnexus serve` + `npm run dev` running) -- **TypeScript check**: `cd gitnexus-web && npx tsc -b --noEmit` +- `npm test` — vitest (~200 tests) +- `npm run test:e2e` — Playwright (7 spec files; requires `gitnexus serve` + `npm run dev`) +- `npx tsc -b --noEmit` — typecheck -No separate lint command is configured; TypeScript strict checking serves as the primary static analysis. +**Pre-commit hook** (`.husky/pre-commit`): formatting (prettier via lint-staged) + typecheck for staged packages. Tests do **not** run in pre-commit — CI only. ### Gotchas -- `npm install` in `gitnexus/` triggers `prepare` (builds via `tsc`) and `postinstall` (patches tree-sitter-swift). Native tree-sitter bindings require `python3`, `make`, and `g++` to be present. -- `tree-sitter-kotlin` and `tree-sitter-swift` are optional dependencies — install warnings for these are expected and non-blocking. -- The Web UI uses `vite-plugin-wasm` and requires `Cross-Origin-Opener-Policy`/`Cross-Origin-Embedder-Policy` headers for `SharedArrayBuffer` (handled automatically by Vite dev server). -- There is no ESLint/Prettier configuration in this repo. +- `npm install` in `gitnexus/` triggers `prepare` (builds via `tsc`) and `postinstall` (patches tree-sitter-swift, builds tree-sitter-proto). Native bindings need `python3`, `make`, `g++`. +- `tree-sitter-kotlin` and `tree-sitter-swift` are optional — install warnings expected. +- ESLint configured via `eslint.config.mjs` (TS, React Hooks, unused-imports). No `npm run lint` script; use `npx eslint .`. Prettier runs via lint-staged. CI checks both in `ci-quality.yml`. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ac4f46aef..1be9468c3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,99 +1,129 @@ # Architecture — GitNexus -This repository is a **monorepo** with two main products: the **CLI / MCP package** (`gitnexus/`) and the **browser UI** (`gitnexus-web/`). Supporting folders ship editor integrations and plugins without changing the core graph engine. +Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`). ## Repository layout | Path | Role | |------|------| -| `gitnexus/` | Published npm package `gitnexus`: CLI, MCP server (stdio), local HTTP API for bridge mode, ingestion pipeline, LadybugDB graph, embeddings (optional). | -| `gitnexus-web/` | Vite + React UI: in-browser indexing (WASM), graph visualization, optional connection to `gitnexus serve`. | -| `.claude/`, `gitnexus-claude-plugin/`, `gitnexus-cursor-integration/` | Packaged **skills** and plugin metadata so agents discover the same workflows as documented in `AGENTS.md`. | -| `eval/` | Evaluation harnesses and docs for benchmarking tool usage. | -| `.github/` | CI workflows (quality, unit, integration, E2E) and composite actions. | +| `gitnexus/` | npm package `gitnexus`: CLI, MCP server (stdio), HTTP API, ingestion pipeline, LadybugDB graph, embeddings. | +| `gitnexus-web/` | Vite + React thin client: graph explorer + AI chat. All queries via `gitnexus serve` HTTP API. | +| `gitnexus-shared/` | Shared TypeScript types and constants (consumed by CLI and Web). | +| `.claude/`, `gitnexus-claude-plugin/`, `gitnexus-cursor-integration/` | Agent skills and plugin metadata. | +| `eval/` | Evaluation harnesses for benchmarking tool usage. | +| `.github/` | CI workflows + composite actions (`setup-gitnexus/`, `setup-gitnexus-web/`). | ## End-to-end flow: index → graph → tools -1. **Ingestion** (`gitnexus analyze`) - - Entry: `gitnexus/src/cli/analyze.ts` → `runPipelineFromRepo` in `gitnexus/src/core/ingestion/pipeline.ts`. - - The pipeline is structured as a **DAG (Directed Acyclic Graph)** of named phases (see [Pipeline Phase DAG](#pipeline-phase-dag) below). - - Output is loaded into **LadybugDB** under **`.gitnexus/`** at the repo root (`lbug/`, `meta.json`, etc.). Optional **FTS** indexes and **embeddings** attach to the same store. - - The repo is registered in **`~/.gitnexus/registry.json`** so MCP can find it from any working directory. +1. **Ingestion** — `analyze.ts` → `runFullAnalysis` (`run-analyze.ts`) → `runPipelineFromRepo` (`pipeline.ts`). DAG of 12 phases builds a `KnowledgeGraph` in memory, then loads into LadybugDB under `.gitnexus/`. Repo registered in `~/.gitnexus/registry.json` for MCP discovery. -2. **Persistence & metadata** - - `gitnexus/src/storage/repo-manager.ts` — paths, registry, cleanup of legacy Kuzu artifacts. - - `gitnexus/src/core/lbug/lbug-adapter.ts` — graph load, queries, embedding restore batches. +2. **Persistence** — `repo-manager.ts` (paths, registry, KuzuDB cleanup). `lbug-adapter.ts` (graph load, queries, embedding batches). -3. **Query & agents** - - **MCP (stdio):** `gitnexus/src/cli/mcp.ts` → `startMCPServer` → `LocalBackend` (`gitnexus/src/mcp/local/local-backend.ts`) opens registered repos and serves **tools** from `gitnexus/src/mcp/tools.ts` and **resources** from `gitnexus/src/mcp/resources.ts`. - - **Bridge HTTP:** `gitnexus/src/cli/serve.ts` → Express app in `gitnexus/src/server/api.ts` (CORS-limited) exposes REST + MCP-over-HTTP for the web UI. - - **CLI tools (no MCP):** `gitnexus query`, `context`, `impact`, `cypher` in `gitnexus/src/cli/tool.ts` call the same backend for scripts and CI. +3. **Query layer** — three interfaces to the same backend: + - **MCP (stdio):** `mcp.ts` → `LocalBackend` → tools (`tools.ts`) + resources (`resources.ts`) + - **HTTP bridge:** `serve.ts` → Express (`api.ts`, `mcp-http.ts`) for web UI + - **CLI direct:** `gitnexus query|context|impact|cypher` in `tool.ts` -4. **Staleness** - - `gitnexus/src/mcp/staleness.ts` compares indexed `lastCommit` to `HEAD` and surfaces hints when the graph is behind git. +4. **Staleness** — `staleness.ts` compares indexed `lastCommit` to `HEAD`, surfaces hints. -## MCP tools (summary) +## MCP tools | Tool | Purpose | |------|---------| -| `list_repos` | Discover indexed repositories when more than one is registered. | -| `query` | Natural-language / keyword search over the graph (hybrid BM25 + optional vectors). | -| `cypher` | Ad hoc **Cypher** against the schema (see resource `gitnexus://repo/{name}/schema`). | -| `context` | Callers, callees, processes for one symbol (with disambiguation). | -| `impact` | Blast radius (upstream/downstream) with depth and risk summary. | -| `detect_changes` | Map git diffs to affected symbols and processes. | -| `rename` | Graph-assisted rename with `dry_run` preview (`graph` vs `text_search` confidence). | +| `list_repos` | Discover indexed repos | +| `query` | Hybrid BM25 + vector search over the graph | +| `cypher` | Ad hoc Cypher against the schema | +| `context` | Callers, callees, processes for one symbol | +| `impact` | Blast radius (upstream/downstream) with risk summary | +| `detect_changes` | Map git diffs to affected symbols and processes | +| `rename` | Graph-assisted multi-file rename with `dry_run` preview | +| `api_impact` | Pre-change impact report for an API route handler | +| `route_map` | API route → handler → consumer mappings | +| `tool_map` | MCP/RPC tool definitions and handlers | +| `shape_check` | Response shape vs consumer property access mismatches | +| `group_list` | List repo groups or details for one group | +| `group_query` | Cross-repo search in a group (reciprocal rank fusion) | +| `group_sync` | Rebuild group Contract Registry (`contracts.json`) | +| `group_contracts` | Inspect group contracts and cross-links | +| `group_status` | Index and Contract Registry staleness per repo in a group | ## Where to change what -| If you are changing… | Start in… | -|----------------------|-----------| -| CLI commands / flags | `gitnexus/src/cli/` (`index.ts`, per-command modules). | -| Parsing or graph construction | `gitnexus/src/core/ingestion/pipeline-phases/` (individual phase files), `pipeline.ts` (orchestrator). | -| Graph schema / DB access | `gitnexus/src/core/lbug/` (`schema.ts`, `lbug-adapter.ts`), `gitnexus/src/mcp/core/lbug-adapter.ts` if MCP-specific. | -| MCP protocol, tools, resources | `gitnexus/src/mcp/server.ts`, `tools.ts`, `resources.ts`. | -| Search ranking | `gitnexus/src/core/search/` (BM25, hybrid fusion). | -| Embeddings | `gitnexus/src/core/embeddings/`, phases in `analyze.ts`. | -| Wiki generation | `gitnexus/src/core/wiki/`. | -| Web UI behavior | `gitnexus-web/src/` (components, workers, graph client). | -| CI | `.github/workflows/*.yml`, `.github/actions/setup-gitnexus/`. | +| Concern | Start in | +|---------|----------| +| CLI commands/flags | `src/cli/` (`index.ts`, per-command modules) | +| Parsing/graph construction | `src/core/ingestion/pipeline-phases/` + `pipeline.ts` | +| Graph schema/DB | `src/core/lbug/` (`schema.ts`, `lbug-adapter.ts`) | +| MCP tools/resources | `src/mcp/server.ts`, `tools.ts`, `resources.ts` | +| Search ranking | `src/core/search/` (BM25, hybrid fusion) | +| Embeddings | `src/core/embeddings/` + `src/core/run-analyze.ts` | +| Wiki generation | `src/core/wiki/` | +| Language support | `src/core/ingestion/languages/` + `tree-sitter-queries.ts` + `gitnexus-shared/src/languages.ts` | +| Import resolution | `src/core/ingestion/import-processor.ts` + `model/resolution-context.ts` | +| Call resolution/MRO | `src/core/ingestion/call-processor.ts` + `model/resolve.ts` | +| Type extraction | `src/core/ingestion/type-extractors/` | +| Worker pool | `src/core/ingestion/workers/` | +| Web UI | `gitnexus-web/src/` | +| CI | `.github/workflows/*.yml`, `.github/actions/` | + +> Paths above are relative to `gitnexus/` unless they start with `gitnexus-web/` or `.github/`. + +--- ## Pipeline Phase DAG -The ingestion pipeline is a DAG of named phases. Each phase is defined in its own file under `gitnexus/src/core/ingestion/pipeline-phases/` with explicit dependencies, typed inputs, and typed outputs. +12 phases defined in `gitnexus/src/core/ingestion/pipeline-phases/`, each with explicit `deps` and typed output. ``` scan → structure → [markdown, cobol] → parse → [routes, tools, orm] → crossFile → mro → communities → processes ``` -### Phase files +| Phase | File | Deps | Output | +|-------|------|------|--------| +| `scan` | `scan.ts` | (root) | File paths + sizes | +| `structure` | `structure.ts` | `scan` | File/Folder nodes, CONTAINS edges, `allPathSet` | +| `markdown` | `markdown.ts` | `structure` | Section nodes, cross-link edges from .md/.mdx | +| `cobol` | `cobol.ts` | `structure` | COBOL program/paragraph/section nodes (regex, no tree-sitter) | +| `parse` | `parse.ts` + `parse-impl.ts` | `structure`, `markdown`, `cobol` | Symbol nodes, IMPORTS/CALLS/EXTENDS edges, extracted routes/tools/ORM queries | +| `routes` | `routes.ts` | `parse` | Route nodes + HANDLES_ROUTE edges (Next.js, Expo, PHP, decorators) | +| `tools` | `tools.ts` | `parse` | Tool nodes + HANDLES_TOOL edges | +| `orm` | `orm.ts` | `parse` | QUERIES edges (Prisma, Supabase) | +| `crossFile` | `cross-file.ts` + `cross-file-impl.ts` | `parse`, `routes`, `tools`, `orm` | Cross-file type propagation in topological import order | +| `mro` | `mro.ts` | `crossFile`, `structure` | METHOD_OVERRIDES + METHOD_IMPLEMENTS edges | +| `communities` | `communities.ts` | `mro`, `structure` | Community nodes + MEMBER_OF edges (Leiden algorithm) | +| `processes` | `processes.ts` | `communities`, `routes`, `tools`, `structure` | Process nodes + STEP_IN_PROCESS edges | -| Phase | File | Dependencies | What it does | -|-------|------|-------------|--------------| -| `scan` | `scan.ts` | (root) | Walk repo filesystem, collect paths + sizes | -| `structure` | `structure.ts` | `scan` | Build File/Folder nodes + CONTAINS edges | -| `markdown` | `markdown.ts` | `structure` | Extract headings and cross-links from .md/.mdx | -| `cobol` | `cobol.ts` | `structure` | Regex-based COBOL/JCL extraction | -| `parse` | `parse.ts` + `parse-impl.ts` | `structure`, `markdown`, `cobol` | Chunked tree-sitter parse, import/call/heritage resolution | -| `routes` | `routes.ts` | `parse` | Route registry (Next.js, Expo, PHP, decorator-based) | -| `tools` | `tools.ts` | `parse` | MCP/RPC tool detection | -| `orm` | `orm.ts` | `parse` | Prisma/Supabase ORM query edges | -| `crossFile` | `cross-file.ts` + `cross-file-impl.ts` | `parse`, `routes`, `tools`, `orm` | Cross-file type propagation in topological order | -| `mro` | `mro.ts` | `crossFile` | Method Resolution Order, METHOD_OVERRIDES edges | -| `communities` | `communities.ts` | `mro` | Leiden community detection | -| `processes` | `processes.ts` | `communities`, `routes`, `tools` | Execution flow detection, Route/Tool → Process links | +**Non-phase files in the same directory:** `parse-impl.ts`, `cross-file-impl.ts` (implementation), `wildcard-synthesis.ts` (whole-module import expansion), `orm-extraction.ts` (sequential ORM fallback), `types.ts`, `runner.ts`, `index.ts`. + +### DAG runner + +`runner.ts` — static phase graph, no plugins, compile-time type safety. + +1. **Validation** — Kahn's topological sort. Rejects on: duplicate names, missing deps, cycles (DFS traces the concrete cycle path, e.g., `A -> B -> C -> A`, plus count of transitively blocked dependents). + +2. **Execution** — sequential in topological order. Each phase receives: + - `ctx: PipelineContext` — shared mutable `KnowledgeGraph`, `repoPath`, progress callback, options + - `deps: ReadonlyMap` — **declared deps only** (runner filters the results map to prevent hidden coupling) + +3. **Error handling** — wraps phase errors with the phase name, emits terminal `error` progress event, swallows progress handler errors to preserve the original cause. + +4. **Timing** — per-phase `durationMs` in `PhaseResult`, dev-mode console logging. + +**Design patterns:** +- **Single graph accumulator** — all phases mutate the same `KnowledgeGraph` in `ctx`; the graph is the primary output. +- **Typed phase access** — `getPhaseOutput(deps, 'name')` for type-safe upstream results. +- **Binding accumulator lifecycle** — created in `parse`, disposed by `crossFile` (in `finally`). No other phase should take ownership. +- **Skippable phases** — `skipGraphPhases` omits MRO/communities/processes (faster tests). `skipWorkers` forces sequential parsing. ### How to add a new phase -1. Create a new file in `pipeline-phases/` (e.g. `my-phase.ts`) -2. Define a `PipelinePhase` object with `name`, `deps`, and `execute(ctx, deps)` -3. Export it from `pipeline-phases/index.ts` -4. Add it to the `buildPhaseList()` function in `pipeline.ts` +1. Create `pipeline-phases/my-phase.ts` with a `PipelinePhase` (name, deps, execute) +2. Export from `pipeline-phases/index.ts` +3. Add to `buildPhaseList()` in `pipeline.ts` ```typescript -// pipeline-phases/my-phase.ts -import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js'; +import type { PipelinePhase, PhaseResult } from './types.js'; import { getPhaseOutput } from './types.js'; import type { ParseOutput } from './parse.js'; @@ -101,81 +131,168 @@ export interface MyPhaseOutput { /* ... */ } export const myPhase: PipelinePhase = { name: 'myPhase', - deps: ['parse'], // runs after parse completes + deps: ['parse'], async execute(ctx, deps) { const { allPaths } = getPhaseOutput(deps, 'parse'); - // ... do work, write to ctx.graph ... + // ... write to ctx.graph ... return { /* typed output */ }; }, }; ``` -### DAG runner +--- -The runner (`pipeline-phases/runner.ts`) validates the DAG at startup (detects cycles and missing deps via topological sort), then executes phases in dependency order. Each phase receives: -- `ctx: PipelineContext` — shared graph, repoPath, progress callback -- `deps: Map` — outputs from all upstream phases +## Language-agnostic graph feeding + +16 languages → single unified graph. Four abstraction layers: + +``` + Unified Graph Schema (44 node types, 21 relationship types) + ↑ + Unified Resolution (3-tier name lookup + MRO walk) + ↑ + Language Providers (import semantics, type config, export checker, MRO strategy) + ↑ + Tree-Sitter Queries (per-language S-expressions, unified capture tags) +``` + +### Language providers + +Each language implements `LanguageProvider` (`language-provider.ts`). Key fields: + +| Field | Purpose | +|-------|---------| +| `id`, `extensions` | Language identity and file matching | +| `treeSitterQueries` | S-expression queries for AST extraction | +| `importSemantics` | `named` / `wildcard-leaf` / `wildcard-transitive` / `namespace` | +| `importResolver` | Language-specific path → file resolution | +| `exportChecker` | Public/exported symbol detection | +| `typeConfig` | Type annotation extraction rules | +| `mroStrategy` | `first-wins` / `c3` / `none` | + +16 providers in `languages/index.ts` via `satisfies Record` — missing a language is a compile error. + +### Unified capture tags + +Per-language tree-sitter queries use different AST node names but produce the **same semantic capture tags**: `@definition.class`, `@definition.function`, `@call.name`, `@import.source`, `@heritage.extends`. Downstream extraction needs no language branching. Defined in `tree-sitter-queries.ts`. + +### Import resolution + +Unified 3-tier algorithm (`model/resolution-context.ts`), per-language `importSemantics` controls which tier activates: + +| Tier | Confidence | Mechanism | +|------|-----------|-----------| +| 1 — same-file | 0.95 | Symbol table for caller's file | +| 2 — import-scoped | 0.9 | `NamedImportMap` chains (named) or all files in `importMap` (wildcard) | +| 3 — global | 0.5 | O(1) index lookups: class, impl, callable. Fallback only | + +| Import strategy | Languages | Behavior | +|----------------|-----------|----------| +| `named` | TS, JS, Java, C#, Rust, PHP, Kotlin | Only explicitly imported names visible | +| `wildcard-leaf` | Go, Ruby, Swift, Dart | Whole-package import, no transitive re-exports | +| `wildcard-transitive` | C, C++ | `#include` closure chains through re-exports | +| `namespace` | Python | Module aliases resolved at call site | + +### Chunked parse-and-resolve + +`parse` processes files in ~20 MB byte-budget chunks to bound memory. Per chunk: +1. Worker pool dispatches files (or sequential fallback via `skipWorkers`) +2. Each worker: detect language → load grammar → run queries → return unified `ParseWorkerResult` +3. Synthesize wildcard bindings (`wildcard-synthesis.ts`) +4. Resolve imports and heritage +5. Collect `BindingAccumulator` entries for cross-file propagation + +Workers: `workers/worker-pool.ts`, `workers/parse-worker.ts`. + +### Heritage and MRO + +All languages emit unified `ExtractedHeritage` (child, parent, `EXTENDS`/`IMPLEMENTS`). MRO phase walks the heritage graph using per-language strategy: +- **`first-wins`** — Java, C#, C++, TS, Ruby, Go +- **`c3`** — Python (C3 linearization) +- **`none`** — single-inheritance languages + +Unified walk: `lookupMethodByOwnerWithMRO()` in `model/resolve.ts`. + +--- + +## Full analysis flow + +`runFullAnalysis` in `run-analyze.ts` orchestrates everything around the pipeline: + +``` +CLI (analyze.ts) → runFullAnalysis(repoPath, options, callbacks) + 1. Early exit if lastCommit == HEAD (unless --force) [0%] + 2. Cache existing embeddings from prior index [0%] + 3. runPipelineFromRepo() → KnowledgeGraph [0-60%] + 4. Clean up legacy KuzuDB files [60%] + 5. initLbug() → loadGraphToLbug() via CSV streaming [60-85%] + 6. Create FTS indexes (File, Function, Class, Method...) [85-90%] + 7. Restore cached embeddings (batch insert) [88%] + 8. Generate new embeddings if --embeddings [90-98%] + 9. Save metadata + register repo + update .gitignore [98-100%] + 10. Generate AI context files (AGENTS.md, CLAUDE.md) [100%] +``` + +**Options:** `--force` (rebuild regardless), `--embeddings` (opt-in, skipped if >50k nodes), `--skipGit`, `--noStats`. + +## Storage + +``` +/.gitnexus/ + ├── lbug # LadybugDB database + ├── lbug.wal # Write-ahead log + ├── lbug.lock # Single-writer lock + └── meta.json # lastCommit, indexedAt, stats + +~/.gitnexus/ + └── registry.json # Global repo registry (MCP discovery) +``` + +Managed by `repo-manager.ts`. + +## LadybugDB schema + +Defined in `lbug/schema.ts`. Separate node tables per type, single `CodeRelation` table. + +**Node tables:** File, Folder, Function, Class, Interface, Method, Constructor, CodeElement, Struct, Enum, Macro, Typedef, Union, Namespace, Trait, Impl, TypeAlias, Const, Static, Property, Record, Delegate, Annotation, Template, Module, Community, Process, Route, Tool, Section, Embedding. + +**Relation types** (`CodeRelation.type`): CONTAINS, DEFINES, CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, ACCESSES, METHOD_OVERRIDES, METHOD_IMPLEMENTS, MEMBER_OF, STEP_IN_PROCESS, HANDLES_ROUTE, FETCHES, HANDLES_TOOL, ENTRY_POINT_OF. + +## Embeddings and search + +**Embeddings** (`src/core/embeddings/`): Snowflake arctic-embed-xs (384D). Embeddable: File, Function, Class, Method, Interface. Incremental via SHA1 content hash. Separate `Embedding` table. + +**Search** (`src/core/search/`): Hybrid BM25 + semantic vector, merged via Reciprocal Rank Fusion (K=60). ## Known limitations ### Overloaded method resolution -Method and Constructor node IDs include an arity suffix (`#`) to -disambiguate overloaded methods. Two overloads with different parameter counts -produce distinct graph nodes: `Method:file:Class.method#1` vs -`Method:file:Class.method#2`. +Node IDs use arity suffix (`#`): `Method:file:Class.method#1` vs `#2`. -**Same-arity overload disambiguation:** When two overloads share the same -parameter count but differ in types (e.g. `save(int)` vs `save(String)`), a -type-hash suffix `~type1,type2` is appended to produce distinct node IDs: -`Method:file:Class.save#1~int` vs `Method:file:Class.save#1~String`. The suffix -is only added when a same-arity collision is detected within a class and all -parameters have non-null type annotations. Languages without type info (Python, -Ruby, JS) fall back to arity-only IDs. TypeScript/JavaScript overload signatures -are intentionally excluded from type-hashing because they are declaration-only -contracts that should collapse to the implementation body's node ID. See issue -\#651. +**Same-arity disambiguation:** type-hash suffix `~type1,type2` when collision detected and type annotations present. Languages without types (Python, Ruby, JS) use arity-only. TS/JS overload signatures excluded (collapse to implementation body). See #651. -**C++ const-qualified overload disambiguation:** Methods overloaded by const -qualification (e.g. `begin()` vs `begin() const`) are disambiguated via an -`isConst` property and a `$const` ID suffix appended to the const-qualified -variant when a non-const collision exists. The `$const` suffix appears after the -type-hash suffix: e.g. `Method:file:Container.begin#0$const`. +**C++ const-qualified:** `$const` suffix after type-hash when non-const collision exists: `Method:file:Container.begin#0$const`. -**Generic/template type preservation in type-hash:** The type-hash suffix uses -`rawType` (full AST text including generic/template args) rather than the -simplified `type` from `extractSimpleTypeName`. This means C++ template overloads -like `process(vector)` vs `process(vector)` produce distinct IDs: -`~vector` vs `~vector`. Java generic overloads like -`process(List)` vs `process(List)` are a compile error due to -type erasure, so this gap is theoretical for Java. +**Generic/template types:** type-hash uses `rawType` (full AST text including generics): `~vector` vs `~vector`. -**ID stability on first overload:** Type and const tags are collision-only. When -a class has `save(int)` as its only `save` method, the ID is `save#1` (no tag). -Adding `save(String)` changes the original to `save#1~int`. This is correct for -fresh analysis but means IDs are not stable across overload additions. Future -incremental re-analysis should account for this. +**ID stability:** collision-only tags mean IDs change when overloads are added. `save#1` becomes `save#1~int` when `save(String)` is added. -**Variadic method matching:** When one side is variadic (`parameterCount` -undefined) and the other has a fixed count, `METHOD_IMPLEMENTS` edges are -emitted with confidence 0.7 instead of 1.0. Variadic methods like -`foo(String... args)` may superficially match `foo(String s)` by type but -are not guaranteed to be interchangeable across all languages (Java/Kotlin -accept this via varargs sugar; TypeScript, C#, Rust do not). +**Variadic matching:** confidence 0.7 when one side is variadic and the other has fixed count. -**Confidence tiering** for `METHOD_IMPLEMENTS` edges: +**METHOD_IMPLEMENTS confidence tiering:** -| Match quality | Confidence | When | -|---|---|---| -| Exact parameter types match | 1.0 | Both sides have `parameterTypes` arrays and they match | -| Arity (count) matches | 1.0 | Both sides have `parameterCount`, types unavailable | -| Variadic vs fixed | 0.7 | One side is variadic, other has fixed count | -| Lenient (insufficient info) | 0.7 | One or both sides lack type and count data | +| Match quality | Confidence | +|---|---| +| Exact parameter types match | 1.0 | +| Arity match, types unavailable | 1.0 | +| Variadic vs fixed | 0.7 | +| Insufficient info | 0.7 | ## Related docs -- [MIGRATION.md](MIGRATION.md) — breaking changes and migration guidance. -- [RUNBOOK.md](RUNBOOK.md) — operational commands and recovery. -- [GUARDRAILS.md](GUARDRAILS.md) — safety boundaries for humans and agents. -- [TESTING.md](TESTING.md) — how to run tests. -- `AGENTS.md` / `CLAUDE.md` — agent workflows and tool usage expectations for **this** repo when indexed by GitNexus. +- [MIGRATION.md](MIGRATION.md) — breaking changes and migration guidance +- [RUNBOOK.md](RUNBOOK.md) — operational commands and recovery +- [GUARDRAILS.md](GUARDRAILS.md) — safety boundaries for humans and agents +- [TESTING.md](TESTING.md) — how to run tests +- `AGENTS.md` / `CLAUDE.md` — agent workflows and tool usage diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d2d48f017..22104edb4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,7 +62,7 @@ Commits within a PR may use any style — only the **merged PR title** shows up - [ ] Typecheck passes: `npx tsc --noEmit` in `gitnexus/` and `npx tsc -b --noEmit` in `gitnexus-web/`. - [ ] No secrets, tokens, or machine-specific paths committed. - [ ] Documentation updated if behavior or public CLI/MCP contract changes. -- [ ] Pre-commit hook runs clean (`.husky/pre-commit` — typecheck + unit tests for staged packages). +- [ ] Pre-commit hook runs clean (`.husky/pre-commit` — formatting via lint-staged + typecheck for staged packages; tests run in CI only). ## Code review diff --git a/GUARDRAILS.md b/GUARDRAILS.md index 7401c79c5..ac48ab906 100644 --- a/GUARDRAILS.md +++ b/GUARDRAILS.md @@ -1,72 +1,69 @@ -# Guardrails — GitNexus (repo + agents) +# Guardrails — GitNexus -Rules for **human contributors** and **AI agents** working on this codebase or publishing artifacts. These complement `AGENTS.md` / `CLAUDE.md` (which focus on GitNexus-in-GitNexus workflows). +Rules for **human contributors** and **AI agents**. Complements `AGENTS.md` (workflows) and `CONTRIBUTING.md` (PR process). -## Scope (typical agent session) +## Scope (least privilege) -When automating changes in this repository, treat scope as **least privilege**: +- **Read:** Source, tests, docs, public config as needed. +- **Write:** Only files required for the fix or feature; no unrelated formatting or refactors. +- **Execute:** Tests, typecheck, documented CLI commands. No destructive commands on user data without approval. +- **Off-limits:** Other people's machines, production deployments you don't own, credentials you lack permission to use. -- **Read:** Source, tests, docs, public config as needed for the task. -- **Write:** Only files required for the requested fix or feature; avoid unrelated formatting or refactors. -- **Execute:** Tests, typecheck, and documented CLI commands; do not run destructive commands on user data outside the repo without explicit approval. -- **Off-limits:** Other people’s machines, production deployments you don’t own, and credentials you didn’t receive permission to use. - -Adjust explicitly if the maintainer defines a different scope for a task. +Maintainer may widen scope per task. --- ## Non-negotiables -1. **Never commit secrets** — API keys, tokens, `.env` with real values, private URLs, or session cookies. Use `.env.example` with placeholders only. -2. **Never rename symbols with blind find-and-replace** when working in a GitNexus-indexed project — use the **`rename` MCP tool** with **`dry_run: true` first**, then review `graph` vs `text_search` edits. (There is no separate `gitnexus rename` CLI; renaming goes through MCP or editor integration.) -3. **Run impact analysis before editing shared symbols** — use **`impact`** (upstream) for functions/classes/methods others call; do not ignore **HIGH** / **CRITICAL** risk without maintainer sign-off. -4. **Prefer `detect_changes` before commit** — confirm diffs map to expected symbols/processes when the graph is available. -5. **Preserve embeddings** — if `.gitnexus/meta.json` shows embeddings, run `npx gitnexus analyze --embeddings` when refreshing the index; plain `analyze` can drop them. +1. **Never commit secrets** — API keys, tokens, real `.env` values, private URLs, session cookies. Use `.env.example` with placeholders. +2. **Never rename with find-and-replace** in GitNexus-indexed projects — use `rename` MCP tool with `dry_run: true` first, review `graph` vs `text_search` edits. No separate `gitnexus rename` CLI exists. +3. **Run impact analysis before editing shared symbols** — `impact` (upstream) for functions/classes/methods others call. Do not ignore HIGH/CRITICAL without maintainer sign-off. +4. **Run `detect_changes` before commit** — confirm diffs map to expected symbols/processes when the graph is available. +5. **Preserve embeddings** — if `.gitnexus/meta.json` shows embeddings, use `npx gitnexus analyze --embeddings`; plain `analyze` drops them. --- ## Signs (recurring failure patterns) -Use this format: **Trigger → Instruction → Reason**. -Append new Signs here when the same mistake repeats (e.g. CI broken twice the same way). +Format: **Trigger → Instruction → Reason**. Append new Signs when the same mistake repeats. -### Sign: Stale graph after edits +### Stale graph after edits -- **Trigger:** MCP or resources warn the index is behind `HEAD`, or code search doesn’t match latest commit. -- **Instruction:** Run `npx gitnexus analyze` from the repo root (plus `--embeddings` if the project used them). -- **Reason:** Tools query LadybugDB built at last analyze; git changes are invisible until re-indexed. +- **Trigger:** MCP warns index is behind `HEAD`, or search doesn't match latest commit. +- **Do:** `npx gitnexus analyze` (plus `--embeddings` if used). +- **Why:** Tools query LadybugDB from last analyze; git changes are invisible until re-indexed. -### Sign: Embeddings vanished after analyze +### Embeddings vanished after analyze -- **Trigger:** Semantic search quality drops; `stats.embeddings` in `.gitnexus/meta.json` is 0 after a refresh. -- **Instruction:** Re-run `npx gitnexus analyze --embeddings` and confirm `meta.json` reflects stored embeddings. -- **Reason:** Embedding generation is opt-in; analyze without the flag does not preserve prior vectors. +- **Trigger:** Semantic search quality drops; `stats.embeddings` in `meta.json` is 0 after refresh. +- **Do:** `npx gitnexus analyze --embeddings`, confirm `meta.json` reflects stored embeddings. +- **Why:** Embedding generation is opt-in; analyze without the flag does not preserve prior vectors. -### Sign: MCP lists no repos +### MCP lists no repos -- **Trigger:** MCP stderr says no indexed repos. -- **Instruction:** Run `npx gitnexus analyze` in the target repository; verify `npx gitnexus list` shows it. -- **Reason:** The MCP server discovers repos via `~/.gitnexus/registry.json`, populated by analyze. +- **Trigger:** MCP stderr says no indexed repos. +- **Do:** `npx gitnexus analyze` in the target repo; verify `npx gitnexus list` shows it. +- **Why:** MCP discovers repos via `~/.gitnexus/registry.json`, populated by analyze. -### Sign: Wrong repo in multi-repo setups +### Wrong repo in multi-repo setups -- **Trigger:** Query/impact results clearly belong to another project. -- **Instruction:** Call `list_repos`, then pass **`repo`** on subsequent tools (or use per-workspace MCP config). -- **Reason:** Default target may be ambiguous when multiple repos are registered. +- **Trigger:** Query/impact results belong to another project. +- **Do:** Call `list_repos`, then pass `repo` on subsequent tools. +- **Why:** Default target is ambiguous when multiple repos are registered. -### Sign: LadybugDB lock / “database busy” +### LadybugDB lock / "database busy" -- **Trigger:** Errors opening `.gitnexus/lbug` while MCP and analyze both run. -- **Instruction:** Stop overlapping processes; one writer at a time. Retry analyze or restart MCP. -- **Reason:** Embedded DB expects single-process ownership of the store. +- **Trigger:** Errors opening `.gitnexus/lbug` while MCP and analyze both run. +- **Do:** Stop overlapping processes (one writer at a time). Retry analyze or restart MCP. +- **Why:** Embedded DB expects single-process ownership. --- ## Publishing & supply chain -- **npm:** Do not publish from unreviewed automation; follow maintainer release process. Bump version intentionally; tag releases to match `package.json`. -- **Dependencies:** Prefer minimal, auditable changes to `package.json`; run tests and CI after lockfile updates. -- **License:** This project ships under **PolyForm Noncommercial 1.0.0** — do not relicense or imply a different license in docs or metadata without maintainer approval. +- **npm:** Do not publish from unreviewed automation. Bump version intentionally; tag releases to match `package.json`. +- **Dependencies:** Minimal, auditable `package.json` changes; run tests and CI after lockfile updates. +- **License:** PolyForm Noncommercial 1.0.0 — do not relicense without maintainer approval. --- @@ -74,15 +71,15 @@ Append new Signs here when the same mistake repeats (e.g. CI broken twice the sa Stop and ask a **human maintainer** when: -- Impact analysis shows **HIGH** / **CRITICAL** risk and the task still requires the change. -- You need to alter **CI**, **release**, or **security-sensitive** config. -- Requirements conflict (e.g. “speed up analyze” vs “must keep all embeddings on huge repo”). +- Impact analysis shows HIGH/CRITICAL risk and the task still requires the change. +- You need to alter CI, release, or security-sensitive config. +- Requirements conflict (e.g. "speed up analyze" vs "must keep all embeddings on huge repo"). - You are unsure whether data loss is acceptable (`clean`, forced migrations, schema changes). --- ## Related docs -- [ARCHITECTURE.md](ARCHITECTURE.md) — components and data flow. -- [RUNBOOK.md](RUNBOOK.md) — commands for recovery. -- [CONTRIBUTING.md](CONTRIBUTING.md) — PR and commit expectations. +- [ARCHITECTURE.md](ARCHITECTURE.md) — components and data flow +- [RUNBOOK.md](RUNBOOK.md) — commands for recovery +- [CONTRIBUTING.md](CONTRIBUTING.md) — PR and commit expectations diff --git a/TESTING.md b/TESTING.md index 8d267983a..cf481d32b 100644 --- a/TESTING.md +++ b/TESTING.md @@ -20,9 +20,9 @@ From repository root, unless noted: cd gitnexus npm install npm run build -npm test # unit: vitest run test/unit +npm test # full suite: vitest run +npm run test:unit # unit only: vitest run test/unit npm run test:integration # integration suite -npm run test:all npm run test:coverage npx tsc --noEmit # typecheck (matches CI) ``` @@ -42,8 +42,11 @@ npm run test:e2e # Playwright (requires gitnexus serve + npm run dev) A husky pre-commit hook (`.husky/pre-commit`) runs automatically on every `git commit`: -- **`gitnexus-web/` files staged** → `tsc -b --noEmit` + `vitest run` -- **`gitnexus/` files staged** → `tsc --noEmit` + `vitest run --project default` +1. **Formatting** — `lint-staged` runs prettier on staged files +2. **`gitnexus-web/` files staged** → `tsc -b --noEmit` +3. **`gitnexus/` files staged** → `tsc --noEmit` + +Tests do **not** run in the pre-commit hook — they run in CI (`ci-tests.yml`) only. Skip with `git commit --no-verify` (use sparingly). @@ -77,7 +80,7 @@ Re-run the full relevant suite when: GitHub Actions (`.github/workflows/ci.yml`) orchestrate: -- **`ci-quality.yml`** — `tsc --noEmit` for `gitnexus/` + `tsc -b --noEmit` for `gitnexus-web/` +- **`ci-quality.yml`** — prettier format check, eslint lint, `tsc --noEmit` for `gitnexus/`, `tsc -b --noEmit` for `gitnexus-web/` - **`ci-tests.yml`** — `vitest run` with coverage (ubuntu) + cross-platform (macOS, Windows) - **`ci-e2e.yml`** — Playwright E2E tests, gated on `gitnexus-web/**` changes From d024119b337b011537e34b600bac5cb3022f91d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Thu, 16 Apr 2026 09:17:21 +0100 Subject: [PATCH 57/67] chore(deps): tree-sitter 0.25 upgrade readiness monitor with daily Dependabot (#847) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(deps): add tree-sitter aware Dependabot config and drift monitoring Two things Dependabot cannot see on its own: 1. ABI consistency. The tree-sitter runtime supports a known range of grammar ABIs. When a grammar bumps past that range, require() silently fails and fallback paths mask the regression in test coverage. 2. Vendored upstream drift. vendor/tree-sitter-proto is a snapshot of coder3101/tree-sitter-proto regenerated against a pinned cli version. Upstream keeps moving. Nothing notices until a maintainer remembers to look. Dependabot configuration - Added npm ecosystems for gitnexus, gitnexus-web, gitnexus-shared. - Grouped all tree-sitter-* grammar bumps into one PR (ecosystem moves in lockstep, one PR per grammar is noise). - Pinned the tree-sitter runtime itself. Bumping 0.21 to 0.22+ changes which grammar ABIs load and requires coordinated updates to the vendored proto grammar. That stays a deliberate human decision. - Pinned tree-sitter-cli for the same reason (it controls which ABI vendor/tree-sitter-proto/src/parser.c emits when regenerated). Drift check (.github/scripts/check-tree-sitter-drift.py) - Reads the tree-sitter runtime version from gitnexus/package.json. - Walks every installed tree-sitter-* grammar plus the vendored proto and reports its LANGUAGE_VERSION against the runtime's supported ABI range (table maintained in the script; extend when bumping runtime). - Fetches coder3101/tree-sitter-proto main parser.c and compares byte for byte to the vendored copy. Reports the upstream HEAD short SHA and the upstream ABI so a maintainer can act. - Prints a Markdown report; exits 0 when everything is in range and matches upstream, 1 otherwise. - Stdlib only, no external deps. Drift workflow (.github/workflows/tree-sitter-drift-check.yml) - Runs weekly (Mondays 09:00 UTC) to match Dependabot's cadence. - Also runs on PRs that touch the script or workflow itself, where it fails the PR check on drift so the drift gate cannot land broken. - On scheduled runs with drift, opens or updates a single tracking issue labeled tree-sitter-drift. On scheduled runs that come back clean, closes the open tracking issue (if any) with a comment. * refactor(deps): rewrite drift check as tree-sitter 0.25 upgrade readiness monitor Replace the ABI drift pass/fail gate with a daily upgrade readiness dashboard that tracks peer-dep compatibility of all 14 grammars with tree-sitter@0.25.0 and reports which are ready, unreleased, or blocking. Key changes: - Rename drift-check → upgrade-readiness (script, workflow, job id) - Fix P0: pass report via env var, not ${{ }} template interpolation - Fix P1: npm fetch failure now adds a blocker instead of false-green - Fix P1: pass GITHUB_TOKEN for authenticated GitHub API calls - Switch Dependabot to daily for tree-sitter grammars - Use dict for blockers (no prefix collision), derive TARGET_RUNTIME constant, reuse GRAMMARS parser_path, normalize CRLF in comparisons - Reduce per-call HTTP timeout from 15s to 8s for workflow budget - PR runs warn on blockers instead of hard-failing Co-Authored-By: Claude Opus 4.6 (1M context) * chore(ci): remove global-upgrade smoke test workflow The ci-global-upgrade.yml workflow tested npm global install upgrades over a specific release candidate (1.6.2-rc.8). That RC has shipped and the workflow is no longer needed. Remove it and all references from ci.yml (needs, env vars, gate check). Co-Authored-By: Claude Opus 4.6 (1M context) * feat(ci): add changelog comments to upgrade readiness tracking issue Each daily run now posts a comment summarizing what changed before updating the issue body. Comments include the ready/blocker counts and a diff of grammar status changes (e.g. tree-sitter-cpp: Unreleased -> Ready). Gives a timeline of how the upgrade unblocks. --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .github/dependabot.yml | 59 +++ .../check-tree-sitter-upgrade-readiness.py | 358 ++++++++++++++++++ .github/workflows/ci-global-upgrade.yml | 113 ------ .github/workflows/ci.yml | 16 +- .../tree-sitter-upgrade-readiness.yml | 185 +++++++++ 5 files changed, 604 insertions(+), 127 deletions(-) create mode 100644 .github/scripts/check-tree-sitter-upgrade-readiness.py delete mode 100644 .github/workflows/ci-global-upgrade.yml create mode 100644 .github/workflows/tree-sitter-upgrade-readiness.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 292cb435d..f3530e4d3 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -14,3 +14,62 @@ updates: labels: - dependencies - ci + + # Gitnexus npm deps — tree-sitter grammars checked daily so we catch + # new releases that unblock the tree-sitter 0.25 upgrade ASAP. Grammars + # are grouped so lockstep bumps produce a single PR. The tree-sitter + # RUNTIME is pinned — upgrade deliberately via the drift check workflow. + # See .github/scripts/check-tree-sitter-upgrade-readiness.py for + # the upgrade readiness tracker. + - package-ecosystem: npm + directory: /gitnexus + schedule: + interval: daily + open-pull-requests-limit: 10 + commit-message: + prefix: chore(deps) + include: scope + labels: + - dependencies + groups: + tree-sitter-grammars: + patterns: + - tree-sitter-* + exclude-patterns: + - tree-sitter + - tree-sitter-cli + ignore: + # Pin the tree-sitter runtime at 0.21.x until the drift check + # reports all grammars are peer-dep compatible with 0.25. + - dependency-name: tree-sitter + update-types: + - version-update:semver-major + - version-update:semver-minor + # tree-sitter-cli follows the runtime's version cadence. Bump when + # regenerating vendor/tree-sitter-proto/src/parser.c, not on a schedule. + - dependency-name: tree-sitter-cli + + # gitnexus-web (thin frontend client). + - package-ecosystem: npm + directory: /gitnexus-web + schedule: + interval: weekly + open-pull-requests-limit: 5 + commit-message: + prefix: chore(deps) + include: scope + labels: + - dependencies + - frontend + + # Shared types package. + - package-ecosystem: npm + directory: /gitnexus-shared + schedule: + interval: weekly + open-pull-requests-limit: 5 + commit-message: + prefix: chore(deps) + include: scope + labels: + - dependencies diff --git a/.github/scripts/check-tree-sitter-upgrade-readiness.py b/.github/scripts/check-tree-sitter-upgrade-readiness.py new file mode 100644 index 000000000..df21533a8 --- /dev/null +++ b/.github/scripts/check-tree-sitter-upgrade-readiness.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +"""Monitor tree-sitter 0.25 upgrade readiness. + +Tracks two things Dependabot cannot see: + + 1. Peer-dep compatibility. Each tree-sitter-* grammar declares a peer + dependency on the tree-sitter runtime. We want to know when every + grammar's *latest npm release* satisfies tree-sitter@0.25.0 so we + can upgrade without --legacy-peer-deps. + + 2. Vendored upstream drift. vendor/tree-sitter-proto/ is a snapshot of + coder3101/tree-sitter-proto's parser.c. When upstream moves, we want + to know whether we can pick it up. + +Invoked from .github/workflows/tree-sitter-upgrade-readiness.yml daily. +Runs locally too: + + python3 .github/scripts/check-tree-sitter-upgrade-readiness.py + +Outputs Markdown to stdout. Exit 0 when every grammar is upgrade-ready +and the vendored proto is in sync. Exit 1 when blockers remain (the +workflow uses this to open or update a tracking issue). + +No external deps -- stdlib only, so it runs on any vanilla runner. +""" + +from __future__ import annotations + +import json +import os +import pathlib +import re +import sys +import urllib.error +import urllib.request + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] +GITNEXUS_DIR = REPO_ROOT / "gitnexus" +VENDOR_PROTO_DIR = GITNEXUS_DIR / "vendor" / "tree-sitter-proto" + +# ── Upgrade target ────────────────────────────────────────────────────── +# The runtime version we want to upgrade TO. Update this when the goal +# changes (e.g. once 0.25 lands and we target 0.26). +TARGET_RUNTIME = "0.25.0" +TARGET_RUNTIME_MAJOR_MINOR = ".".join(TARGET_RUNTIME.split(".")[:2]) + +# Tree-sitter runtime -> (min_abi, max_abi) it can load. Only the current +# and target entries matter; extend when changing TARGET_RUNTIME. +RUNTIME_ABI_RANGES: dict[str, tuple[int, int]] = { + "0.21": (13, 14), + "0.25": (13, 15), +} + +assert TARGET_RUNTIME_MAJOR_MINOR in RUNTIME_ABI_RANGES, ( + f"RUNTIME_ABI_RANGES has no entry for {TARGET_RUNTIME_MAJOR_MINOR!r}. " + f"Add the ABI range after auditing the upstream release notes." +) + +# Grammars we use. Values are the upstream GitHub repos to check for +# unreleased ABI bumps (owner/repo, branch, parser.c path). +GRAMMARS: dict[str, tuple[str, str, str]] = { + "tree-sitter-c": ("tree-sitter/tree-sitter-c", "master", "src/parser.c"), + "tree-sitter-c-sharp": ("tree-sitter/tree-sitter-c-sharp", "master", "src/parser.c"), + "tree-sitter-cpp": ("tree-sitter/tree-sitter-cpp", "master", "src/parser.c"), + "tree-sitter-dart": ("UserNobody14/tree-sitter-dart", "master", "src/parser.c"), + "tree-sitter-go": ("tree-sitter/tree-sitter-go", "master", "src/parser.c"), + "tree-sitter-java": ("tree-sitter/tree-sitter-java", "master", "src/parser.c"), + "tree-sitter-javascript": ("tree-sitter/tree-sitter-javascript", "master", "src/parser.c"), + "tree-sitter-kotlin": ("fwcd/tree-sitter-kotlin", "main", "src/parser.c"), + "tree-sitter-php": ("tree-sitter/tree-sitter-php", "master", "php/src/parser.c"), + "tree-sitter-python": ("tree-sitter/tree-sitter-python", "master", "src/parser.c"), + "tree-sitter-ruby": ("tree-sitter/tree-sitter-ruby", "master", "src/parser.c"), + "tree-sitter-rust": ("tree-sitter/tree-sitter-rust", "master", "src/parser.c"), + "tree-sitter-swift": ("alex-pinkus/tree-sitter-swift", "main", "src/parser.c"), + "tree-sitter-typescript": ("tree-sitter/tree-sitter-typescript", "master", "typescript/src/parser.c"), +} + +UPSTREAM_PROTO_OWNER = "coder3101" +UPSTREAM_PROTO_REPO = "tree-sitter-proto" +UPSTREAM_PROTO_BRANCH = "main" + + +# ── Helpers ───────────────────────────────────────────────────────────── + +def read_current_runtime() -> str: + """Return the tree-sitter runtime version pinned in package.json (e.g. '0.21').""" + pkg = json.loads((GITNEXUS_DIR / "package.json").read_text()) + raw = pkg["dependencies"]["tree-sitter"] + match = re.search(r"(\d+)\.(\d+)", raw) + if not match: + raise SystemExit(f"could not parse tree-sitter version: {raw!r}") + return f"{match.group(1)}.{match.group(2)}" + + +def npm_view_json(pkg: str) -> dict | None: + """Fetch package metadata from the npm registry via HTTPS. + + Uses the registry API directly so we don't depend on the npm CLI + being available (it's a batch file on Windows which complicates + subprocess calls). + """ + url = f"https://registry.npmjs.org/{pkg}/latest" + try: + req = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=8) as resp: + return json.loads(resp.read().decode("utf-8")) + except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError): + return None + + +def satisfies_target(peer_range: str | None, target: str) -> bool: + """Check if a semver range like '^0.22.4' or '^0.25.0' satisfies the target. + + Simple heuristic: extract the minimum version from the range and check + if target >= min. For caret ranges (^X.Y.Z), the upper bound is the + next major (for X>0) or next minor (for X==0). We check both bounds. + """ + if peer_range is None: + # No peer dep declared = no constraint = compatible. + return True + match = re.search(r"(\d+)\.(\d+)\.(\d+)", peer_range) + if not match: + return False + min_major, min_minor, min_patch = int(match.group(1)), int(match.group(2)), int(match.group(3)) + + t_match = re.search(r"(\d+)\.(\d+)\.(\d+)", target) + if not t_match: + return False + t_major, t_minor, t_patch = int(t_match.group(1)), int(t_match.group(2)), int(t_match.group(3)) + + # Target must be >= minimum. + target_tuple = (t_major, t_minor, t_patch) + min_tuple = (min_major, min_minor, min_patch) + if target_tuple < min_tuple: + return False + + # For caret ranges with major 0: ^0.X.Y allows [0.X.Y, 0.(X+1).0). + if peer_range.startswith("^") and min_major == 0: + if t_major != 0 or t_minor >= min_minor + 1: + return False + # For caret ranges with major >0: ^X.Y.Z allows [X.Y.Z, (X+1).0.0). + elif peer_range.startswith("^") and min_major > 0: + if t_major >= min_major + 1: + return False + + return True + + +_GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN") + + +def fetch_text(url: str, timeout: int = 8) -> str | None: + """Fetch a URL and return its text, or None on failure. + + Adds an Authorization header for github.com URLs when GITHUB_TOKEN is + set (raises the rate limit from 60 to 5 000 requests/hour). + """ + headers: dict[str, str] = {} + if _GITHUB_TOKEN and ("github.com" in url or "githubusercontent.com" in url): + headers["Authorization"] = f"Bearer {_GITHUB_TOKEN}" + try: + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.read().decode("utf-8", errors="ignore") + except (urllib.error.URLError, urllib.error.HTTPError): + return None + + +def extract_abi_from_text(text: str) -> int | None: + """Extract LANGUAGE_VERSION from parser.c text.""" + match = re.search(r"#define\s+LANGUAGE_VERSION\s+(\d+)", text[:4096]) + return int(match.group(1)) if match else None + + +def extract_language_version(parser_c: pathlib.Path) -> int | None: + """Return the LANGUAGE_VERSION defined in a parser.c, or None if absent.""" + if not parser_c.is_file(): + return None + with parser_c.open("r", encoding="utf-8", errors="ignore") as fh: + head = fh.read(4096) + return extract_abi_from_text(head) + + +def md_h(text: str, level: int = 2) -> str: + return f"{'#' * level} {text}\n" + + +# ── Main ──────────────────────────────────────────────────────────────── + +def main() -> int: + blockers: dict[str, str] = {} + lines: list[str] = [] + lines.append(md_h("Tree-sitter 0.25 upgrade readiness", 1)) + lines.append("") + + current_runtime = read_current_runtime() + current_abi_range = RUNTIME_ABI_RANGES.get(current_runtime, (0, 0)) + target_abi_range = RUNTIME_ABI_RANGES.get(TARGET_RUNTIME_MAJOR_MINOR, (0, 0)) + + lines.append(f"- Current runtime: `tree-sitter@{current_runtime}.x` (ABI {current_abi_range[0]}..{current_abi_range[1]})") + lines.append(f"- Target runtime: `tree-sitter@{TARGET_RUNTIME}` (ABI {target_abi_range[0]}..{target_abi_range[1]})") + lines.append("") + + # ── Grammar peer-dep compatibility ─────────────────────────────── + lines.append(md_h("Grammar compatibility", 2)) + lines.append("| Grammar | npm latest | Peer dep | Satisfies 0.25? | ABI | Upstream ABI | Status |") + lines.append("|---|---|---|---|---|---|---|") + + ready_count = 0 + total_count = len(GRAMMARS) + + for name, (upstream_repo, upstream_branch, parser_path) in sorted(GRAMMARS.items()): + # Fetch latest npm metadata. + info = npm_view_json(name) + fetch_failed = info is None + npm_version = "?" + peer_range = None + peer_optional = True + if info: + npm_version = info.get("version", "?") + peers = info.get("peerDependencies") or {} + peer_range = peers.get("tree-sitter") + meta = info.get("peerDependenciesMeta") or {} + ts_meta = meta.get("tree-sitter") or {} + peer_optional = ts_meta.get("optional", False) if peer_range else True + + if fetch_failed: + peer_display = "? (fetch failed)" + compatible = False + else: + peer_display = peer_range or "none" + if peer_range and not peer_optional: + peer_display += " (required)" + compatible = satisfies_target(peer_range, TARGET_RUNTIME) + + # Check installed ABI using the same parser_path from GRAMMARS. + installed_parser = GITNEXUS_DIR / "node_modules" / name / parser_path + if not installed_parser.is_file(): + # Fallback to default location. + installed_parser = GITNEXUS_DIR / "node_modules" / name / "src" / "parser.c" + installed_abi = extract_language_version(installed_parser) + abi_display = str(installed_abi) if installed_abi else "?" + + # Check upstream (main/master branch) ABI for unreleased work. + upstream_url = ( + f"https://raw.githubusercontent.com/{upstream_repo}/" + f"{upstream_branch}/{parser_path}" + ) + upstream_text = fetch_text(upstream_url) + upstream_abi = extract_abi_from_text(upstream_text) if upstream_text else None + upstream_abi_display = str(upstream_abi) if upstream_abi else "?" + + # Determine status. + if fetch_failed: + status = "Unknown (fetch failed)" + blockers[name] = f"`{name}`: npm registry fetch failed — could not verify peer dep" + elif compatible: + status = "Ready" + ready_count += 1 + elif upstream_abi and upstream_abi >= 15: + status = "Unreleased (ABI 15 on main)" + blockers[name] = f"`{name}`: ABI 15 on `{upstream_repo}` main but not published to npm" + else: + status = "Blocking" + blockers[name] = f"`{name}@{npm_version}`: peer `{peer_display}` incompatible with 0.25" + + # Also check upstream package.json for relaxed peer dep. + if not compatible and not fetch_failed: + upstream_pkg_url = ( + f"https://raw.githubusercontent.com/{upstream_repo}/" + f"{upstream_branch}/package.json" + ) + upstream_pkg_text = fetch_text(upstream_pkg_url) + if upstream_pkg_text: + try: + upstream_pkg = json.loads(upstream_pkg_text) + upstream_peer = (upstream_pkg.get("peerDependencies") or {}).get("tree-sitter") + if upstream_peer and satisfies_target(upstream_peer, TARGET_RUNTIME): + status = "Unreleased (peer relaxed on main)" + blockers[name] = f"`{name}`: peer dep relaxed on `{upstream_repo}` main but not published to npm" + except json.JSONDecodeError: + pass + + compat_icon = "Yes" if compatible else "**No**" + lines.append( + f"| `{name}` | {npm_version} | {peer_display} | {compat_icon} | {abi_display} | {upstream_abi_display} | {status} |" + ) + + lines.append("") + lines.append(f"**{ready_count}/{total_count}** grammars ready for `tree-sitter@{TARGET_RUNTIME}`.") + lines.append("") + + # ── Vendored proto drift ───────────────────────────────────────── + lines.append(md_h("Vendored tree-sitter-proto", 2)) + vendored_abi = extract_language_version(VENDOR_PROTO_DIR / "src" / "parser.c") + + upstream_proto_url = ( + f"https://raw.githubusercontent.com/{UPSTREAM_PROTO_OWNER}/" + f"{UPSTREAM_PROTO_REPO}/{UPSTREAM_PROTO_BRANCH}/src/parser.c" + ) + upstream_proto_text = fetch_text(upstream_proto_url) + upstream_proto_abi = extract_abi_from_text(upstream_proto_text) if upstream_proto_text else None + + sha_url = ( + f"https://api.github.com/repos/{UPSTREAM_PROTO_OWNER}/" + f"{UPSTREAM_PROTO_REPO}/commits/{UPSTREAM_PROTO_BRANCH}" + ) + sha_text = fetch_text(sha_url) + upstream_sha = "?" + if sha_text: + try: + upstream_sha = json.loads(sha_text).get("sha", "?")[:12] + except json.JSONDecodeError: + pass + + local_proto_path = VENDOR_PROTO_DIR / "src" / "parser.c" + local_proto_text = local_proto_path.read_text(encoding="utf-8", errors="ignore") if local_proto_path.is_file() else "" + in_sync = bool( + upstream_proto_text + and local_proto_text.replace("\r\n", "\n") + == upstream_proto_text.replace("\r\n", "\n") + ) + + lines.append(f"- Upstream: `{UPSTREAM_PROTO_OWNER}/{UPSTREAM_PROTO_REPO}@{UPSTREAM_PROTO_BRANCH}` (HEAD `{upstream_sha}`)") + lines.append(f"- Upstream ABI: **{upstream_proto_abi}**") + lines.append(f"- Vendored ABI: **{vendored_abi}**") + lines.append(f"- In sync: {'yes' if in_sync else 'no — upstream has diverged'}") + + if upstream_proto_abi and vendored_abi and upstream_proto_abi > vendored_abi: + can_upgrade = upstream_proto_abi <= target_abi_range[1] + lines.append(f"- Upstream ABI {upstream_proto_abi} {'is' if can_upgrade else 'is NOT'} within target runtime range ({target_abi_range[0]}..{target_abi_range[1]})") + if can_upgrade: + lines.append(f"- **Action:** after upgrading to tree-sitter@{TARGET_RUNTIME}, regenerate vendored parser.c from upstream `{upstream_sha}`") + else: + lines.append(f"- **Action:** wait for runtime upgrade beyond {TARGET_RUNTIME} that supports ABI {upstream_proto_abi}") + blockers["vendored-proto-abi"] = f"vendored tree-sitter-proto: upstream ABI {upstream_proto_abi} outside target range" + elif not in_sync: + lines.append("- **Action:** review upstream changes; vendored copy may need updating") + blockers["vendored-proto-sync"] = "vendored tree-sitter-proto: out of sync with upstream" + + # ── Summary ────────────────────────────────────────────────────── + lines.append("") + lines.append(md_h("Summary", 2)) + if blockers: + lines.append(f"**{len(blockers)} blocker(s) remaining:**\n") + for b in blockers.values(): + lines.append(f"- {b}") + lines.append("") + lines.append("Upgrade to `tree-sitter@0.25` is **blocked**.") + else: + lines.append("All grammars are compatible. Upgrade to `tree-sitter@0.25` is **ready**.") + + print("\n".join(lines)) + return 1 if blockers else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/ci-global-upgrade.yml b/.github/workflows/ci-global-upgrade.yml deleted file mode 100644 index e181f46ad..000000000 --- a/.github/workflows/ci-global-upgrade.yml +++ /dev/null @@ -1,113 +0,0 @@ -name: Global Install Upgrade Smoke - -# Catches regressions where `npm install -g gitnexus@` fails to upgrade -# cleanly over a prior global install. Prior precedent: issue #836 and PR #843's -# incomplete fix slipped past CI because no global-upgrade test existed. -# -# Reusable workflow — only callable from ci.yml. Concurrency is governed by the -# caller (ci.yml), so no `concurrency:` block here. - -on: - workflow_call: - -jobs: - global-upgrade: - name: ${{ matrix.os }} / upgrade over ${{ matrix.prior }} - strategy: - fail-fast: false - matrix: - # macOS is the reporter's platform (issue #836) and the highest-risk - # surface for npm global-install rmdir behavior. Linux and Windows - # provide cross-platform regression coverage. - os: [macos-latest, ubuntu-latest, windows-latest] - # Prior version that must be upgraded OVER. Should be a published rc - # that preceded the fix. Bump when a known-bad version changes. - prior: ['1.6.2-rc.8'] - runs-on: ${{ matrix.os }} - timeout-minutes: 15 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: ./.github/actions/setup-gitnexus - with: - build: 'false' - - - name: Install prior published version globally - run: npm install -g gitnexus@${{ matrix.prior }} - - - name: Verify prior version installed - run: gitnexus --version - - - name: Pack current branch - working-directory: gitnexus - run: npm pack - shell: bash - - - name: Compute packed tarball path - id: tarball - working-directory: gitnexus - run: | - TARBALL=$(ls gitnexus-*.tgz | head -1) - echo "path=$(pwd)/$TARBALL" >> "$GITHUB_OUTPUT" - shell: bash - - - name: Upgrade over prior version (the actual regression test) - run: npm install -g "${{ steps.tarball.outputs.path }}" - shell: bash - - - name: Verify upgraded version runs - run: gitnexus --version - - - name: Verify vendor/ has no nested node_modules after install - shell: bash - run: | - # The original #836 bug was about vendor/tree-sitter-proto/node_modules/ - # blocking rmdir on upgrade. That is what the fix eliminates. A - # vendor/tree-sitter-proto/build/ directory can still appear because - # node-gyp-build compiles through the npm-created symlink; the - # contents are plain object files and .node binaries that rmdir - # handles fine, evidenced by this test getting past the upgrade step. - GLOBAL_PREFIX=$(npm root -g) - if [ -d "$GLOBAL_PREFIX/gitnexus/vendor/tree-sitter-proto" ]; then - echo "=== Contents of global vendor/tree-sitter-proto/ ===" - ls -la "$GLOBAL_PREFIX/gitnexus/vendor/tree-sitter-proto/" - if [ -d "$GLOBAL_PREFIX/gitnexus/vendor/tree-sitter-proto/node_modules" ]; then - echo "::error::vendor/tree-sitter-proto/node_modules/ was created — this is the #836 hazard" - exit 1 - fi - fi - - ignore-scripts: - name: ${{ matrix.os }} / --ignore-scripts degraded mode - strategy: - fail-fast: false - matrix: - os: [macos-latest, ubuntu-latest, windows-latest] - runs-on: ${{ matrix.os }} - timeout-minutes: 10 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: ./.github/actions/setup-gitnexus - with: - build: 'false' - - - name: Pack current branch - working-directory: gitnexus - run: npm pack - shell: bash - - - name: Compute packed tarball path - id: tarball - working-directory: gitnexus - run: | - TARBALL=$(ls gitnexus-*.tgz | head -1) - echo "path=$(pwd)/$TARBALL" >> "$GITHUB_OUTPUT" - shell: bash - - - name: Install globally with --ignore-scripts - run: npm install -g --ignore-scripts "${{ steps.tarball.outputs.path }}" - shell: bash - - - name: Verify CLI boots without postinstall (proto parsing may be unavailable) - run: gitnexus --version diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2eeeaab2..cf0c6d5c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,11 +48,6 @@ jobs: permissions: contents: read - global-upgrade: - uses: ./.github/workflows/ci-global-upgrade.yml - permissions: - contents: read - # ── Save PR metadata for the reporting workflow ───────────────── # The ci-report.yml workflow (triggered by workflow_run) needs the # PR number and job results to post a comment. We save them as an @@ -61,7 +56,7 @@ jobs: save-pr-meta: name: Save PR Metadata if: always() && github.event_name == 'pull_request' - needs: [quality, tests, e2e, global-upgrade] + needs: [quality, tests, e2e] runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -72,7 +67,6 @@ jobs: QUALITY: ${{ needs.quality.result }} TESTS: ${{ needs.tests.result }} E2E: ${{ needs.e2e.result }} - GLOBAL_UPGRADE: ${{ needs.global-upgrade.result }} run: | mkdir -p pr-meta echo "$PR_NUMBER" > pr-meta/pr_number @@ -101,7 +95,7 @@ jobs: # Single required check for branch protection. ci-status: name: CI Gate - needs: [quality, tests, e2e, global-upgrade] + needs: [quality, tests, e2e] if: always() runs-on: ubuntu-latest timeout-minutes: 5 @@ -112,12 +106,10 @@ jobs: QUALITY: ${{ needs.quality.result }} TESTS: ${{ needs.tests.result }} E2E: ${{ needs.e2e.result }} - GLOBAL_UPGRADE: ${{ needs.global-upgrade.result }} run: | echo "Quality: $QUALITY" echo "Tests: $TESTS" echo "E2E: $E2E" - echo "Global upgrade: $GLOBAL_UPGRADE" if [[ "$QUALITY" != "success" ]] || [[ "$TESTS" != "success" ]]; then echo "::error::Quality or test jobs failed" @@ -127,7 +119,3 @@ jobs: echo "::error::E2E job failed" exit 1 fi - if [[ "$GLOBAL_UPGRADE" != "success" && "$GLOBAL_UPGRADE" != "skipped" ]]; then - echo "::error::Global upgrade smoke failed" - exit 1 - fi diff --git a/.github/workflows/tree-sitter-upgrade-readiness.yml b/.github/workflows/tree-sitter-upgrade-readiness.yml new file mode 100644 index 000000000..74ce72a27 --- /dev/null +++ b/.github/workflows/tree-sitter-upgrade-readiness.yml @@ -0,0 +1,185 @@ +name: Tree-sitter Upgrade Readiness + +# Monitors readiness for upgrading tree-sitter to 0.25.x. Tracks: +# 1. Peer-dep compatibility — can each grammar install cleanly with +# tree-sitter@0.25.0 without --legacy-peer-deps? +# 2. Vendored proto drift — has coder3101/tree-sitter-proto moved +# ahead of our vendored snapshot? +# See .github/scripts/check-tree-sitter-upgrade-readiness.py for the logic. +# +# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". + +on: + schedule: + # Daily at 09:00 UTC. Matches Dependabot's daily cadence so drift + # and dep PRs surface together. + - cron: '0 9 * * *' + workflow_dispatch: + pull_request: + paths: + - '.github/scripts/check-tree-sitter-upgrade-readiness.py' + - '.github/workflows/tree-sitter-upgrade-readiness.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + readiness: + name: Check upgrade readiness + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + # Needed to open/update the tracking issue on scheduled runs. + issues: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: ./.github/actions/setup-gitnexus + with: + build: 'false' + + - name: Run upgrade readiness check + id: readiness + shell: bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set +e + python3 .github/scripts/check-tree-sitter-upgrade-readiness.py > drift-report.md + code=$? + set -e + echo "exit_code=$code" >> "$GITHUB_OUTPUT" + { + echo 'report<> "$GITHUB_OUTPUT" + echo "=== Report ===" + cat drift-report.md + + # On PR runs, the script validates that it runs correctly. Blockers + # are informational — the scheduled run opens a tracking issue. + - name: Annotate PR with readiness status + if: github.event_name == 'pull_request' && steps.readiness.outputs.exit_code != '0' + run: | + echo "::warning::Tree-sitter 0.25 upgrade has blockers. See job output for the full readiness report." + + - name: Upsert tracking issue on scheduled runs + if: > + github.event_name == 'schedule' && + steps.readiness.outputs.exit_code != '0' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + REPORT: ${{ steps.readiness.outputs.report }} + with: + script: | + const title = 'Tree-sitter 0.25 upgrade readiness'; + const report = process.env.REPORT; + const body = report + '\n\n' + + 'Generated daily by `.github/workflows/tree-sitter-upgrade-readiness.yml`. ' + + 'Closes automatically when all blockers are resolved.'; + const { data: open } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'tree-sitter-drift', + per_page: 10, + }); + const existing = open.find(i => i.title === title); + if (existing) { + // Extract ready/total count for the changelog comment. + const readyMatch = report.match(/\*\*(\d+)\/(\d+)\*\* grammars ready/); + const blockerMatch = report.match(/\*\*(\d+) blocker/); + const ready = readyMatch ? readyMatch[1] : '?'; + const total = readyMatch ? readyMatch[2] : '?'; + const blockers = blockerMatch ? blockerMatch[1] : '?'; + + // Find grammars whose status changed by diffing the old and + // new table rows. Each row looks like: + // | `tree-sitter-foo` | ... | Ready | + // | `tree-sitter-foo` | ... | Blocking | + const parseRows = (md) => { + const map = {}; + for (const m of md.matchAll(/\| `(tree-sitter-[^`]+)` \|.*?\| (\S+(?:\s\S+)*?) \|$/gm)) { + map[m[1]] = m[2].trim(); + } + return map; + }; + const oldRows = parseRows(existing.body || ''); + const newRows = parseRows(report); + const changes = []; + for (const [name, newStatus] of Object.entries(newRows)) { + const oldStatus = oldRows[name]; + if (oldStatus && oldStatus !== newStatus) { + changes.push(`\`${name}\`: ${oldStatus} → ${newStatus}`); + } + } + + const today = new Date().toISOString().slice(0, 10); + let comment = `**${today}:** ${ready}/${total} ready. ${blockers} blocker(s) remaining.`; + if (changes.length > 0) { + comment += '\n\nChanges:\n' + changes.map(c => `- ${c}`).join('\n'); + } else { + comment += ' No changes from previous run.'; + } + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existing.number, + body: comment, + }); + + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existing.number, + body, + }); + core.info(`Updated existing issue #${existing.number}`); + } else { + const { data: created } = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title, + body, + labels: ['tree-sitter-drift', 'dependencies'], + }); + core.info(`Opened issue #${created.number}`); + } + + - name: Close tracking issue on clean scheduled runs + if: > + github.event_name == 'schedule' && + steps.readiness.outputs.exit_code == '0' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const title = 'Tree-sitter 0.25 upgrade readiness'; + const { data: open } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'tree-sitter-drift', + per_page: 10, + }); + const existing = open.find(i => i.title === title); + if (existing) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existing.number, + body: 'All grammars are now compatible with tree-sitter@0.25. Upgrade is ready! Closing automatically.', + }); + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existing.number, + state: 'closed', + }); + core.info(`Closed issue #${existing.number}`); + } From 06f18ada159ab88ab4f18bd72471a0c8928c1f3f Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:50:54 +0100 Subject: [PATCH 58/67] refactor(ingestion): move class extraction configs to configs/ subdirectory (#879) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * refactor(ingestion): move class extraction configs to configs/ subdirectory Extract inline ClassExtractionConfig objects from 13 language provider files into 11 config files under class-extractors/configs/, matching the pattern established by method-extractors/configs/ and field-extractors/configs/. Pure structural refactor — zero behavioral change. All class extraction tests pass unchanged. Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/8ca2bf18-46ea-41c3-9c7f-9eb3f5752ade Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: resolve prettier formatting in c-cpp.ts import Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/1b1e3c43-fc0e-444c-b109-cc0c56ffb470 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --- .../class-extractors/configs/c-cpp.ts | 15 +++++++ .../class-extractors/configs/csharp.ts | 24 +++++++++++ .../class-extractors/configs/dart.ts | 10 +++++ .../ingestion/class-extractors/configs/go.ts | 21 ++++++++++ .../ingestion/class-extractors/configs/jvm.ts | 40 +++++++++++++++++++ .../ingestion/class-extractors/configs/php.ts | 10 +++++ .../class-extractors/configs/python.ts | 10 +++++ .../class-extractors/configs/ruby.ts | 10 +++++ .../class-extractors/configs/rust.ts | 10 +++++ .../class-extractors/configs/swift.ts | 17 ++++++++ .../configs/typescript-javascript.ts | 34 ++++++++++++++++ .../src/core/ingestion/languages/c-cpp.ts | 12 ++---- .../src/core/ingestion/languages/csharp.ts | 21 +--------- gitnexus/src/core/ingestion/languages/dart.ts | 7 +--- gitnexus/src/core/ingestion/languages/go.ts | 18 +-------- gitnexus/src/core/ingestion/languages/java.ts | 18 +-------- .../src/core/ingestion/languages/kotlin.ts | 12 +----- gitnexus/src/core/ingestion/languages/php.ts | 7 +--- .../src/core/ingestion/languages/python.ts | 7 +--- gitnexus/src/core/ingestion/languages/ruby.ts | 7 +--- gitnexus/src/core/ingestion/languages/rust.ts | 7 +--- .../src/core/ingestion/languages/swift.ts | 14 +------ .../core/ingestion/languages/typescript.ts | 28 +++---------- gitnexus/src/core/ingestion/languages/vue.ts | 17 +------- 24 files changed, 232 insertions(+), 144 deletions(-) create mode 100644 gitnexus/src/core/ingestion/class-extractors/configs/c-cpp.ts create mode 100644 gitnexus/src/core/ingestion/class-extractors/configs/csharp.ts create mode 100644 gitnexus/src/core/ingestion/class-extractors/configs/dart.ts create mode 100644 gitnexus/src/core/ingestion/class-extractors/configs/go.ts create mode 100644 gitnexus/src/core/ingestion/class-extractors/configs/jvm.ts create mode 100644 gitnexus/src/core/ingestion/class-extractors/configs/php.ts create mode 100644 gitnexus/src/core/ingestion/class-extractors/configs/python.ts create mode 100644 gitnexus/src/core/ingestion/class-extractors/configs/ruby.ts create mode 100644 gitnexus/src/core/ingestion/class-extractors/configs/rust.ts create mode 100644 gitnexus/src/core/ingestion/class-extractors/configs/swift.ts create mode 100644 gitnexus/src/core/ingestion/class-extractors/configs/typescript-javascript.ts diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/c-cpp.ts b/gitnexus/src/core/ingestion/class-extractors/configs/c-cpp.ts new file mode 100644 index 000000000..fcc1a22bf --- /dev/null +++ b/gitnexus/src/core/ingestion/class-extractors/configs/c-cpp.ts @@ -0,0 +1,15 @@ +// gitnexus/src/core/ingestion/class-extractors/configs/c-cpp.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ClassExtractionConfig } from '../../class-types.js'; + +export const cClassConfig: ClassExtractionConfig = { + language: SupportedLanguages.C, + typeDeclarationNodes: ['struct_specifier', 'enum_specifier'], +}; + +export const cppClassConfig: ClassExtractionConfig = { + language: SupportedLanguages.CPlusPlus, + typeDeclarationNodes: ['class_specifier', 'struct_specifier', 'enum_specifier'], + ancestorScopeNodeTypes: ['namespace_definition', 'class_specifier', 'struct_specifier'], +}; diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/csharp.ts b/gitnexus/src/core/ingestion/class-extractors/configs/csharp.ts new file mode 100644 index 000000000..59b7be617 --- /dev/null +++ b/gitnexus/src/core/ingestion/class-extractors/configs/csharp.ts @@ -0,0 +1,24 @@ +// gitnexus/src/core/ingestion/class-extractors/configs/csharp.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ClassExtractionConfig } from '../../class-types.js'; + +export const csharpClassConfig: ClassExtractionConfig = { + language: SupportedLanguages.CSharp, + typeDeclarationNodes: [ + 'class_declaration', + 'interface_declaration', + 'struct_declaration', + 'enum_declaration', + 'record_declaration', + ], + fileScopeNodeTypes: ['file_scoped_namespace_declaration'], + ancestorScopeNodeTypes: [ + 'namespace_declaration', + 'class_declaration', + 'interface_declaration', + 'struct_declaration', + 'enum_declaration', + 'record_declaration', + ], +}; diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/dart.ts b/gitnexus/src/core/ingestion/class-extractors/configs/dart.ts new file mode 100644 index 000000000..c46c05533 --- /dev/null +++ b/gitnexus/src/core/ingestion/class-extractors/configs/dart.ts @@ -0,0 +1,10 @@ +// gitnexus/src/core/ingestion/class-extractors/configs/dart.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ClassExtractionConfig } from '../../class-types.js'; + +export const dartClassConfig: ClassExtractionConfig = { + language: SupportedLanguages.Dart, + typeDeclarationNodes: ['class_definition', 'extension_declaration', 'enum_declaration'], + ancestorScopeNodeTypes: ['class_definition', 'extension_declaration', 'enum_declaration'], +}; diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/go.ts b/gitnexus/src/core/ingestion/class-extractors/configs/go.ts new file mode 100644 index 000000000..58dade9fa --- /dev/null +++ b/gitnexus/src/core/ingestion/class-extractors/configs/go.ts @@ -0,0 +1,21 @@ +// gitnexus/src/core/ingestion/class-extractors/configs/go.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ClassExtractionConfig } from '../../class-types.js'; + +export const goClassConfig: ClassExtractionConfig = { + language: SupportedLanguages.Go, + typeDeclarationNodes: ['type_declaration'], + fileScopeNodeTypes: ['package_clause'], + extractName(node) { + const typeSpec = node.namedChildren.find((child) => child.type === 'type_spec'); + return typeSpec?.childForFieldName('name')?.text; + }, + extractType(node) { + const typeSpec = node.namedChildren.find((child) => child.type === 'type_spec'); + const typeNode = typeSpec?.childForFieldName('type'); + if (typeNode?.type === 'struct_type') return 'Struct'; + if (typeNode?.type === 'interface_type') return 'Interface'; + return undefined; + }, +}; diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/jvm.ts b/gitnexus/src/core/ingestion/class-extractors/configs/jvm.ts new file mode 100644 index 000000000..fbd22f545 --- /dev/null +++ b/gitnexus/src/core/ingestion/class-extractors/configs/jvm.ts @@ -0,0 +1,40 @@ +// gitnexus/src/core/ingestion/class-extractors/configs/jvm.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ClassExtractionConfig } from '../../class-types.js'; + +// --------------------------------------------------------------------------- +// Java +// --------------------------------------------------------------------------- + +export const javaClassConfig: ClassExtractionConfig = { + language: SupportedLanguages.Java, + typeDeclarationNodes: [ + 'class_declaration', + 'interface_declaration', + 'enum_declaration', + 'record_declaration', + ], + fileScopeNodeTypes: ['package_declaration'], + ancestorScopeNodeTypes: [ + 'class_declaration', + 'interface_declaration', + 'enum_declaration', + 'record_declaration', + ], +}; + +// --------------------------------------------------------------------------- +// Kotlin +// --------------------------------------------------------------------------- + +export const kotlinClassConfig: ClassExtractionConfig = { + language: SupportedLanguages.Kotlin, + typeDeclarationNodes: ['class_declaration', 'object_declaration', 'companion_object'], + fileScopeNodeTypes: ['package_header'], + ancestorScopeNodeTypes: ['class_declaration', 'object_declaration', 'companion_object'], + extractType(node) { + if (node.type !== 'class_declaration') return undefined; + return node.children.some((child) => child?.text === 'interface') ? 'Interface' : 'Class'; + }, +}; diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/php.ts b/gitnexus/src/core/ingestion/class-extractors/configs/php.ts new file mode 100644 index 000000000..850b415b4 --- /dev/null +++ b/gitnexus/src/core/ingestion/class-extractors/configs/php.ts @@ -0,0 +1,10 @@ +// gitnexus/src/core/ingestion/class-extractors/configs/php.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ClassExtractionConfig } from '../../class-types.js'; + +export const phpClassConfig: ClassExtractionConfig = { + language: SupportedLanguages.PHP, + typeDeclarationNodes: ['class_declaration', 'interface_declaration', 'enum_declaration'], + ancestorScopeNodeTypes: ['namespace_definition'], +}; diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/python.ts b/gitnexus/src/core/ingestion/class-extractors/configs/python.ts new file mode 100644 index 000000000..42761468e --- /dev/null +++ b/gitnexus/src/core/ingestion/class-extractors/configs/python.ts @@ -0,0 +1,10 @@ +// gitnexus/src/core/ingestion/class-extractors/configs/python.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ClassExtractionConfig } from '../../class-types.js'; + +export const pythonClassConfig: ClassExtractionConfig = { + language: SupportedLanguages.Python, + typeDeclarationNodes: ['class_definition'], + ancestorScopeNodeTypes: ['class_definition'], +}; diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/ruby.ts b/gitnexus/src/core/ingestion/class-extractors/configs/ruby.ts new file mode 100644 index 000000000..2c4c711bd --- /dev/null +++ b/gitnexus/src/core/ingestion/class-extractors/configs/ruby.ts @@ -0,0 +1,10 @@ +// gitnexus/src/core/ingestion/class-extractors/configs/ruby.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ClassExtractionConfig } from '../../class-types.js'; + +export const rubyClassConfig: ClassExtractionConfig = { + language: SupportedLanguages.Ruby, + typeDeclarationNodes: ['class'], + ancestorScopeNodeTypes: ['module', 'class'], +}; diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/rust.ts b/gitnexus/src/core/ingestion/class-extractors/configs/rust.ts new file mode 100644 index 000000000..7f3873802 --- /dev/null +++ b/gitnexus/src/core/ingestion/class-extractors/configs/rust.ts @@ -0,0 +1,10 @@ +// gitnexus/src/core/ingestion/class-extractors/configs/rust.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ClassExtractionConfig } from '../../class-types.js'; + +export const rustClassConfig: ClassExtractionConfig = { + language: SupportedLanguages.Rust, + typeDeclarationNodes: ['struct_item', 'enum_item'], + ancestorScopeNodeTypes: ['mod_item', 'struct_item', 'enum_item'], +}; diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/swift.ts b/gitnexus/src/core/ingestion/class-extractors/configs/swift.ts new file mode 100644 index 000000000..713a02496 --- /dev/null +++ b/gitnexus/src/core/ingestion/class-extractors/configs/swift.ts @@ -0,0 +1,17 @@ +// gitnexus/src/core/ingestion/class-extractors/configs/swift.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ClassExtractionConfig } from '../../class-types.js'; + +export const swiftClassConfig: ClassExtractionConfig = { + language: SupportedLanguages.Swift, + typeDeclarationNodes: ['class_declaration', 'protocol_declaration'], + ancestorScopeNodeTypes: ['class_declaration', 'protocol_declaration'], + extractType(node) { + if (node.type === 'protocol_declaration') return 'Interface'; + if (node.type !== 'class_declaration') return undefined; + if (node.children.some((child) => child?.text === 'struct')) return 'Struct'; + if (node.children.some((child) => child?.text === 'enum')) return 'Enum'; + return 'Class'; + }, +}; diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/typescript-javascript.ts b/gitnexus/src/core/ingestion/class-extractors/configs/typescript-javascript.ts new file mode 100644 index 000000000..b2262432d --- /dev/null +++ b/gitnexus/src/core/ingestion/class-extractors/configs/typescript-javascript.ts @@ -0,0 +1,34 @@ +// gitnexus/src/core/ingestion/class-extractors/configs/typescript-javascript.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ClassExtractionConfig } from '../../class-types.js'; + +const shared: Omit = { + typeDeclarationNodes: [ + 'class_declaration', + 'abstract_class_declaration', + 'interface_declaration', + 'enum_declaration', + ], + ancestorScopeNodeTypes: [ + 'class_declaration', + 'abstract_class_declaration', + 'interface_declaration', + 'enum_declaration', + ], +}; + +export const typescriptClassConfig: ClassExtractionConfig = { + ...shared, + language: SupportedLanguages.TypeScript, +}; + +export const javascriptClassConfig: ClassExtractionConfig = { + ...shared, + language: SupportedLanguages.JavaScript, +}; + +export const vueClassConfig: ClassExtractionConfig = { + ...shared, + language: SupportedLanguages.Vue, +}; diff --git a/gitnexus/src/core/ingestion/languages/c-cpp.ts b/gitnexus/src/core/ingestion/languages/c-cpp.ts index 5b887634f..7a6703a7c 100644 --- a/gitnexus/src/core/ingestion/languages/c-cpp.ts +++ b/gitnexus/src/core/ingestion/languages/c-cpp.ts @@ -10,6 +10,7 @@ import { SupportedLanguages } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; +import { cClassConfig, cppClassConfig } from '../class-extractors/configs/c-cpp.js'; import { defineLanguage } from '../language-provider.js'; import { typeConfig as cCppConfig } from '../type-extractors/c-cpp.js'; import { cCppExportChecker } from '../export-detection.js'; @@ -145,16 +146,9 @@ const C_BUILT_INS: ReadonlySet = new Set([ 'put', ]); -const cClassExtractor = createClassExtractor({ - language: SupportedLanguages.C, - typeDeclarationNodes: ['struct_specifier', 'enum_specifier'], -}); +const cClassExtractor = createClassExtractor(cClassConfig); -const cppClassExtractor = createClassExtractor({ - language: SupportedLanguages.CPlusPlus, - typeDeclarationNodes: ['class_specifier', 'struct_specifier', 'enum_specifier'], - ancestorScopeNodeTypes: ['namespace_definition', 'class_specifier', 'struct_specifier'], -}); +const cppClassExtractor = createClassExtractor(cppClassConfig); /** * C/C++ function name extraction — unwraps pointer_declarator / reference_declarator / diff --git a/gitnexus/src/core/ingestion/languages/csharp.ts b/gitnexus/src/core/ingestion/languages/csharp.ts index 6ec3a6a8b..bf02412d0 100644 --- a/gitnexus/src/core/ingestion/languages/csharp.ts +++ b/gitnexus/src/core/ingestion/languages/csharp.ts @@ -8,6 +8,7 @@ import { SupportedLanguages } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; +import { csharpClassConfig } from '../class-extractors/configs/csharp.js'; import { defineLanguage } from '../language-provider.js'; import { typeConfig as csharpConfig } from '../type-extractors/csharp.js'; import { csharpExportChecker } from '../export-detection.js'; @@ -126,24 +127,6 @@ export const csharpProvider = defineLanguage({ mroStrategy: 'implements-split', fieldExtractor: createFieldExtractor(csharpFieldConfig), methodExtractor: createMethodExtractor(csharpMethodConfig), - classExtractor: createClassExtractor({ - language: SupportedLanguages.CSharp, - typeDeclarationNodes: [ - 'class_declaration', - 'interface_declaration', - 'struct_declaration', - 'enum_declaration', - 'record_declaration', - ], - fileScopeNodeTypes: ['file_scoped_namespace_declaration'], - ancestorScopeNodeTypes: [ - 'namespace_declaration', - 'class_declaration', - 'interface_declaration', - 'struct_declaration', - 'enum_declaration', - 'record_declaration', - ], - }), + classExtractor: createClassExtractor(csharpClassConfig), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/dart.ts b/gitnexus/src/core/ingestion/languages/dart.ts index 7dc6769c6..779592cdd 100644 --- a/gitnexus/src/core/ingestion/languages/dart.ts +++ b/gitnexus/src/core/ingestion/languages/dart.ts @@ -15,6 +15,7 @@ import type { NodeLabel } from 'gitnexus-shared'; import { FUNCTION_NODE_TYPES } from '../utils/ast-helpers.js'; import { SupportedLanguages } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; +import { dartClassConfig } from '../class-extractors/configs/dart.js'; import { defineLanguage } from '../language-provider.js'; import { typeConfig as dartConfig } from '../type-extractors/dart.js'; import { dartExportChecker } from '../export-detection.js'; @@ -93,11 +94,7 @@ export const dartProvider = defineLanguage({ importSemantics: 'wildcard-leaf', fieldExtractor: createFieldExtractor(dartFieldConfig), methodExtractor: createMethodExtractor(dartMethodConfig), - classExtractor: createClassExtractor({ - language: SupportedLanguages.Dart, - typeDeclarationNodes: ['class_definition', 'extension_declaration', 'enum_declaration'], - ancestorScopeNodeTypes: ['class_definition', 'extension_declaration', 'enum_declaration'], - }), + classExtractor: createClassExtractor(dartClassConfig), enclosingFunctionFinder: dartEnclosingFunctionFinder, builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/go.ts b/gitnexus/src/core/ingestion/languages/go.ts index 2a2b35f50..5e75b3088 100644 --- a/gitnexus/src/core/ingestion/languages/go.ts +++ b/gitnexus/src/core/ingestion/languages/go.ts @@ -11,6 +11,7 @@ import { SupportedLanguages } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; +import { goClassConfig } from '../class-extractors/configs/go.js'; import { defineLanguage } from '../language-provider.js'; import { typeConfig as goConfig } from '../type-extractors/go.js'; import { goExportChecker } from '../export-detection.js'; @@ -31,20 +32,5 @@ export const goProvider = defineLanguage({ importSemantics: 'wildcard-leaf', fieldExtractor: createFieldExtractor(goFieldConfig), methodExtractor: createMethodExtractor(goMethodConfig), - classExtractor: createClassExtractor({ - language: SupportedLanguages.Go, - typeDeclarationNodes: ['type_declaration'], - fileScopeNodeTypes: ['package_clause'], - extractName(node) { - const typeSpec = node.namedChildren.find((child) => child.type === 'type_spec'); - return typeSpec?.childForFieldName('name')?.text; - }, - extractType(node) { - const typeSpec = node.namedChildren.find((child) => child.type === 'type_spec'); - const typeNode = typeSpec?.childForFieldName('type'); - if (typeNode?.type === 'struct_type') return 'Struct'; - if (typeNode?.type === 'interface_type') return 'Interface'; - return undefined; - }, - }), + classExtractor: createClassExtractor(goClassConfig), }); diff --git a/gitnexus/src/core/ingestion/languages/java.ts b/gitnexus/src/core/ingestion/languages/java.ts index b9fab77f1..297b5eea1 100644 --- a/gitnexus/src/core/ingestion/languages/java.ts +++ b/gitnexus/src/core/ingestion/languages/java.ts @@ -9,6 +9,7 @@ import { SupportedLanguages } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; +import { javaClassConfig } from '../class-extractors/configs/jvm.js'; import { defineLanguage } from '../language-provider.js'; import { javaTypeConfig } from '../type-extractors/jvm.js'; import { javaExportChecker } from '../export-detection.js'; @@ -32,20 +33,5 @@ export const javaProvider = defineLanguage({ mroStrategy: 'implements-split', fieldExtractor: createFieldExtractor(javaConfig), methodExtractor: createMethodExtractor(javaMethodConfig), - classExtractor: createClassExtractor({ - language: SupportedLanguages.Java, - typeDeclarationNodes: [ - 'class_declaration', - 'interface_declaration', - 'enum_declaration', - 'record_declaration', - ], - fileScopeNodeTypes: ['package_declaration'], - ancestorScopeNodeTypes: [ - 'class_declaration', - 'interface_declaration', - 'enum_declaration', - 'record_declaration', - ], - }), + classExtractor: createClassExtractor(javaClassConfig), }); diff --git a/gitnexus/src/core/ingestion/languages/kotlin.ts b/gitnexus/src/core/ingestion/languages/kotlin.ts index 94c47e7ab..1017e6aae 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin.ts @@ -9,6 +9,7 @@ import { SupportedLanguages } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; +import { kotlinClassConfig } from '../class-extractors/configs/jvm.js'; import { defineLanguage } from '../language-provider.js'; import { kotlinTypeConfig } from '../type-extractors/jvm.js'; import { kotlinExportChecker } from '../export-detection.js'; @@ -107,16 +108,7 @@ export const kotlinProvider = defineLanguage({ mroStrategy: 'implements-split', fieldExtractor: createFieldExtractor(kotlinConfig), methodExtractor: createMethodExtractor(kotlinMethodConfig), - classExtractor: createClassExtractor({ - language: SupportedLanguages.Kotlin, - typeDeclarationNodes: ['class_declaration', 'object_declaration', 'companion_object'], - fileScopeNodeTypes: ['package_header'], - ancestorScopeNodeTypes: ['class_declaration', 'object_declaration', 'companion_object'], - extractType(node) { - if (node.type !== 'class_declaration') return undefined; - return node.children.some((child) => child?.text === 'interface') ? 'Interface' : 'Class'; - }, - }), + classExtractor: createClassExtractor(kotlinClassConfig), builtInNames: BUILT_INS, labelOverride: (functionNode, defaultLabel) => { if (defaultLabel !== 'Function') return defaultLabel; diff --git a/gitnexus/src/core/ingestion/languages/php.ts b/gitnexus/src/core/ingestion/languages/php.ts index 642c6bd83..2f8be3f98 100644 --- a/gitnexus/src/core/ingestion/languages/php.ts +++ b/gitnexus/src/core/ingestion/languages/php.ts @@ -8,6 +8,7 @@ import { SupportedLanguages } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; +import { phpClassConfig } from '../class-extractors/configs/php.js'; import { defineLanguage } from '../language-provider.js'; import { typeConfig as phpConfig } from '../type-extractors/php.js'; import { phpExportChecker } from '../export-detection.js'; @@ -239,11 +240,7 @@ export const phpProvider = defineLanguage({ namedBindingExtractor: extractPhpNamedBindings, fieldExtractor: createFieldExtractor(phpFieldConfig), methodExtractor: createMethodExtractor(phpMethodConfig), - classExtractor: createClassExtractor({ - language: SupportedLanguages.PHP, - typeDeclarationNodes: ['class_declaration', 'interface_declaration', 'enum_declaration'], - ancestorScopeNodeTypes: ['namespace_definition'], - }), + classExtractor: createClassExtractor(phpClassConfig), descriptionExtractor: phpDescriptionExtractor, isRouteFile: isPhpRouteFile, builtInNames: BUILT_INS, diff --git a/gitnexus/src/core/ingestion/languages/python.ts b/gitnexus/src/core/ingestion/languages/python.ts index 8c776d4a0..70ca7e19c 100644 --- a/gitnexus/src/core/ingestion/languages/python.ts +++ b/gitnexus/src/core/ingestion/languages/python.ts @@ -12,6 +12,7 @@ import { SupportedLanguages } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; +import { pythonClassConfig } from '../class-extractors/configs/python.js'; import { defineLanguage } from '../language-provider.js'; import { typeConfig as pythonConfig } from '../type-extractors/python.js'; import { pythonExportChecker } from '../export-detection.js'; @@ -65,10 +66,6 @@ export const pythonProvider = defineLanguage({ mroStrategy: 'c3', fieldExtractor: createFieldExtractor(pythonFieldConfig), methodExtractor: createMethodExtractor(pythonMethodConfig), - classExtractor: createClassExtractor({ - language: SupportedLanguages.Python, - typeDeclarationNodes: ['class_definition'], - ancestorScopeNodeTypes: ['class_definition'], - }), + classExtractor: createClassExtractor(pythonClassConfig), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/ruby.ts b/gitnexus/src/core/ingestion/languages/ruby.ts index a15b5439c..7cb0e08da 100644 --- a/gitnexus/src/core/ingestion/languages/ruby.ts +++ b/gitnexus/src/core/ingestion/languages/ruby.ts @@ -10,6 +10,7 @@ import { SupportedLanguages } from 'gitnexus-shared'; import type { NodeLabel } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; +import { rubyClassConfig } from '../class-extractors/configs/ruby.js'; import { defineLanguage } from '../language-provider.js'; import type { SyntaxNode } from '../utils/ast-helpers.js'; import { typeConfig as rubyConfig } from '../type-extractors/ruby.js'; @@ -128,10 +129,6 @@ export const rubyProvider = defineLanguage({ ...rubyMethodConfig, extractFunctionName: rubyExtractFunctionName, }), - classExtractor: createClassExtractor({ - language: SupportedLanguages.Ruby, - typeDeclarationNodes: ['class'], - ancestorScopeNodeTypes: ['module', 'class'], - }), + classExtractor: createClassExtractor(rubyClassConfig), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/rust.ts b/gitnexus/src/core/ingestion/languages/rust.ts index 5e664ef8f..961233ff7 100644 --- a/gitnexus/src/core/ingestion/languages/rust.ts +++ b/gitnexus/src/core/ingestion/languages/rust.ts @@ -13,6 +13,7 @@ import { SupportedLanguages } from 'gitnexus-shared'; import type { NodeLabel } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; +import { rustClassConfig } from '../class-extractors/configs/rust.js'; import { defineLanguage } from '../language-provider.js'; import type { SyntaxNode } from '../utils/ast-helpers.js'; import { typeConfig as rustConfig } from '../type-extractors/rust.js'; @@ -125,10 +126,6 @@ export const rustProvider = defineLanguage({ ...rustMethodConfig, extractFunctionName: rustExtractFunctionName, }), - classExtractor: createClassExtractor({ - language: SupportedLanguages.Rust, - typeDeclarationNodes: ['struct_item', 'enum_item'], - ancestorScopeNodeTypes: ['mod_item', 'struct_item', 'enum_item'], - }), + classExtractor: createClassExtractor(rustClassConfig), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/swift.ts b/gitnexus/src/core/ingestion/languages/swift.ts index 314c27c1d..cc9706bbf 100644 --- a/gitnexus/src/core/ingestion/languages/swift.ts +++ b/gitnexus/src/core/ingestion/languages/swift.ts @@ -13,6 +13,7 @@ import { SupportedLanguages } from 'gitnexus-shared'; import type { NodeLabel } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; +import { swiftClassConfig } from '../class-extractors/configs/swift.js'; import { defineLanguage } from '../language-provider.js'; import { typeConfig as swiftConfig } from '../type-extractors/swift.js'; import { swiftExportChecker } from '../export-detection.js'; @@ -245,18 +246,7 @@ export const swiftProvider = defineLanguage({ ...swiftMethodConfig, extractFunctionName: swiftExtractFunctionName, }), - classExtractor: createClassExtractor({ - language: SupportedLanguages.Swift, - typeDeclarationNodes: ['class_declaration', 'protocol_declaration'], - ancestorScopeNodeTypes: ['class_declaration', 'protocol_declaration'], - extractType(node) { - if (node.type === 'protocol_declaration') return 'Interface'; - if (node.type !== 'class_declaration') return undefined; - if (node.children.some((child) => child?.text === 'struct')) return 'Struct'; - if (node.children.some((child) => child?.text === 'enum')) return 'Enum'; - return 'Class'; - }, - }), + classExtractor: createClassExtractor(swiftClassConfig), implicitImportWirer: wireSwiftImplicitImports, builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/typescript.ts b/gitnexus/src/core/ingestion/languages/typescript.ts index b680c5aa1..5c8e315ff 100644 --- a/gitnexus/src/core/ingestion/languages/typescript.ts +++ b/gitnexus/src/core/ingestion/languages/typescript.ts @@ -11,7 +11,10 @@ import { SupportedLanguages } from 'gitnexus-shared'; import type { NodeLabel } from 'gitnexus-shared'; import { defineLanguage } from '../language-provider.js'; import { createClassExtractor } from '../class-extractors/generic.js'; -import type { ClassExtractionConfig } from '../class-types.js'; +import { + typescriptClassConfig, + javascriptClassConfig, +} from '../class-extractors/configs/typescript-javascript.js'; import type { SyntaxNode } from '../utils/ast-helpers.js'; import { typeConfig as typescriptConfig } from '../type-extractors/typescript.js'; import { tsExportChecker } from '../export-detection.js'; @@ -149,22 +152,6 @@ export const BUILT_INS: ReadonlySet = new Set([ 'valueOf', ]); -const tsJsClassConfig: ClassExtractionConfig = { - language: SupportedLanguages.TypeScript, - typeDeclarationNodes: [ - 'class_declaration', - 'abstract_class_declaration', - 'interface_declaration', - 'enum_declaration', - ], - ancestorScopeNodeTypes: [ - 'class_declaration', - 'abstract_class_declaration', - 'interface_declaration', - 'enum_declaration', - ], -}; - export const typescriptProvider = defineLanguage({ id: SupportedLanguages.TypeScript, extensions: ['.ts', '.tsx'], @@ -178,7 +165,7 @@ export const typescriptProvider = defineLanguage({ ...typescriptMethodConfig, extractFunctionName: tsExtractFunctionName, }), - classExtractor: createClassExtractor(tsJsClassConfig), + classExtractor: createClassExtractor(typescriptClassConfig), builtInNames: BUILT_INS, }); @@ -195,9 +182,6 @@ export const javascriptProvider = defineLanguage({ ...javascriptMethodConfig, extractFunctionName: tsExtractFunctionName, }), - classExtractor: createClassExtractor({ - ...tsJsClassConfig, - language: SupportedLanguages.JavaScript, - }), + classExtractor: createClassExtractor(javascriptClassConfig), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/vue.ts b/gitnexus/src/core/ingestion/languages/vue.ts index 20ccdfa18..231d9dcc5 100644 --- a/gitnexus/src/core/ingestion/languages/vue.ts +++ b/gitnexus/src/core/ingestion/languages/vue.ts @@ -13,6 +13,7 @@ import { SupportedLanguages } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; +import { vueClassConfig } from '../class-extractors/configs/typescript-javascript.js'; import { defineLanguage } from '../language-provider.js'; import { typeConfig as typescriptConfig } from '../type-extractors/typescript.js'; import { tsExportChecker } from '../export-detection.js'; @@ -56,21 +57,7 @@ const VUE_SPECIFIC_BUILT_INS = [ const VUE_BUILT_INS: ReadonlySet = new Set([...TS_BUILT_INS, ...VUE_SPECIFIC_BUILT_INS]); -const vueClassExtractor = createClassExtractor({ - language: SupportedLanguages.Vue, - typeDeclarationNodes: [ - 'class_declaration', - 'abstract_class_declaration', - 'interface_declaration', - 'enum_declaration', - ], - ancestorScopeNodeTypes: [ - 'class_declaration', - 'abstract_class_declaration', - 'interface_declaration', - 'enum_declaration', - ], -}); +const vueClassExtractor = createClassExtractor(vueClassConfig); export const vueProvider = defineLanguage({ id: SupportedLanguages.Vue, From 03821faf58db42a49bf77e90d4675e1f0ca3ac42 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 11:45:30 +0100 Subject: [PATCH 59/67] feat(ingestion): language-agnostic call extractor with config+factory pattern (#877) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * feat(ingestion): add call-types, call-extractors factory, per-language configs, and wire into providers Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/893afa77-5b34-4e6b-a1dc-03034261fb36 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * feat(ingestion): replace inline call extraction in parse-worker and call-processor, delete call-sites/ Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/893afa77-5b34-4e6b-a1dc-03034261fb36 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * test(ingestion): add unit tests for call extraction configs and factory Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/893afa77-5b34-4e6b-a1dc-03034261fb36 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * style: fix prettier formatting in call-extractor files Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/d4b06f56-03b6-4fa4-801f-7ddcc6e81f13 * fix: address review comments — doc comment, idempotency note, C# behavioral test Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/e53e650b-fae6-4551-ab25-cda28e4d647f * fix: rename misleading test title, remove stale code reference in comment Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/e53e650b-fae6-4551-ab25-cda28e4d647f --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --- .../call-extractors/configs/c-cpp.ts | 12 + .../call-extractors/configs/csharp.ts | 9 + .../ingestion/call-extractors/configs/dart.ts | 8 + .../ingestion/call-extractors/configs/go.ts | 8 + .../ingestion/call-extractors/configs/jvm.ts | 59 ++ .../ingestion/call-extractors/configs/php.ts | 8 + .../call-extractors/configs/python.ts | 8 + .../ingestion/call-extractors/configs/ruby.ts | 8 + .../ingestion/call-extractors/configs/rust.ts | 8 + .../call-extractors/configs/swift.ts | 8 + .../configs/typescript-javascript.ts | 12 + .../core/ingestion/call-extractors/generic.ts | 86 +++ gitnexus/src/core/ingestion/call-processor.ts | 122 ++-- .../call-sites/extract-language-call-site.ts | 33 -- .../src/core/ingestion/call-sites/java.ts | 41 -- gitnexus/src/core/ingestion/call-types.ts | 80 +++ .../src/core/ingestion/language-provider.ts | 7 + .../src/core/ingestion/languages/c-cpp.ts | 4 + .../src/core/ingestion/languages/csharp.ts | 3 + gitnexus/src/core/ingestion/languages/dart.ts | 3 + gitnexus/src/core/ingestion/languages/go.ts | 3 + gitnexus/src/core/ingestion/languages/java.ts | 3 + .../src/core/ingestion/languages/kotlin.ts | 3 + gitnexus/src/core/ingestion/languages/php.ts | 3 + .../src/core/ingestion/languages/python.ts | 3 + gitnexus/src/core/ingestion/languages/ruby.ts | 3 + gitnexus/src/core/ingestion/languages/rust.ts | 3 + .../src/core/ingestion/languages/swift.ts | 3 + .../core/ingestion/languages/typescript.ts | 7 + gitnexus/src/core/ingestion/languages/vue.ts | 3 + .../core/ingestion/workers/parse-worker.ts | 383 +++++++------ gitnexus/test/unit/call-extraction.test.ts | 527 ++++++++++++++++++ 32 files changed, 1141 insertions(+), 330 deletions(-) create mode 100644 gitnexus/src/core/ingestion/call-extractors/configs/c-cpp.ts create mode 100644 gitnexus/src/core/ingestion/call-extractors/configs/csharp.ts create mode 100644 gitnexus/src/core/ingestion/call-extractors/configs/dart.ts create mode 100644 gitnexus/src/core/ingestion/call-extractors/configs/go.ts create mode 100644 gitnexus/src/core/ingestion/call-extractors/configs/jvm.ts create mode 100644 gitnexus/src/core/ingestion/call-extractors/configs/php.ts create mode 100644 gitnexus/src/core/ingestion/call-extractors/configs/python.ts create mode 100644 gitnexus/src/core/ingestion/call-extractors/configs/ruby.ts create mode 100644 gitnexus/src/core/ingestion/call-extractors/configs/rust.ts create mode 100644 gitnexus/src/core/ingestion/call-extractors/configs/swift.ts create mode 100644 gitnexus/src/core/ingestion/call-extractors/configs/typescript-javascript.ts create mode 100644 gitnexus/src/core/ingestion/call-extractors/generic.ts delete mode 100644 gitnexus/src/core/ingestion/call-sites/extract-language-call-site.ts delete mode 100644 gitnexus/src/core/ingestion/call-sites/java.ts create mode 100644 gitnexus/src/core/ingestion/call-types.ts create mode 100644 gitnexus/test/unit/call-extraction.test.ts diff --git a/gitnexus/src/core/ingestion/call-extractors/configs/c-cpp.ts b/gitnexus/src/core/ingestion/call-extractors/configs/c-cpp.ts new file mode 100644 index 000000000..02a6ed60f --- /dev/null +++ b/gitnexus/src/core/ingestion/call-extractors/configs/c-cpp.ts @@ -0,0 +1,12 @@ +// gitnexus/src/core/ingestion/call-extractors/configs/c-cpp.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { CallExtractionConfig } from '../../call-types.js'; + +export const cCallConfig: CallExtractionConfig = { + language: SupportedLanguages.C, +}; + +export const cppCallConfig: CallExtractionConfig = { + language: SupportedLanguages.CPlusPlus, +}; diff --git a/gitnexus/src/core/ingestion/call-extractors/configs/csharp.ts b/gitnexus/src/core/ingestion/call-extractors/configs/csharp.ts new file mode 100644 index 000000000..e2c0415c2 --- /dev/null +++ b/gitnexus/src/core/ingestion/call-extractors/configs/csharp.ts @@ -0,0 +1,9 @@ +// gitnexus/src/core/ingestion/call-extractors/configs/csharp.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { CallExtractionConfig } from '../../call-types.js'; + +export const csharpCallConfig: CallExtractionConfig = { + language: SupportedLanguages.CSharp, + typeAsReceiverHeuristic: true, +}; diff --git a/gitnexus/src/core/ingestion/call-extractors/configs/dart.ts b/gitnexus/src/core/ingestion/call-extractors/configs/dart.ts new file mode 100644 index 000000000..9d3c08def --- /dev/null +++ b/gitnexus/src/core/ingestion/call-extractors/configs/dart.ts @@ -0,0 +1,8 @@ +// gitnexus/src/core/ingestion/call-extractors/configs/dart.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { CallExtractionConfig } from '../../call-types.js'; + +export const dartCallConfig: CallExtractionConfig = { + language: SupportedLanguages.Dart, +}; diff --git a/gitnexus/src/core/ingestion/call-extractors/configs/go.ts b/gitnexus/src/core/ingestion/call-extractors/configs/go.ts new file mode 100644 index 000000000..870fc88e0 --- /dev/null +++ b/gitnexus/src/core/ingestion/call-extractors/configs/go.ts @@ -0,0 +1,8 @@ +// gitnexus/src/core/ingestion/call-extractors/configs/go.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { CallExtractionConfig } from '../../call-types.js'; + +export const goCallConfig: CallExtractionConfig = { + language: SupportedLanguages.Go, +}; diff --git a/gitnexus/src/core/ingestion/call-extractors/configs/jvm.ts b/gitnexus/src/core/ingestion/call-extractors/configs/jvm.ts new file mode 100644 index 000000000..51de04228 --- /dev/null +++ b/gitnexus/src/core/ingestion/call-extractors/configs/jvm.ts @@ -0,0 +1,59 @@ +// gitnexus/src/core/ingestion/call-extractors/configs/jvm.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { CallExtractionConfig, ExtractedCallSite } from '../../call-types.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +// --------------------------------------------------------------------------- +// Java method_reference (::) parsing — absorbs call-sites/java.ts +// --------------------------------------------------------------------------- + +/** + * Parse Java `method_reference` nodes (`expr::method`, `Type::new`, + * `this::m`, `super::m`). + */ +function parseJavaMethodReference(callNode: SyntaxNode): ExtractedCallSite | null { + if (callNode.type !== 'method_reference') return null; + + const recv = callNode.namedChild(0); + if (!recv) return null; + + // Type::new → constructor call + for (const c of callNode.children) { + if (c.type === 'new') { + if (recv.type !== 'identifier') return null; + return { calledName: recv.text, callForm: 'constructor' }; + } + } + + // expr::method → member call with receiver + const rhs = callNode.child(callNode.childCount - 1); + if (!rhs || rhs.type !== 'identifier') return null; + const methodName = rhs.text; + + if (recv.type === 'identifier') { + return { calledName: methodName, callForm: 'member', receiverName: recv.text }; + } + if (recv.type === 'this') { + return { calledName: methodName, callForm: 'member', receiverName: 'this' }; + } + if (recv.type === 'super') { + return { calledName: methodName, callForm: 'member', receiverName: 'super' }; + } + return null; +} + +// --------------------------------------------------------------------------- +// Configs +// --------------------------------------------------------------------------- + +export const javaCallConfig: CallExtractionConfig = { + language: SupportedLanguages.Java, + extractLanguageCallSite: parseJavaMethodReference, + typeAsReceiverHeuristic: true, +}; + +export const kotlinCallConfig: CallExtractionConfig = { + language: SupportedLanguages.Kotlin, + typeAsReceiverHeuristic: true, +}; diff --git a/gitnexus/src/core/ingestion/call-extractors/configs/php.ts b/gitnexus/src/core/ingestion/call-extractors/configs/php.ts new file mode 100644 index 000000000..25ed0b9ab --- /dev/null +++ b/gitnexus/src/core/ingestion/call-extractors/configs/php.ts @@ -0,0 +1,8 @@ +// gitnexus/src/core/ingestion/call-extractors/configs/php.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { CallExtractionConfig } from '../../call-types.js'; + +export const phpCallConfig: CallExtractionConfig = { + language: SupportedLanguages.PHP, +}; diff --git a/gitnexus/src/core/ingestion/call-extractors/configs/python.ts b/gitnexus/src/core/ingestion/call-extractors/configs/python.ts new file mode 100644 index 000000000..35ab87305 --- /dev/null +++ b/gitnexus/src/core/ingestion/call-extractors/configs/python.ts @@ -0,0 +1,8 @@ +// gitnexus/src/core/ingestion/call-extractors/configs/python.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { CallExtractionConfig } from '../../call-types.js'; + +export const pythonCallConfig: CallExtractionConfig = { + language: SupportedLanguages.Python, +}; diff --git a/gitnexus/src/core/ingestion/call-extractors/configs/ruby.ts b/gitnexus/src/core/ingestion/call-extractors/configs/ruby.ts new file mode 100644 index 000000000..d829c5c89 --- /dev/null +++ b/gitnexus/src/core/ingestion/call-extractors/configs/ruby.ts @@ -0,0 +1,8 @@ +// gitnexus/src/core/ingestion/call-extractors/configs/ruby.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { CallExtractionConfig } from '../../call-types.js'; + +export const rubyCallConfig: CallExtractionConfig = { + language: SupportedLanguages.Ruby, +}; diff --git a/gitnexus/src/core/ingestion/call-extractors/configs/rust.ts b/gitnexus/src/core/ingestion/call-extractors/configs/rust.ts new file mode 100644 index 000000000..03c48f781 --- /dev/null +++ b/gitnexus/src/core/ingestion/call-extractors/configs/rust.ts @@ -0,0 +1,8 @@ +// gitnexus/src/core/ingestion/call-extractors/configs/rust.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { CallExtractionConfig } from '../../call-types.js'; + +export const rustCallConfig: CallExtractionConfig = { + language: SupportedLanguages.Rust, +}; diff --git a/gitnexus/src/core/ingestion/call-extractors/configs/swift.ts b/gitnexus/src/core/ingestion/call-extractors/configs/swift.ts new file mode 100644 index 000000000..28f2c180c --- /dev/null +++ b/gitnexus/src/core/ingestion/call-extractors/configs/swift.ts @@ -0,0 +1,8 @@ +// gitnexus/src/core/ingestion/call-extractors/configs/swift.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { CallExtractionConfig } from '../../call-types.js'; + +export const swiftCallConfig: CallExtractionConfig = { + language: SupportedLanguages.Swift, +}; diff --git a/gitnexus/src/core/ingestion/call-extractors/configs/typescript-javascript.ts b/gitnexus/src/core/ingestion/call-extractors/configs/typescript-javascript.ts new file mode 100644 index 000000000..20a63cda9 --- /dev/null +++ b/gitnexus/src/core/ingestion/call-extractors/configs/typescript-javascript.ts @@ -0,0 +1,12 @@ +// gitnexus/src/core/ingestion/call-extractors/configs/typescript-javascript.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { CallExtractionConfig } from '../../call-types.js'; + +export const typescriptCallConfig: CallExtractionConfig = { + language: SupportedLanguages.TypeScript, +}; + +export const javascriptCallConfig: CallExtractionConfig = { + language: SupportedLanguages.JavaScript, +}; diff --git a/gitnexus/src/core/ingestion/call-extractors/generic.ts b/gitnexus/src/core/ingestion/call-extractors/generic.ts new file mode 100644 index 000000000..82e38457b --- /dev/null +++ b/gitnexus/src/core/ingestion/call-extractors/generic.ts @@ -0,0 +1,86 @@ +// gitnexus/src/core/ingestion/call-extractors/generic.ts + +/** + * Generic table-driven call extractor factory. + * + * Mirrors method-extractors/generic.ts and field-extractors/generic.ts — + * define a config per language and generate extractors from configs. + * + * The factory converts a declarative {@link CallExtractionConfig} into a + * runtime {@link CallExtractor} whose `extract()` method: + * 1. Tries `config.extractLanguageCallSite(callNode)` for non-standard shapes. + * 2. Falls through to the generic path using shared utilities from + * `utils/call-analysis.ts` (`inferCallForm`, `extractReceiverName`, etc.). + */ + +import type { SyntaxNode } from '../utils/ast-helpers.js'; +import { + inferCallForm, + extractReceiverName, + extractReceiverNode, + extractMixedChain, + countCallArguments, +} from '../utils/call-analysis.js'; +import type { CallExtractor, CallExtractionConfig, ExtractedCallSite } from '../call-types.js'; + +/** + * Create a CallExtractor from a declarative config. + */ +export function createCallExtractor(config: CallExtractionConfig): CallExtractor { + return { + language: config.language, + + extract(callNode: SyntaxNode, callNameNode: SyntaxNode | undefined): ExtractedCallSite | null { + // ── Path 1: Language-specific call site ────────────────────────── + // Non-standard call shapes (e.g. Java `::` method references) are + // handled entirely by the config hook. When it returns a result, + // the generic path is skipped — no argCount, no mixed chain. + // + // Note: `extractLanguageCallSite` is called on every `extract()` + // invocation — both `extract(callNode, undefined)` (parse-worker + // Path 1) and `extract(callNode, callNameNode)` (Path 2). + // Language hooks must therefore be idempotent and cheap (e.g. a + // single node-type check). + if (config.extractLanguageCallSite) { + const seed = config.extractLanguageCallSite(callNode); + if (seed) { + return { + ...seed, + ...(config.typeAsReceiverHeuristic ? { typeAsReceiverHeuristic: true } : {}), + }; + } + } + + // ── Path 2: Generic extraction via @call.name ──────────────────── + if (!callNameNode) return null; + + const calledName = callNameNode.text; + const callForm = inferCallForm(callNode, callNameNode); + let receiverName = callForm === 'member' ? extractReceiverName(callNameNode) : undefined; + let receiverMixedChain: ExtractedCallSite['receiverMixedChain']; + + // When the receiver is a complex expression (call chain, field chain, + // or mixed), extractReceiverName returns undefined. Walk the receiver + // node to build a unified mixed chain for deferred resolution. + if (callForm === 'member' && receiverName === undefined) { + const receiverNode = extractReceiverNode(callNameNode); + if (receiverNode) { + const extracted = extractMixedChain(receiverNode); + if (extracted && extracted.chain.length > 0) { + receiverMixedChain = extracted.chain; + receiverName = extracted.baseReceiverName; + } + } + } + + return { + calledName, + ...(callForm !== undefined ? { callForm } : {}), + ...(receiverName !== undefined ? { receiverName } : {}), + argCount: countCallArguments(callNode), + ...(receiverMixedChain !== undefined ? { receiverMixedChain } : {}), + ...(config.typeAsReceiverHeuristic ? { typeAsReceiverHeuristic: true } : {}), + }; + }, + }; +} diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index ce0364a4d..bb7186697 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -48,7 +48,6 @@ import { extractTemplateComponents } from './vue-sfc-extractor.js'; import { extractReturnTypeName, stripNullable } from './type-extractors/shared.js'; import type { LiteralTypeInferrer } from './type-extractors/types.js'; import type { SyntaxNode } from './utils/ast-helpers.js'; -import { extractParsedCallSite } from './call-sites/extract-language-call-site.js'; import { lookupMethodByOwnerWithMRO } from './model/resolve.js'; /** Per-file resolved type bindings for exported symbols. @@ -910,74 +909,79 @@ export const processCalls = async ( if (!captureMap['call']) return; const callNode = captureMap['call']; - const languageSeed = extractParsedCallSite(language, callNode); - if (languageSeed) { - if (provider.isBuiltInName(languageSeed.calledName)) return; + const callExtractor = provider.callExtractor; - const sourceId = - findEnclosingFunction(callNode, file.path, ctx, provider) || - generateId('File', file.path); - const receiverName = - languageSeed.callForm === 'member' ? languageSeed.receiverName : undefined; - let receiverTypeName = - receiverName && typeEnv ? typeEnv.lookup(receiverName, callNode) : undefined; + // ── Language-specific call site (e.g. Java :: method references) ── + if (callExtractor) { + const langCallSite = callExtractor.extract(callNode, undefined); + if (langCallSite) { + if (provider.isBuiltInName(langCallSite.calledName)) return; - if ( - receiverName !== undefined && - receiverTypeName === undefined && - languageSeed.callForm === 'member' && - (language === 'java' || language === 'csharp' || language === 'kotlin') - ) { - const c0 = receiverName.charCodeAt(0); - if (c0 >= 65 && c0 <= 90) receiverTypeName = receiverName; - } + const sourceId = + findEnclosingFunction(callNode, file.path, ctx, provider) || + generateId('File', file.path); + const receiverName = + langCallSite.callForm === 'member' ? langCallSite.receiverName : undefined; + let receiverTypeName = + receiverName && typeEnv ? typeEnv.lookup(receiverName, callNode) : undefined; - const resolved = resolveCallTarget( - { - calledName: languageSeed.calledName, - callForm: languageSeed.callForm, - ...(receiverTypeName !== undefined ? { receiverTypeName } : {}), - ...(receiverName !== undefined ? { receiverName } : {}), - }, - file.path, - ctx, - undefined, - widenCache, - undefined, - heritageMap, - ); + if ( + langCallSite.typeAsReceiverHeuristic && + receiverName !== undefined && + receiverTypeName === undefined && + langCallSite.callForm === 'member' + ) { + const c0 = receiverName.charCodeAt(0); + if (c0 >= 65 && c0 <= 90) receiverTypeName = receiverName; + } - if (!resolved) return; - graph.addRelationship({ - id: generateId('CALLS', `${sourceId}:${languageSeed.calledName}->${resolved.nodeId}`), - sourceId, - targetId: resolved.nodeId, - type: 'CALLS', - confidence: resolved.confidence, - reason: resolved.reason, - }); - - if (heritageMap && languageSeed.callForm === 'member' && receiverTypeName) { - const implTargets = findInterfaceDispatchTargets( - languageSeed.calledName, - receiverTypeName, + const resolved = resolveCallTarget( + { + calledName: langCallSite.calledName, + callForm: langCallSite.callForm, + ...(receiverTypeName !== undefined ? { receiverTypeName } : {}), + ...(receiverName !== undefined ? { receiverName } : {}), + }, file.path, ctx, + undefined, + widenCache, + undefined, heritageMap, - resolved.nodeId, ); - for (const impl of implTargets) { - graph.addRelationship({ - id: generateId('CALLS', `${sourceId}:${languageSeed.calledName}->${impl.nodeId}`), - sourceId, - targetId: impl.nodeId, - type: 'CALLS', - confidence: impl.confidence, - reason: impl.reason, - }); + + if (!resolved) return; + graph.addRelationship({ + id: generateId('CALLS', `${sourceId}:${langCallSite.calledName}->${resolved.nodeId}`), + sourceId, + targetId: resolved.nodeId, + type: 'CALLS', + confidence: resolved.confidence, + reason: resolved.reason, + }); + + if (heritageMap && langCallSite.callForm === 'member' && receiverTypeName) { + const implTargets = findInterfaceDispatchTargets( + langCallSite.calledName, + receiverTypeName, + file.path, + ctx, + heritageMap, + resolved.nodeId, + ); + for (const impl of implTargets) { + graph.addRelationship({ + id: generateId('CALLS', `${sourceId}:${langCallSite.calledName}->${impl.nodeId}`), + sourceId, + targetId: impl.nodeId, + type: 'CALLS', + confidence: impl.confidence, + reason: impl.reason, + }); + } } + return; } - return; } const nameNode = captureMap['call.name']; diff --git a/gitnexus/src/core/ingestion/call-sites/extract-language-call-site.ts b/gitnexus/src/core/ingestion/call-sites/extract-language-call-site.ts deleted file mode 100644 index feed2cd70..000000000 --- a/gitnexus/src/core/ingestion/call-sites/extract-language-call-site.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** Non-generic @call shapes → { calledName, callForm, receiverName? } (used from call-processor / parse-worker). */ - -import { SupportedLanguages } from '../../../config/supported-languages.js'; -import type { SyntaxNode } from '../utils/ast-helpers.js'; -import { parseJavaMethodReference } from './java.js'; - -export type ParsedCallSite = { - calledName: string; - callForm: 'free' | 'member' | 'constructor'; - receiverName?: string; -}; - -/** Non-null → seed replaces @call.name; null → use @call.name + inferCallForm / extractReceiverName. */ -export function extractParsedCallSite( - language: SupportedLanguages, - callNode: SyntaxNode, -): ParsedCallSite | null { - switch (language) { - case SupportedLanguages.Java: - if (callNode.type === 'method_reference') { - const parsed = parseJavaMethodReference(callNode); - if (!parsed) return null; - return { - calledName: parsed.calledName, - callForm: parsed.callForm, - ...(parsed.receiverName !== undefined ? { receiverName: parsed.receiverName } : {}), - }; - } - return null; - default: - return null; - } -} diff --git a/gitnexus/src/core/ingestion/call-sites/java.ts b/gitnexus/src/core/ingestion/call-sites/java.ts deleted file mode 100644 index e22c71cca..000000000 --- a/gitnexus/src/core/ingestion/call-sites/java.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** Java `method_reference` (`::`) nodes (tree-sitter-java). `super::` still lacks TypeEnv receiver typing. */ - -import type { SyntaxNode } from '../utils/ast-helpers.js'; - -export type ParsedJavaMethodReference = { - calledName: string; - callForm: 'member' | 'constructor'; - receiverName?: string; -}; - -/** Parse `expr::method`, `Type::new`, `this::m`, `super::m`. */ -export const parseJavaMethodReference = ( - callNode: SyntaxNode, -): ParsedJavaMethodReference | null => { - if (callNode.type !== 'method_reference') return null; - - const recv = callNode.namedChild(0); - if (!recv) return null; - - for (const c of callNode.children) { - if (c.type === 'new') { - if (recv.type !== 'identifier') return null; - return { calledName: recv.text, callForm: 'constructor' }; - } - } - - const rhs = callNode.child(callNode.childCount - 1); - if (!rhs || rhs.type !== 'identifier') return null; - const methodName = rhs.text; - - if (recv.type === 'identifier') { - return { calledName: methodName, callForm: 'member', receiverName: recv.text }; - } - if (recv.type === 'this') { - return { calledName: methodName, callForm: 'member', receiverName: 'this' }; - } - if (recv.type === 'super') { - return { calledName: methodName, callForm: 'member', receiverName: 'super' }; - } - return null; -}; diff --git a/gitnexus/src/core/ingestion/call-types.ts b/gitnexus/src/core/ingestion/call-types.ts new file mode 100644 index 000000000..da175720d --- /dev/null +++ b/gitnexus/src/core/ingestion/call-types.ts @@ -0,0 +1,80 @@ +// gitnexus/src/core/ingestion/call-types.ts + +/** + * Types for the language-agnostic call extraction pipeline. + * + * Mirrors method-types.ts / field-types.ts: defines the domain interfaces + * consumed by createCallExtractor() and the per-language configs. + */ + +import type { SupportedLanguages } from 'gitnexus-shared'; +import type { SyntaxNode } from './utils/ast-helpers.js'; +import type { MixedChainStep } from './utils/call-analysis.js'; + +// --------------------------------------------------------------------------- +// Extracted result +// --------------------------------------------------------------------------- + +/** + * Per-node call extraction result. The parse worker enriches this with + * file-level context (filePath, sourceId, TypeEnv lookups, arg types) to + * produce the final `ExtractedCall` that enters the resolution pipeline. + */ +export interface ExtractedCallSite { + calledName: string; + callForm?: 'free' | 'member' | 'constructor'; + receiverName?: string; + argCount?: number; + /** Unified mixed chain for complex receivers (field + call chains). */ + receiverMixedChain?: MixedChainStep[]; + /** When true, the type-as-receiver heuristic applies: if receiverName + * starts with an uppercase letter and has no TypeEnv binding, treat it + * as a type name (e.g. Java `User::getName`). */ + typeAsReceiverHeuristic?: boolean; +} + +// --------------------------------------------------------------------------- +// Extractor interface (produced by createCallExtractor) +// --------------------------------------------------------------------------- + +export interface CallExtractor { + readonly language: SupportedLanguages; + /** + * Extract a call site from captured AST nodes. + * + * @param callNode The @call capture (call_expression, method_invocation, …) + * @param callNameNode The @call.name capture (identifier inside the call). + * May be undefined when the call shape has no name capture + * (e.g. Java method_reference via `::`). + * @returns Extracted call site, or null when no call can be derived. + */ + extract(callNode: SyntaxNode, callNameNode: SyntaxNode | undefined): ExtractedCallSite | null; +} + +// --------------------------------------------------------------------------- +// Config interface (one per language / language group) +// --------------------------------------------------------------------------- + +export interface CallExtractionConfig { + language: SupportedLanguages; + + /** + * Language-specific call site extraction. Called **before** the generic + * path. If it returns non-null, the generic `inferCallForm` / + * `extractReceiverName` path is skipped entirely. + * + * Use this for call shapes that don't follow the standard `@call` / + * `@call.name` pattern (e.g. Java `method_reference` via `::`). + */ + extractLanguageCallSite?: (callNode: SyntaxNode) => ExtractedCallSite | null; + + /** + * Whether the type-as-receiver heuristic applies for this language. + * When true and the receiver name starts with an uppercase letter, + * the receiver is treated as a type name when no TypeEnv binding exists. + * + * Applies to JVM and C# languages where `Type.method()` and `Type::method` + * are common patterns. + */ + typeAsReceiverHeuristic?: boolean; +} diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 736c3b666..ef29c477c 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -12,6 +12,7 @@ import type { SupportedLanguages, MroStrategy } from 'gitnexus-shared'; import type { LanguageTypeConfig } from './type-extractors/types.js'; import type { CallRouter } from './call-routing.js'; +import type { CallExtractor } from './call-types.js'; import type { ClassExtractor } from './class-types.js'; import type { ExportChecker } from './export-detection.js'; import type { FieldExtractor } from './field-extractor.js'; @@ -155,6 +156,12 @@ interface LanguageProviderConfig { readonly mroStrategy?: MroStrategy; // ── Language-specific extraction hooks ──────────────────────────── + /** Call extractor for extracting call site information (calledName, callForm, + * receiverName, argCount, mixed chains) from @call / @call.name captures. + * Produced by createCallExtractor() with a per-language CallExtractionConfig. + * Default: undefined — if unset, no calls are extracted for this language. + * All tree-sitter providers MUST supply this. */ + readonly callExtractor?: CallExtractor; /** Field extractor for extracting field/property definitions from class/struct * declarations. Produces FieldInfo[] with name, type, visibility, static, * readonly metadata. Default: undefined (no field extraction). */ diff --git a/gitnexus/src/core/ingestion/languages/c-cpp.ts b/gitnexus/src/core/ingestion/languages/c-cpp.ts index 7a6703a7c..02ae4064e 100644 --- a/gitnexus/src/core/ingestion/languages/c-cpp.ts +++ b/gitnexus/src/core/ingestion/languages/c-cpp.ts @@ -38,6 +38,8 @@ import { } from '../field-extractors/configs/c-cpp.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { cMethodConfig, cppMethodConfig } from '../method-extractors/configs/c-cpp.js'; +import { createCallExtractor } from '../call-extractors/generic.js'; +import { cCallConfig, cppCallConfig } from '../call-extractors/configs/c-cpp.js'; const C_BUILT_INS: ReadonlySet = new Set([ 'printf', @@ -316,6 +318,7 @@ export const cProvider = defineLanguage({ exportChecker: cCppExportChecker, importResolver: resolveCImport, importSemantics: 'wildcard-transitive', + callExtractor: createCallExtractor(cCallConfig), fieldExtractor: createFieldExtractor(cFieldConfig), methodExtractor: createMethodExtractor({ ...cMethodConfig, @@ -335,6 +338,7 @@ export const cppProvider = defineLanguage({ importResolver: resolveCppImport, importSemantics: 'wildcard-transitive', mroStrategy: 'leftmost-base', + callExtractor: createCallExtractor(cppCallConfig), fieldExtractor: createFieldExtractor(cppFieldConfig), methodExtractor: createMethodExtractor({ ...cppMethodConfig, diff --git a/gitnexus/src/core/ingestion/languages/csharp.ts b/gitnexus/src/core/ingestion/languages/csharp.ts index bf02412d0..08fccbc15 100644 --- a/gitnexus/src/core/ingestion/languages/csharp.ts +++ b/gitnexus/src/core/ingestion/languages/csharp.ts @@ -15,6 +15,8 @@ import { csharpExportChecker } from '../export-detection.js'; import { resolveCSharpImport } from '../import-resolvers/csharp.js'; import { extractCSharpNamedBindings } from '../named-bindings/csharp.js'; import { CSHARP_QUERIES } from '../tree-sitter-queries.js'; +import { createCallExtractor } from '../call-extractors/generic.js'; +import { csharpCallConfig } from '../call-extractors/configs/csharp.js'; import { createFieldExtractor } from '../field-extractors/generic.js'; import { csharpConfig as csharpFieldConfig } from '../field-extractors/configs/csharp.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; @@ -125,6 +127,7 @@ export const csharpProvider = defineLanguage({ namedBindingExtractor: extractCSharpNamedBindings, interfaceNamePattern: /^I[A-Z]/, mroStrategy: 'implements-split', + callExtractor: createCallExtractor(csharpCallConfig), fieldExtractor: createFieldExtractor(csharpFieldConfig), methodExtractor: createMethodExtractor(csharpMethodConfig), classExtractor: createClassExtractor(csharpClassConfig), diff --git a/gitnexus/src/core/ingestion/languages/dart.ts b/gitnexus/src/core/ingestion/languages/dart.ts index 779592cdd..107f918a5 100644 --- a/gitnexus/src/core/ingestion/languages/dart.ts +++ b/gitnexus/src/core/ingestion/languages/dart.ts @@ -25,6 +25,8 @@ import { createFieldExtractor } from '../field-extractors/generic.js'; import { dartConfig as dartFieldConfig } from '../field-extractors/configs/dart.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { dartMethodConfig } from '../method-extractors/configs/dart.js'; +import { createCallExtractor } from '../call-extractors/generic.js'; +import { dartCallConfig } from '../call-extractors/configs/dart.js'; /** * Resolve the enclosing function from a `function_body` node by looking at its @@ -92,6 +94,7 @@ export const dartProvider = defineLanguage({ exportChecker: dartExportChecker, importResolver: resolveDartImport, importSemantics: 'wildcard-leaf', + callExtractor: createCallExtractor(dartCallConfig), fieldExtractor: createFieldExtractor(dartFieldConfig), methodExtractor: createMethodExtractor(dartMethodConfig), classExtractor: createClassExtractor(dartClassConfig), diff --git a/gitnexus/src/core/ingestion/languages/go.ts b/gitnexus/src/core/ingestion/languages/go.ts index 5e75b3088..390d528df 100644 --- a/gitnexus/src/core/ingestion/languages/go.ts +++ b/gitnexus/src/core/ingestion/languages/go.ts @@ -21,6 +21,8 @@ import { createFieldExtractor } from '../field-extractors/generic.js'; import { goConfig as goFieldConfig } from '../field-extractors/configs/go.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { goMethodConfig } from '../method-extractors/configs/go.js'; +import { createCallExtractor } from '../call-extractors/generic.js'; +import { goCallConfig } from '../call-extractors/configs/go.js'; export const goProvider = defineLanguage({ id: SupportedLanguages.Go, @@ -30,6 +32,7 @@ export const goProvider = defineLanguage({ exportChecker: goExportChecker, importResolver: resolveGoImport, importSemantics: 'wildcard-leaf', + callExtractor: createCallExtractor(goCallConfig), fieldExtractor: createFieldExtractor(goFieldConfig), methodExtractor: createMethodExtractor(goMethodConfig), classExtractor: createClassExtractor(goClassConfig), diff --git a/gitnexus/src/core/ingestion/languages/java.ts b/gitnexus/src/core/ingestion/languages/java.ts index 297b5eea1..b79322f8a 100644 --- a/gitnexus/src/core/ingestion/languages/java.ts +++ b/gitnexus/src/core/ingestion/languages/java.ts @@ -16,6 +16,8 @@ import { javaExportChecker } from '../export-detection.js'; import { resolveJavaImport } from '../import-resolvers/jvm.js'; import { extractJavaNamedBindings } from '../named-bindings/java.js'; import { JAVA_QUERIES } from '../tree-sitter-queries.js'; +import { createCallExtractor } from '../call-extractors/generic.js'; +import { javaCallConfig } from '../call-extractors/configs/jvm.js'; import { createFieldExtractor } from '../field-extractors/generic.js'; import { javaConfig } from '../field-extractors/configs/jvm.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; @@ -31,6 +33,7 @@ export const javaProvider = defineLanguage({ namedBindingExtractor: extractJavaNamedBindings, interfaceNamePattern: /^I[A-Z]/, mroStrategy: 'implements-split', + callExtractor: createCallExtractor(javaCallConfig), fieldExtractor: createFieldExtractor(javaConfig), methodExtractor: createMethodExtractor(javaMethodConfig), classExtractor: createClassExtractor(javaClassConfig), diff --git a/gitnexus/src/core/ingestion/languages/kotlin.ts b/gitnexus/src/core/ingestion/languages/kotlin.ts index 1017e6aae..97dcddc38 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin.ts @@ -18,6 +18,8 @@ import { extractKotlinNamedBindings } from '../named-bindings/kotlin.js'; import { appendKotlinWildcard } from '../import-resolvers/jvm.js'; import { KOTLIN_QUERIES } from '../tree-sitter-queries.js'; import type { SyntaxNode } from '../utils/ast-helpers.js'; +import { createCallExtractor } from '../call-extractors/generic.js'; +import { kotlinCallConfig } from '../call-extractors/configs/jvm.js'; import { createFieldExtractor } from '../field-extractors/generic.js'; import { kotlinConfig } from '../field-extractors/configs/jvm.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; @@ -106,6 +108,7 @@ export const kotlinProvider = defineLanguage({ namedBindingExtractor: extractKotlinNamedBindings, importPathPreprocessor: appendKotlinWildcard, mroStrategy: 'implements-split', + callExtractor: createCallExtractor(kotlinCallConfig), fieldExtractor: createFieldExtractor(kotlinConfig), methodExtractor: createMethodExtractor(kotlinMethodConfig), classExtractor: createClassExtractor(kotlinClassConfig), diff --git a/gitnexus/src/core/ingestion/languages/php.ts b/gitnexus/src/core/ingestion/languages/php.ts index 2f8be3f98..26c3a83af 100644 --- a/gitnexus/src/core/ingestion/languages/php.ts +++ b/gitnexus/src/core/ingestion/languages/php.ts @@ -21,6 +21,8 @@ import { createFieldExtractor } from '../field-extractors/generic.js'; import { phpConfig as phpFieldConfig } from '../field-extractors/configs/php.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { phpMethodConfig } from '../method-extractors/configs/php.js'; +import { createCallExtractor } from '../call-extractors/generic.js'; +import { phpCallConfig } from '../call-extractors/configs/php.js'; const BUILT_INS: ReadonlySet = new Set([ 'echo', @@ -238,6 +240,7 @@ export const phpProvider = defineLanguage({ exportChecker: phpExportChecker, importResolver: resolvePhpImport, namedBindingExtractor: extractPhpNamedBindings, + callExtractor: createCallExtractor(phpCallConfig), fieldExtractor: createFieldExtractor(phpFieldConfig), methodExtractor: createMethodExtractor(phpMethodConfig), classExtractor: createClassExtractor(phpClassConfig), diff --git a/gitnexus/src/core/ingestion/languages/python.ts b/gitnexus/src/core/ingestion/languages/python.ts index 70ca7e19c..c2bf6119b 100644 --- a/gitnexus/src/core/ingestion/languages/python.ts +++ b/gitnexus/src/core/ingestion/languages/python.ts @@ -23,6 +23,8 @@ import { createFieldExtractor } from '../field-extractors/generic.js'; import { pythonConfig as pythonFieldConfig } from '../field-extractors/configs/python.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { pythonMethodConfig } from '../method-extractors/configs/python.js'; +import { createCallExtractor } from '../call-extractors/generic.js'; +import { pythonCallConfig } from '../call-extractors/configs/python.js'; const BUILT_INS: ReadonlySet = new Set([ 'print', @@ -64,6 +66,7 @@ export const pythonProvider = defineLanguage({ namedBindingExtractor: extractPythonNamedBindings, importSemantics: 'namespace', mroStrategy: 'c3', + callExtractor: createCallExtractor(pythonCallConfig), fieldExtractor: createFieldExtractor(pythonFieldConfig), methodExtractor: createMethodExtractor(pythonMethodConfig), classExtractor: createClassExtractor(pythonClassConfig), diff --git a/gitnexus/src/core/ingestion/languages/ruby.ts b/gitnexus/src/core/ingestion/languages/ruby.ts index 7cb0e08da..139484b86 100644 --- a/gitnexus/src/core/ingestion/languages/ruby.ts +++ b/gitnexus/src/core/ingestion/languages/ruby.ts @@ -22,6 +22,8 @@ import { createFieldExtractor } from '../field-extractors/generic.js'; import { rubyConfig as rubyFieldConfig } from '../field-extractors/configs/ruby.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { rubyMethodConfig } from '../method-extractors/configs/ruby.js'; +import { createCallExtractor } from '../call-extractors/generic.js'; +import { rubyCallConfig } from '../call-extractors/configs/ruby.js'; /** Ruby method/singleton_method: extract name from 'name' field, label as Method. */ const rubyExtractFunctionName = ( @@ -109,6 +111,7 @@ export const rubyProvider = defineLanguage({ importResolver: resolveRubyImport, callRouter: routeRubyCall, importSemantics: 'wildcard-leaf', + callExtractor: createCallExtractor(rubyCallConfig), resolveEnclosingOwner(node) { // Ruby singleton_class (class << self) should resolve to the enclosing // class or module for owner/container resolution (HAS_METHOD edges, class IDs). diff --git a/gitnexus/src/core/ingestion/languages/rust.ts b/gitnexus/src/core/ingestion/languages/rust.ts index 961233ff7..999e99eca 100644 --- a/gitnexus/src/core/ingestion/languages/rust.ts +++ b/gitnexus/src/core/ingestion/languages/rust.ts @@ -25,6 +25,8 @@ import { createFieldExtractor } from '../field-extractors/generic.js'; import { rustConfig as rustFieldConfig } from '../field-extractors/configs/rust.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { rustMethodConfig } from '../method-extractors/configs/rust.js'; +import { createCallExtractor } from '../call-extractors/generic.js'; +import { rustCallConfig } from '../call-extractors/configs/rust.js'; /** Rust impl_item: find the function_item child and extract its name as a Method. */ const rustExtractFunctionName = ( @@ -121,6 +123,7 @@ export const rustProvider = defineLanguage({ importResolver: resolveRustImport, namedBindingExtractor: extractRustNamedBindings, mroStrategy: 'qualified-syntax', + callExtractor: createCallExtractor(rustCallConfig), fieldExtractor: createFieldExtractor(rustFieldConfig), methodExtractor: createMethodExtractor({ ...rustMethodConfig, diff --git a/gitnexus/src/core/ingestion/languages/swift.ts b/gitnexus/src/core/ingestion/languages/swift.ts index cc9706bbf..2c34e9047 100644 --- a/gitnexus/src/core/ingestion/languages/swift.ts +++ b/gitnexus/src/core/ingestion/languages/swift.ts @@ -25,6 +25,8 @@ import { createFieldExtractor } from '../field-extractors/generic.js'; import { swiftConfig as swiftFieldConfig } from '../field-extractors/configs/swift.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { swiftMethodConfig } from '../method-extractors/configs/swift.js'; +import { createCallExtractor } from '../call-extractors/generic.js'; +import { swiftCallConfig } from '../call-extractors/configs/swift.js'; /** * Group Swift files by SPM target for implicit module visibility. @@ -241,6 +243,7 @@ export const swiftProvider = defineLanguage({ importResolver: resolveSwiftImport, importSemantics: 'wildcard-leaf', heritageDefaultEdge: 'IMPLEMENTS', + callExtractor: createCallExtractor(swiftCallConfig), fieldExtractor: createFieldExtractor(swiftFieldConfig), methodExtractor: createMethodExtractor({ ...swiftMethodConfig, diff --git a/gitnexus/src/core/ingestion/languages/typescript.ts b/gitnexus/src/core/ingestion/languages/typescript.ts index 5c8e315ff..318c2b37b 100644 --- a/gitnexus/src/core/ingestion/languages/typescript.ts +++ b/gitnexus/src/core/ingestion/languages/typescript.ts @@ -29,6 +29,11 @@ import { typescriptMethodConfig, javascriptMethodConfig, } from '../method-extractors/configs/typescript-javascript.js'; +import { createCallExtractor } from '../call-extractors/generic.js'; +import { + typescriptCallConfig, + javascriptCallConfig, +} from '../call-extractors/configs/typescript-javascript.js'; /** * TypeScript/JavaScript: arrow_function and function_expression get their name @@ -160,6 +165,7 @@ export const typescriptProvider = defineLanguage({ exportChecker: tsExportChecker, importResolver: resolveTypescriptImport, namedBindingExtractor: extractTsNamedBindings, + callExtractor: createCallExtractor(typescriptCallConfig), fieldExtractor: typescriptFieldExtractor, methodExtractor: createMethodExtractor({ ...typescriptMethodConfig, @@ -177,6 +183,7 @@ export const javascriptProvider = defineLanguage({ exportChecker: tsExportChecker, importResolver: resolveJavascriptImport, namedBindingExtractor: extractTsNamedBindings, + callExtractor: createCallExtractor(javascriptCallConfig), fieldExtractor: createFieldExtractor(javascriptConfig), methodExtractor: createMethodExtractor({ ...javascriptMethodConfig, diff --git a/gitnexus/src/core/ingestion/languages/vue.ts b/gitnexus/src/core/ingestion/languages/vue.ts index 231d9dcc5..77608870c 100644 --- a/gitnexus/src/core/ingestion/languages/vue.ts +++ b/gitnexus/src/core/ingestion/languages/vue.ts @@ -22,6 +22,8 @@ import { extractTsNamedBindings } from '../named-bindings/typescript.js'; import { TYPESCRIPT_QUERIES } from '../tree-sitter-queries.js'; import { typescriptFieldExtractor } from '../field-extractors/typescript.js'; import { BUILT_INS as TS_BUILT_INS } from './typescript.js'; +import { createCallExtractor } from '../call-extractors/generic.js'; +import { typescriptCallConfig } from '../call-extractors/configs/typescript-javascript.js'; const VUE_SPECIFIC_BUILT_INS = [ 'ref', @@ -67,6 +69,7 @@ export const vueProvider = defineLanguage({ exportChecker: tsExportChecker, importResolver: resolveVueImport, namedBindingExtractor: extractTsNamedBindings, + callExtractor: createCallExtractor(typescriptCallConfig), fieldExtractor: typescriptFieldExtractor, classExtractor: vueClassExtractor, builtInNames: VUE_BUILT_INS, diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index bcafa38d2..dc5f6a62d 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -53,16 +53,7 @@ import { CLASS_CONTAINER_TYPES, type SyntaxNode, } from '../utils/ast-helpers.js'; -import { - countCallArguments, - inferCallForm, - extractReceiverName, - extractReceiverNode, - extractMixedChain, - extractCallArgTypes, - type MixedChainStep, -} from '../utils/call-analysis.js'; -import { extractParsedCallSite } from '../call-sites/extract-language-call-site.js'; +import { extractCallArgTypes, type MixedChainStep } from '../utils/call-analysis.js'; import { buildTypeEnv } from '../type-env.js'; import type { ConstructorBinding } from '../type-env.js'; import { detectFrameworkFromAST } from '../framework-detection.js'; @@ -1656,109 +1647,137 @@ const processFileGroup = ( // Extract call sites if (captureMap['call']) { - const callNode0 = captureMap['call']; - const languageSeed = extractParsedCallSite(language, callNode0); - if (languageSeed) { - if (!provider.isBuiltInName(languageSeed.calledName)) { - const sourceId = - findEnclosingFunctionId(callNode0, file.path, provider) || - generateId('File', file.path); - const receiverName = - languageSeed.callForm === 'member' ? languageSeed.receiverName : undefined; - let receiverTypeName = receiverName - ? typeEnv.lookup(receiverName, callNode0) - : undefined; - // Type-as-receiver (e.g. Java `User::getName`): no TypeEnv binding for the class name - if ( - receiverName !== undefined && - receiverTypeName === undefined && - languageSeed.callForm === 'member' && - (language === SupportedLanguages.Java || - language === SupportedLanguages.CSharp || - language === SupportedLanguages.Kotlin) - ) { - const c0 = receiverName.charCodeAt(0); - if (c0 >= 65 && c0 <= 90) receiverTypeName = receiverName; - } - result.calls.push({ - filePath: file.path, - calledName: languageSeed.calledName, - sourceId, - callForm: languageSeed.callForm, - ...(receiverName !== undefined ? { receiverName } : {}), - ...(receiverTypeName !== undefined ? { receiverTypeName } : {}), - }); - } - continue; - } - + const callNode = captureMap['call']; const callNameNode = captureMap['call.name']; - if (callNameNode) { - const calledName = callNameNode.text; + const callExtractor = provider.callExtractor; - // Dispatch: route language-specific calls (heritage, properties, imports) - const routed = callRouter?.(calledName, captureMap['call']); - if (routed) { - if (routed.kind === 'skip') continue; - - if (routed.kind === 'import') { - result.imports.push({ - filePath: file.path, - rawImportPath: routed.importPath, - language, - }); - continue; - } - - if (routed.kind === 'heritage') { - for (const item of routed.items) { - result.heritage.push({ - filePath: file.path, - className: item.enclosingClass, - parentName: item.mixinName, - kind: item.heritageKind, - }); + if (callExtractor) { + // ── Path 1: Language-specific call site (bypasses routing) ──── + // Try language-specific extraction (e.g. Java `::` method references) + // without callNameNode. If successful, skip routing and the generic + // path entirely. + const langCallSite = callExtractor.extract(callNode, undefined); + if (langCallSite) { + if (!provider.isBuiltInName(langCallSite.calledName)) { + const sourceId = + findEnclosingFunctionId(callNode, file.path, provider) || + generateId('File', file.path); + const receiverName = + langCallSite.callForm === 'member' ? langCallSite.receiverName : undefined; + let receiverTypeName = receiverName + ? typeEnv.lookup(receiverName, callNode) + : undefined; + // Type-as-receiver heuristic (e.g. Java `User::getName`) + if ( + langCallSite.typeAsReceiverHeuristic && + receiverName !== undefined && + receiverTypeName === undefined && + langCallSite.callForm === 'member' + ) { + const c0 = receiverName.charCodeAt(0); + if (c0 >= 65 && c0 <= 90) receiverTypeName = receiverName; } - continue; + result.calls.push({ + filePath: file.path, + calledName: langCallSite.calledName, + sourceId, + callForm: langCallSite.callForm, + ...(receiverName !== undefined ? { receiverName } : {}), + ...(receiverTypeName !== undefined ? { receiverTypeName } : {}), + }); } + continue; + } - if (routed.kind === 'properties') { - const propEnclosingInfo = cachedFindEnclosingClassInfo( - captureMap['call'], - file.path, - provider.resolveEnclosingOwner, - ); - const propEnclosingClassId = propEnclosingInfo?.classId ?? null; - // Enrich routed properties with FieldExtractor metadata - let routedFieldMap: Map | undefined; - if (provider.fieldExtractor && typeEnv) { - const classNode = findEnclosingClassNode(captureMap['call']); - if (classNode) { - routedFieldMap = getFieldInfo(classNode, provider, { - typeEnv, - symbolTable: NOOP_SYMBOL_TABLE, + // ── Path 2: Generic extraction via @call.name ──────────────── + if (callNameNode) { + const calledName = callNameNode.text; + + // Dispatch: route language-specific calls (heritage, properties, imports) + const routed = callRouter?.(calledName, captureMap['call']); + if (routed) { + if (routed.kind === 'skip') continue; + + if (routed.kind === 'import') { + result.imports.push({ + filePath: file.path, + rawImportPath: routed.importPath, + language, + }); + continue; + } + + if (routed.kind === 'heritage') { + for (const item of routed.items) { + result.heritage.push({ filePath: file.path, - language, + className: item.enclosingClass, + parentName: item.mixinName, + kind: item.heritageKind, }); } + continue; } - for (const item of routed.items) { - const routedFieldInfo = routedFieldMap?.get(item.propName); - const propQualifiedName = propEnclosingInfo - ? `${propEnclosingInfo.className}.${item.propName}` - : item.propName; - const nodeId = generateId('Property', `${file.path}:${propQualifiedName}`); - result.nodes.push({ - id: nodeId, - label: 'Property', - properties: { - name: item.propName, + + if (routed.kind === 'properties') { + const propEnclosingInfo = cachedFindEnclosingClassInfo( + captureMap['call'], + file.path, + provider.resolveEnclosingOwner, + ); + const propEnclosingClassId = propEnclosingInfo?.classId ?? null; + // Enrich routed properties with FieldExtractor metadata + let routedFieldMap: Map | undefined; + if (provider.fieldExtractor && typeEnv) { + const classNode = findEnclosingClassNode(captureMap['call']); + if (classNode) { + routedFieldMap = getFieldInfo(classNode, provider, { + typeEnv, + symbolTable: NOOP_SYMBOL_TABLE, + filePath: file.path, + language, + }); + } + } + for (const item of routed.items) { + const routedFieldInfo = routedFieldMap?.get(item.propName); + const propQualifiedName = propEnclosingInfo + ? `${propEnclosingInfo.className}.${item.propName}` + : item.propName; + const nodeId = generateId('Property', `${file.path}:${propQualifiedName}`); + result.nodes.push({ + id: nodeId, + label: 'Property', + properties: { + name: item.propName, + filePath: file.path, + startLine: item.startLine, + endLine: item.endLine, + language, + isExported: true, + description: item.accessorType, + ...(item.declaredType + ? { declaredType: item.declaredType } + : routedFieldInfo?.type + ? { declaredType: routedFieldInfo.type } + : {}), + ...(routedFieldInfo?.visibility !== undefined + ? { visibility: routedFieldInfo.visibility } + : {}), + ...(routedFieldInfo?.isStatic !== undefined + ? { isStatic: routedFieldInfo.isStatic } + : {}), + ...(routedFieldInfo?.isReadonly !== undefined + ? { isReadonly: routedFieldInfo.isReadonly } + : {}), + }, + }); + result.symbols.push({ filePath: file.path, - startLine: item.startLine, - endLine: item.endLine, - language, - isExported: true, - description: item.accessorType, + name: item.propName, + nodeId, + type: 'Property', + ...(propEnclosingClassId ? { ownerId: propEnclosingClassId } : {}), ...(item.declaredType ? { declaredType: item.declaredType } : routedFieldInfo?.type @@ -1773,111 +1792,81 @@ const processFileGroup = ( ...(routedFieldInfo?.isReadonly !== undefined ? { isReadonly: routedFieldInfo.isReadonly } : {}), - }, - }); - result.symbols.push({ - filePath: file.path, - name: item.propName, - nodeId, - type: 'Property', - ...(propEnclosingClassId ? { ownerId: propEnclosingClassId } : {}), - ...(item.declaredType - ? { declaredType: item.declaredType } - : routedFieldInfo?.type - ? { declaredType: routedFieldInfo.type } - : {}), - ...(routedFieldInfo?.visibility !== undefined - ? { visibility: routedFieldInfo.visibility } - : {}), - ...(routedFieldInfo?.isStatic !== undefined - ? { isStatic: routedFieldInfo.isStatic } - : {}), - ...(routedFieldInfo?.isReadonly !== undefined - ? { isReadonly: routedFieldInfo.isReadonly } - : {}), - }); - const fileId = generateId('File', file.path); - const relId = generateId('DEFINES', `${fileId}->${nodeId}`); - result.relationships.push({ - id: relId, - sourceId: fileId, - targetId: nodeId, - type: 'DEFINES', - confidence: 1.0, - reason: '', - }); - if (propEnclosingClassId) { + }); + const fileId = generateId('File', file.path); + const relId = generateId('DEFINES', `${fileId}->${nodeId}`); result.relationships.push({ - id: generateId('HAS_PROPERTY', `${propEnclosingClassId}->${nodeId}`), - sourceId: propEnclosingClassId, + id: relId, + sourceId: fileId, targetId: nodeId, - type: 'HAS_PROPERTY', + type: 'DEFINES', confidence: 1.0, reason: '', }); - } - } - continue; - } - - // kind === 'call' — fall through to normal call processing below - } - - if (!provider.isBuiltInName(calledName)) { - const callNode = captureMap['call']; - const sourceId = - findEnclosingFunctionId(callNode, file.path, provider) || - generateId('File', file.path); - const callForm = inferCallForm(callNode, callNameNode); - let receiverName = - callForm === 'member' ? extractReceiverName(callNameNode) : undefined; - let receiverTypeName = receiverName - ? typeEnv.lookup(receiverName, callNode) - : undefined; - let receiverMixedChain: MixedChainStep[] | undefined; - - // When the receiver is a complex expression (call chain, field chain, or mixed), - // extractReceiverName returns undefined. Walk the receiver node to build a unified - // mixed chain for deferred resolution in processCallsFromExtracted. - if (callForm === 'member' && receiverName === undefined && !receiverTypeName) { - const receiverNode = extractReceiverNode(callNameNode); - if (receiverNode) { - const extracted = extractMixedChain(receiverNode); - if (extracted && extracted.chain.length > 0) { - receiverMixedChain = extracted.chain; - receiverName = extracted.baseReceiverName; - // Try the type environment immediately for the base receiver - // (covers explicitly-typed locals and annotated parameters). - if (receiverName) { - receiverTypeName = typeEnv.lookup(receiverName, callNode); + if (propEnclosingClassId) { + result.relationships.push({ + id: generateId('HAS_PROPERTY', `${propEnclosingClassId}->${nodeId}`), + sourceId: propEnclosingClassId, + targetId: nodeId, + type: 'HAS_PROPERTY', + confidence: 1.0, + reason: '', + }); } } + continue; } + + // kind === 'call' — fall through to normal call processing below } - const inferLiteralType = provider.typeConfig?.inferLiteralType; - const argCountForOverloadHints = countCallArguments(callNode); - // Skip when no arg list / zero args: nothing to infer for overload typing; saves AST walks + payload size. - const argTypes = - inferLiteralType && - argCountForOverloadHints !== undefined && - argCountForOverloadHints > 0 - ? extractCallArgTypes(callNode, inferLiteralType, (varName, cn) => - typeEnv.lookup(varName, cn), - ) - : undefined; + if (!provider.isBuiltInName(calledName)) { + const callSite = callExtractor.extract(callNode, callNameNode); + if (callSite) { + const sourceId = + findEnclosingFunctionId(callNode, file.path, provider) || + generateId('File', file.path); + let receiverTypeName = callSite.receiverName + ? typeEnv.lookup(callSite.receiverName, callNode) + : undefined; - result.calls.push({ - filePath: file.path, - calledName, - sourceId, - argCount: countCallArguments(callNode), - ...(callForm !== undefined ? { callForm } : {}), - ...(receiverName !== undefined ? { receiverName } : {}), - ...(receiverTypeName !== undefined ? { receiverTypeName } : {}), - ...(receiverMixedChain !== undefined ? { receiverMixedChain } : {}), - ...(argTypes !== undefined ? { argTypes } : {}), - }); + // Type-as-receiver heuristic + if ( + callSite.typeAsReceiverHeuristic && + callSite.receiverName !== undefined && + receiverTypeName === undefined && + callSite.callForm === 'member' + ) { + const c0 = callSite.receiverName.charCodeAt(0); + if (c0 >= 65 && c0 <= 90) receiverTypeName = callSite.receiverName; + } + + const inferLiteralType = provider.typeConfig?.inferLiteralType; + // Skip when no arg list / zero args: nothing to infer for overload typing + const argTypes = + inferLiteralType && callSite.argCount !== undefined && callSite.argCount > 0 + ? extractCallArgTypes(callNode, inferLiteralType, (varName, cn) => + typeEnv.lookup(varName, cn), + ) + : undefined; + + result.calls.push({ + filePath: file.path, + calledName: callSite.calledName, + sourceId, + ...(callSite.argCount !== undefined ? { argCount: callSite.argCount } : {}), + ...(callSite.callForm !== undefined ? { callForm: callSite.callForm } : {}), + ...(callSite.receiverName !== undefined + ? { receiverName: callSite.receiverName } + : {}), + ...(receiverTypeName !== undefined ? { receiverTypeName } : {}), + ...(callSite.receiverMixedChain !== undefined + ? { receiverMixedChain: callSite.receiverMixedChain } + : {}), + ...(argTypes !== undefined ? { argTypes } : {}), + }); + } + } } } continue; diff --git a/gitnexus/test/unit/call-extraction.test.ts b/gitnexus/test/unit/call-extraction.test.ts new file mode 100644 index 000000000..85921f1f8 --- /dev/null +++ b/gitnexus/test/unit/call-extraction.test.ts @@ -0,0 +1,527 @@ +import { describe, it, expect } from 'vitest'; +import { createCallExtractor } from '../../src/core/ingestion/call-extractors/generic.js'; +import { + javaCallConfig, + kotlinCallConfig, +} from '../../src/core/ingestion/call-extractors/configs/jvm.js'; +import { csharpCallConfig } from '../../src/core/ingestion/call-extractors/configs/csharp.js'; +import { + typescriptCallConfig, + javascriptCallConfig, +} from '../../src/core/ingestion/call-extractors/configs/typescript-javascript.js'; +import { + cCallConfig, + cppCallConfig, +} from '../../src/core/ingestion/call-extractors/configs/c-cpp.js'; +import { pythonCallConfig } from '../../src/core/ingestion/call-extractors/configs/python.js'; +import { rubyCallConfig } from '../../src/core/ingestion/call-extractors/configs/ruby.js'; +import { rustCallConfig } from '../../src/core/ingestion/call-extractors/configs/rust.js'; +import { dartCallConfig } from '../../src/core/ingestion/call-extractors/configs/dart.js'; +import { phpCallConfig } from '../../src/core/ingestion/call-extractors/configs/php.js'; +import { swiftCallConfig } from '../../src/core/ingestion/call-extractors/configs/swift.js'; +import { goCallConfig } from '../../src/core/ingestion/call-extractors/configs/go.js'; +import type { CallExtractionConfig } from '../../src/core/ingestion/call-types.js'; +import type { SyntaxNode } from '../../src/core/ingestion/utils/ast-helpers.js'; +import { SupportedLanguages } from '../../src/config/supported-languages.js'; +import { getProvider } from '../../src/core/ingestion/languages/index.js'; +import Parser from 'tree-sitter'; +import TypeScript from 'tree-sitter-typescript'; +import Python from 'tree-sitter-python'; +import Java from 'tree-sitter-java'; +import CSharp from 'tree-sitter-c-sharp'; +import Go from 'tree-sitter-go'; +import Rust from 'tree-sitter-rust'; +import CPP from 'tree-sitter-cpp'; +import PHP from 'tree-sitter-php'; +import Ruby from 'tree-sitter-ruby'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Parse code with a tree-sitter language and run the language's query to find + * @call / @call.name captures. + */ +function extractCallCaptures( + parser: Parser, + code: string, + language: SupportedLanguages, +): Array<{ + callNode: SyntaxNode; + nameNode: SyntaxNode | undefined; + calledName: string | undefined; +}> { + const provider = getProvider(language); + const queryStr = provider.treeSitterQueries; + if (!queryStr) throw new Error(`No query for ${language}`); + + const tree = parser.parse(code); + const lang = parser.getLanguage(); + const query = new Parser.Query(lang, queryStr); + const matches = query.matches(tree.rootNode); + + const results: Array<{ + callNode: SyntaxNode; + nameNode: SyntaxNode | undefined; + calledName: string | undefined; + }> = []; + + for (const match of matches) { + const captureMap: Record = {}; + for (const c of match.captures) { + captureMap[c.name] = c.node; + } + if (captureMap['call']) { + results.push({ + callNode: captureMap['call'], + nameNode: captureMap['call.name'], + calledName: captureMap['call.name']?.text, + }); + } + } + + return results; +} + +// --------------------------------------------------------------------------- +// Factory construction tests +// --------------------------------------------------------------------------- + +describe('createCallExtractor', () => { + it('constructs all currently registered language configs', () => { + const configs: CallExtractionConfig[] = [ + javaCallConfig, + kotlinCallConfig, + csharpCallConfig, + typescriptCallConfig, + javascriptCallConfig, + cCallConfig, + cppCallConfig, + pythonCallConfig, + rubyCallConfig, + rustCallConfig, + dartCallConfig, + phpCallConfig, + swiftCallConfig, + goCallConfig, + ]; + for (const cfg of configs) { + expect( + () => createCallExtractor(cfg), + `config for ${cfg.language} must construct cleanly`, + ).not.toThrow(); + } + }); + + it('preserves language on the extractor', () => { + const extractor = createCallExtractor(javaCallConfig); + expect(extractor.language).toBe(SupportedLanguages.Java); + }); + + it('returns null when no callNameNode and no language seed', () => { + const extractor = createCallExtractor(typescriptCallConfig); + // A minimal stub SyntaxNode — extract should return null since + // there's no callNameNode and no language-specific hook + const stub = { type: 'call_expression' } as unknown as SyntaxNode; + expect(extractor.extract(stub, undefined)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// LanguageProvider.callExtractor wiring +// --------------------------------------------------------------------------- + +describe('callExtractor on LanguageProvider', () => { + it('all tree-sitter providers have callExtractor defined', () => { + const languages: SupportedLanguages[] = [ + SupportedLanguages.TypeScript, + SupportedLanguages.JavaScript, + SupportedLanguages.Python, + SupportedLanguages.Java, + SupportedLanguages.Kotlin, + SupportedLanguages.Go, + SupportedLanguages.Rust, + SupportedLanguages.CSharp, + SupportedLanguages.C, + SupportedLanguages.CPlusPlus, + SupportedLanguages.PHP, + SupportedLanguages.Ruby, + SupportedLanguages.Swift, + SupportedLanguages.Dart, + SupportedLanguages.Vue, + ]; + for (const lang of languages) { + const provider = getProvider(lang); + expect(provider.callExtractor, `${lang} should have a callExtractor`).toBeDefined(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Generic extraction via @call.name +// --------------------------------------------------------------------------- + +describe('generic call extraction', () => { + const parser = new Parser(); + + describe('TypeScript', () => { + const extractor = createCallExtractor(typescriptCallConfig); + + it('extracts free function call', () => { + parser.setLanguage(TypeScript.typescript); + const captures = extractCallCaptures(parser, 'doStuff()', SupportedLanguages.TypeScript); + const match = captures.find((c) => c.calledName === 'doStuff'); + expect(match).toBeDefined(); + const result = extractor.extract(match!.callNode, match!.nameNode!); + expect(result).not.toBeNull(); + expect(result!.calledName).toBe('doStuff'); + expect(result!.callForm).toBe('free'); + expect(result!.receiverName).toBeUndefined(); + }); + + it('extracts member call with receiver', () => { + parser.setLanguage(TypeScript.typescript); + const captures = extractCallCaptures(parser, 'user.save()', SupportedLanguages.TypeScript); + const match = captures.find((c) => c.calledName === 'save'); + expect(match).toBeDefined(); + const result = extractor.extract(match!.callNode, match!.nameNode!); + expect(result).not.toBeNull(); + expect(result!.calledName).toBe('save'); + expect(result!.callForm).toBe('member'); + expect(result!.receiverName).toBe('user'); + }); + + it('extracts constructor call', () => { + parser.setLanguage(TypeScript.typescript); + const captures = extractCallCaptures(parser, 'new User()', SupportedLanguages.TypeScript); + const match = captures.find((c) => c.calledName === 'User'); + expect(match).toBeDefined(); + const result = extractor.extract(match!.callNode, match!.nameNode!); + expect(result).not.toBeNull(); + expect(result!.calledName).toBe('User'); + expect(result!.callForm).toBe('constructor'); + }); + + it('extracts argCount', () => { + parser.setLanguage(TypeScript.typescript); + const captures = extractCallCaptures(parser, 'foo(a, b, c)', SupportedLanguages.TypeScript); + const match = captures.find((c) => c.calledName === 'foo'); + expect(match).toBeDefined(); + const result = extractor.extract(match!.callNode, match!.nameNode!); + expect(result).not.toBeNull(); + expect(result!.argCount).toBe(3); + }); + + it('does not set typeAsReceiverHeuristic', () => { + parser.setLanguage(TypeScript.typescript); + const captures = extractCallCaptures(parser, 'User.find()', SupportedLanguages.TypeScript); + const match = captures.find((c) => c.calledName === 'find'); + expect(match).toBeDefined(); + const result = extractor.extract(match!.callNode, match!.nameNode!); + expect(result!.typeAsReceiverHeuristic).toBeFalsy(); + }); + }); + + describe('Python', () => { + const extractor = createCallExtractor(pythonCallConfig); + + it('extracts free function call', () => { + parser.setLanguage(Python); + const captures = extractCallCaptures(parser, 'do_stuff()', SupportedLanguages.Python); + const match = captures.find((c) => c.calledName === 'do_stuff'); + expect(match).toBeDefined(); + const result = extractor.extract(match!.callNode, match!.nameNode!); + expect(result).not.toBeNull(); + expect(result!.calledName).toBe('do_stuff'); + expect(result!.callForm).toBe('free'); + }); + + it('extracts member call', () => { + parser.setLanguage(Python); + const captures = extractCallCaptures(parser, 'user.save()', SupportedLanguages.Python); + const match = captures.find((c) => c.calledName === 'save'); + expect(match).toBeDefined(); + const result = extractor.extract(match!.callNode, match!.nameNode!); + expect(result).not.toBeNull(); + expect(result!.callForm).toBe('member'); + expect(result!.receiverName).toBe('user'); + }); + }); + + describe('Java', () => { + const extractor = createCallExtractor(javaCallConfig); + + it('extracts free function call', () => { + parser.setLanguage(Java); + const captures = extractCallCaptures( + parser, + 'class A { void m() { doStuff(); } }', + SupportedLanguages.Java, + ); + const match = captures.find((c) => c.calledName === 'doStuff'); + expect(match).toBeDefined(); + const result = extractor.extract(match!.callNode, match!.nameNode!); + expect(result).not.toBeNull(); + expect(result!.calledName).toBe('doStuff'); + expect(result!.callForm).toBe('free'); + }); + + it('extracts member call with receiver', () => { + parser.setLanguage(Java); + const captures = extractCallCaptures( + parser, + 'class A { void m() { user.save(); } }', + SupportedLanguages.Java, + ); + const match = captures.find((c) => c.calledName === 'save'); + expect(match).toBeDefined(); + const result = extractor.extract(match!.callNode, match!.nameNode!); + expect(result).not.toBeNull(); + expect(result!.callForm).toBe('member'); + expect(result!.receiverName).toBe('user'); + }); + + it('sets typeAsReceiverHeuristic', () => { + parser.setLanguage(Java); + const captures = extractCallCaptures( + parser, + 'class A { void m() { User.find(); } }', + SupportedLanguages.Java, + ); + const match = captures.find((c) => c.calledName === 'find'); + expect(match).toBeDefined(); + const result = extractor.extract(match!.callNode, match!.nameNode!); + expect(result).not.toBeNull(); + expect(result!.typeAsReceiverHeuristic).toBe(true); + }); + }); + + describe('C#', () => { + const extractor = createCallExtractor(csharpCallConfig); + + it('extracts member call with receiver and typeAsReceiverHeuristic', () => { + parser.setLanguage(CSharp); + const captures = extractCallCaptures( + parser, + 'class A { void M() { Console.WriteLine(); } }', + SupportedLanguages.CSharp, + ); + const match = captures.find((c) => c.calledName === 'WriteLine'); + expect(match).toBeDefined(); + const result = extractor.extract(match!.callNode, match!.nameNode!); + expect(result).not.toBeNull(); + expect(result!.calledName).toBe('WriteLine'); + expect(result!.callForm).toBe('member'); + expect(result!.receiverName).toBe('Console'); + expect(result!.typeAsReceiverHeuristic).toBe(true); + }); + + it('sets typeAsReceiverHeuristic flag even for lowercase receivers', () => { + parser.setLanguage(CSharp); + const captures = extractCallCaptures( + parser, + 'class A { void M() { logger.Info(); } }', + SupportedLanguages.CSharp, + ); + const match = captures.find((c) => c.calledName === 'Info'); + expect(match).toBeDefined(); + const result = extractor.extract(match!.callNode, match!.nameNode!); + expect(result).not.toBeNull(); + // typeAsReceiverHeuristic is set on the config/extractor level (true for C#), + // but the uppercase check happens in parse-worker, not the extractor itself + expect(result!.typeAsReceiverHeuristic).toBe(true); + expect(result!.receiverName).toBe('logger'); + }); + }); + + describe('Go', () => { + const extractor = createCallExtractor(goCallConfig); + + it('extracts free function call', () => { + parser.setLanguage(Go); + const captures = extractCallCaptures( + parser, + 'package main\nfunc main() { doStuff() }', + SupportedLanguages.Go, + ); + const match = captures.find((c) => c.calledName === 'doStuff'); + expect(match).toBeDefined(); + const result = extractor.extract(match!.callNode, match!.nameNode!); + expect(result).not.toBeNull(); + expect(result!.calledName).toBe('doStuff'); + expect(result!.callForm).toBe('free'); + }); + }); + + describe('Rust', () => { + const extractor = createCallExtractor(rustCallConfig); + + it('extracts free function call', () => { + parser.setLanguage(Rust); + const captures = extractCallCaptures( + parser, + 'fn main() { do_stuff(); }', + SupportedLanguages.Rust, + ); + const match = captures.find((c) => c.calledName === 'do_stuff'); + expect(match).toBeDefined(); + const result = extractor.extract(match!.callNode, match!.nameNode!); + expect(result).not.toBeNull(); + expect(result!.calledName).toBe('do_stuff'); + }); + }); + + describe('C++', () => { + const extractor = createCallExtractor(cppCallConfig); + + it('extracts free function call', () => { + parser.setLanguage(CPP); + const captures = extractCallCaptures( + parser, + 'void f() { doStuff(); }', + SupportedLanguages.CPlusPlus, + ); + const match = captures.find((c) => c.calledName === 'doStuff'); + expect(match).toBeDefined(); + const result = extractor.extract(match!.callNode, match!.nameNode!); + expect(result).not.toBeNull(); + expect(result!.calledName).toBe('doStuff'); + expect(result!.callForm).toBe('free'); + }); + }); + + describe('PHP', () => { + const extractor = createCallExtractor(phpCallConfig); + + it('extracts free function call', () => { + parser.setLanguage(PHP.php); + const captures = extractCallCaptures(parser, '', SupportedLanguages.PHP); + const match = captures.find((c) => c.calledName === 'doStuff'); + expect(match).toBeDefined(); + const result = extractor.extract(match!.callNode, match!.nameNode!); + expect(result).not.toBeNull(); + expect(result!.calledName).toBe('doStuff'); + }); + }); + + describe('Ruby', () => { + const extractor = createCallExtractor(rubyCallConfig); + + it('extracts member call', () => { + parser.setLanguage(Ruby); + const captures = extractCallCaptures(parser, 'user.save()', SupportedLanguages.Ruby); + const match = captures.find((c) => c.calledName === 'save'); + expect(match).toBeDefined(); + const result = extractor.extract(match!.callNode, match!.nameNode!); + expect(result).not.toBeNull(); + expect(result!.callForm).toBe('member'); + expect(result!.receiverName).toBe('user'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Language-specific call site extraction (Java :: method references) +// --------------------------------------------------------------------------- + +describe('Java method_reference extraction', () => { + const parser = new Parser(); + parser.setLanguage(Java); + const extractor = createCallExtractor(javaCallConfig); + + it('extracts Type::new as constructor', () => { + const captures = extractCallCaptures( + parser, + 'class A { void m() { stream.map(User::new); } }', + SupportedLanguages.Java, + ); + // The method_reference should be captured as @call + const match = captures.find((c) => c.callNode.type === 'method_reference'); + if (match) { + const result = extractor.extract(match.callNode, undefined); + expect(result).not.toBeNull(); + expect(result!.calledName).toBe('User'); + expect(result!.callForm).toBe('constructor'); + } + }); + + it('extracts Type::method as member call', () => { + const captures = extractCallCaptures( + parser, + 'class A { void m() { stream.map(User::getName); } }', + SupportedLanguages.Java, + ); + const match = captures.find((c) => c.callNode.type === 'method_reference'); + if (match) { + const result = extractor.extract(match.callNode, undefined); + expect(result).not.toBeNull(); + expect(result!.calledName).toBe('getName'); + expect(result!.callForm).toBe('member'); + expect(result!.receiverName).toBe('User'); + expect(result!.typeAsReceiverHeuristic).toBe(true); + } + }); + + it('extracts this::method as member call', () => { + const captures = extractCallCaptures( + parser, + 'class A { void m() { stream.map(this::process); } }', + SupportedLanguages.Java, + ); + const match = captures.find((c) => c.callNode.type === 'method_reference'); + if (match) { + const result = extractor.extract(match.callNode, undefined); + expect(result).not.toBeNull(); + expect(result!.calledName).toBe('process'); + expect(result!.callForm).toBe('member'); + expect(result!.receiverName).toBe('this'); + } + }); + + it('extractLanguageCallSite returns null for non-method_reference nodes', () => { + const captures = extractCallCaptures( + parser, + 'class A { void m() { doStuff(); } }', + SupportedLanguages.Java, + ); + const match = captures.find((c) => c.calledName === 'doStuff'); + expect(match).toBeDefined(); + // Language seed should be null for regular calls + const langSeed = extractor.extract(match!.callNode, undefined); + expect(langSeed).toBeNull(); + // But full extraction with callNameNode should work + const full = extractor.extract(match!.callNode, match!.nameNode!); + expect(full).not.toBeNull(); + expect(full!.calledName).toBe('doStuff'); + }); +}); + +// --------------------------------------------------------------------------- +// typeAsReceiverHeuristic config flag +// --------------------------------------------------------------------------- + +describe('typeAsReceiverHeuristic config', () => { + it('JVM configs set typeAsReceiverHeuristic', () => { + expect(javaCallConfig.typeAsReceiverHeuristic).toBe(true); + expect(kotlinCallConfig.typeAsReceiverHeuristic).toBe(true); + }); + + it('C# config sets typeAsReceiverHeuristic', () => { + expect(csharpCallConfig.typeAsReceiverHeuristic).toBe(true); + }); + + it('other configs do not set typeAsReceiverHeuristic', () => { + expect(typescriptCallConfig.typeAsReceiverHeuristic).toBeFalsy(); + expect(javascriptCallConfig.typeAsReceiverHeuristic).toBeFalsy(); + expect(pythonCallConfig.typeAsReceiverHeuristic).toBeFalsy(); + expect(rubyCallConfig.typeAsReceiverHeuristic).toBeFalsy(); + expect(goCallConfig.typeAsReceiverHeuristic).toBeFalsy(); + expect(rustCallConfig.typeAsReceiverHeuristic).toBeFalsy(); + expect(cCallConfig.typeAsReceiverHeuristic).toBeFalsy(); + expect(cppCallConfig.typeAsReceiverHeuristic).toBeFalsy(); + expect(phpCallConfig.typeAsReceiverHeuristic).toBeFalsy(); + expect(dartCallConfig.typeAsReceiverHeuristic).toBeFalsy(); + expect(swiftCallConfig.typeAsReceiverHeuristic).toBeFalsy(); + }); +}); From ed5a4220dd63bf9e2c0dc1a87faab9833c5176a4 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 13:57:25 +0100 Subject: [PATCH 60/67] feat(ingestion): language-agnostic variable extractor with config+factory pattern (#878) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * feat(ingestion): add variable extraction types, factory, configs, and wire into language providers - Create variable-types.ts with VariableInfo, VariableExtractionConfig, VariableExtractor interfaces - Create variable-extractors/generic.ts with createVariableExtractor() factory - Add variableExtractor field to LanguageProvider interface - Create per-language variable extraction configs for all 16 languages - Wire variableExtractor into all language providers - Add variable metadata enrichment to parse-worker for Const/Static/Variable labels Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/3cb85c68-1792-473e-9a46-ea2588da0e5e Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * feat(ingestion): add variable extraction tests and fix Python/TS config issues - Create test/unit/variable-extraction.test.ts with 29 tests covering TypeScript, JavaScript, Python, Go, Rust, C, C++, Ruby, and factory behavior - Fix isConst in generic factory to use config.isConst over node-type membership (TS let/const both use lexical_declaration) - Fix Python type extraction for annotated assignments at module scope - Fix Python dunder name visibility (e.g., __name__ is public, not protected) Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/3cb85c68-1792-473e-9a46-ea2588da0e5e Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: address code review feedback — move imports, clarify scope comment, use shared test context Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/3cb85c68-1792-473e-9a46-ea2588da0e5e Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: address review comments, fix prettier formatting and lint errors - Fix prettier formatting in 5 files (c-cpp, jvm, swift configs, test file) - Remove unused SyntaxNode imports in php.ts and ruby.ts (lint errors) - Remove unused constNodeSet/variableNodeSet variables in generic.ts (warnings) - Remove semantically wrong `methodProps.isReadonly = varInfo.isConst` (review) - Remove dead `nodeLabel === 'Variable'` guard in parse-worker (review) - Fix test guard: replace `if (declNode)` with `expect(declNode).toBeDefined()` (review) - Add comment about Python expression_statement broadness (review) Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/040edbbf-65b5-40e1-80c8-e98f7c4bb54a * feat(ingestion): add block-scoped variable extraction via tree-sitter queries Add @definition.const and @definition.variable tree-sitter query patterns for TypeScript, JavaScript, Python, Go, Java, C, C++, C#, PHP, Ruby, and Dart. Add parse-worker dedup logic to avoid duplicate nodes when variable captures overlap with existing function/property captures. Add 'Variable' label support in getLabelFromCaptures and DEFINITION_CAPTURE_KEYS. Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9fa828c1-87b7-4482-8f26-d2079fb4c58a Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * test: add block-scoped variable extraction tests and query capture tests Add 6 tests for block-scoped variable extraction (TypeScript, Go, Rust, C, Python). Add 14 tests verifying @definition.const/@definition.variable query patterns exist in all language query strings. Import RUBY_QUERIES in test file. Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9fa828c1-87b7-4482-8f26-d2079fb4c58a Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * test: add Python non-assignment expression statement rejection test Addresses code review feedback: verify that the Python variable extractor returns null for expression_statement nodes that contain function calls rather than assignments (e.g. `print("hello")`). Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9fa828c1-87b7-4482-8f26-d2079fb4c58a Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: Dart query node type, add Variable schema, update schema counts - Change `top_level_variable_declaration` → `declaration` in DART_QUERIES (the former doesn't exist in tree-sitter-dart grammar, causing all Dart integration tests to fail with TSQueryErrorNodeType) - Add VARIABLE_SCHEMA to schema.ts and register in initLbug() so that Variable-labeled nodes are persisted to LadybugDB (not silently dropped) - Add 'Variable' to MULTI_LANG_TYPES in csv-generator.ts - Update Dart variable config to remove invalid node type - Update schema test counts (30→31 node schemas, 32→33 total) Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/f79931d1-207f-4fbb-91da-259d44f7fd88 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: address code review comment improvements - Clarify processedDefinitionNodes tracks start indices, not nodes - Improve Python variableNodeTypes comment wording Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/f79931d1-207f-4fbb-91da-259d44f7fd88 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: add Variable to NODE_TABLES, RELATION_SCHEMA, update golden snapshot - Add 'Variable' to NODE_TABLES in gitnexus-shared so validTables.has('Variable') returns true and Variable graph edges are not silently dropped - Add FROM File TO Variable, FROM Variable TO Community, FROM Variable TO Process to RELATION_SCHEMA so KuzuDB can represent edges connecting Variable nodes - Update schema.test.ts: add Variable to multiLang list, fix count 30→31 - Regenerate pipeline-graph-golden snapshot for mini-repo fixture Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/e3aad558-e7bb-40d1-b53f-0a2c0132ca96 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: isolate golden test from cli-e2e fixture pollution The pipeline-graph-golden test was non-deterministic because cli-e2e.test.ts creates AGENTS.md, CLAUDE.md, .claude/skills/, and .gitignore in the shared mini-repo fixture during analyze. These leftover files caused the golden test to find 9 files instead of 7 when tests ran in parallel. Fixes: - Golden test now copies the fixture to a temp dir before running, making it immune to concurrent test pollution - cli-e2e afterAll cleanup now removes ALL generated files (AGENTS.md, CLAUDE.md, .claude/, .gitignore) not just .git/ and .gitnexus/ - Golden snapshot regenerated from clean 7-file fixture Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/bd378e73-6f37-49c6-aed6-7fabf4dc6183 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --- gitnexus-shared/src/lbug/schema-constants.ts | 1 + .../src/core/ingestion/language-provider.ts | 5 + .../src/core/ingestion/languages/c-cpp.ts | 4 + .../src/core/ingestion/languages/csharp.ts | 3 + gitnexus/src/core/ingestion/languages/dart.ts | 3 + gitnexus/src/core/ingestion/languages/go.ts | 3 + gitnexus/src/core/ingestion/languages/java.ts | 3 + .../src/core/ingestion/languages/kotlin.ts | 3 + gitnexus/src/core/ingestion/languages/php.ts | 3 + .../src/core/ingestion/languages/python.ts | 3 + gitnexus/src/core/ingestion/languages/ruby.ts | 3 + gitnexus/src/core/ingestion/languages/rust.ts | 3 + .../src/core/ingestion/languages/swift.ts | 3 + .../core/ingestion/languages/typescript.ts | 7 + gitnexus/src/core/ingestion/languages/vue.ts | 3 + .../src/core/ingestion/tree-sitter-queries.ts | 81 +++ .../src/core/ingestion/utils/ast-helpers.ts | 2 + .../variable-extractors/configs/c-cpp.ts | 93 +++ .../variable-extractors/configs/csharp.ts | 64 ++ .../variable-extractors/configs/dart.ts | 101 +++ .../variable-extractors/configs/go.ts | 91 +++ .../variable-extractors/configs/jvm.ts | 124 ++++ .../variable-extractors/configs/php.ts | 67 ++ .../variable-extractors/configs/python.ts | 109 +++ .../variable-extractors/configs/ruby.ts | 57 ++ .../variable-extractors/configs/rust.ts | 82 +++ .../variable-extractors/configs/swift.ts | 99 +++ .../configs/typescript-javascript.ts | 94 +++ .../ingestion/variable-extractors/generic.ts | 108 +++ gitnexus/src/core/ingestion/variable-types.ts | 91 +++ .../core/ingestion/workers/parse-worker.ts | 42 ++ gitnexus/src/core/lbug/csv-generator.ts | 1 + gitnexus/src/core/lbug/schema.ts | 5 + .../mini-repo/expected-graph.json | 18 +- gitnexus/test/integration/cli-e2e.test.ts | 8 +- .../integration/pipeline-graph-golden.test.ts | 18 +- gitnexus/test/unit/schema.test.ts | 11 +- .../test/unit/tree-sitter-queries.test.ts | 74 ++ .../test/unit/variable-extraction.test.ts | 633 ++++++++++++++++++ 39 files changed, 2103 insertions(+), 20 deletions(-) create mode 100644 gitnexus/src/core/ingestion/variable-extractors/configs/c-cpp.ts create mode 100644 gitnexus/src/core/ingestion/variable-extractors/configs/csharp.ts create mode 100644 gitnexus/src/core/ingestion/variable-extractors/configs/dart.ts create mode 100644 gitnexus/src/core/ingestion/variable-extractors/configs/go.ts create mode 100644 gitnexus/src/core/ingestion/variable-extractors/configs/jvm.ts create mode 100644 gitnexus/src/core/ingestion/variable-extractors/configs/php.ts create mode 100644 gitnexus/src/core/ingestion/variable-extractors/configs/python.ts create mode 100644 gitnexus/src/core/ingestion/variable-extractors/configs/ruby.ts create mode 100644 gitnexus/src/core/ingestion/variable-extractors/configs/rust.ts create mode 100644 gitnexus/src/core/ingestion/variable-extractors/configs/swift.ts create mode 100644 gitnexus/src/core/ingestion/variable-extractors/configs/typescript-javascript.ts create mode 100644 gitnexus/src/core/ingestion/variable-extractors/generic.ts create mode 100644 gitnexus/src/core/ingestion/variable-types.ts create mode 100644 gitnexus/test/unit/variable-extraction.test.ts diff --git a/gitnexus-shared/src/lbug/schema-constants.ts b/gitnexus-shared/src/lbug/schema-constants.ts index 0eca57286..656ffe552 100644 --- a/gitnexus-shared/src/lbug/schema-constants.ts +++ b/gitnexus-shared/src/lbug/schema-constants.ts @@ -30,6 +30,7 @@ export const NODE_TABLES = [ 'TypeAlias', 'Const', 'Static', + 'Variable', 'Property', 'Record', 'Delegate', diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index ef29c477c..298c7999f 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -17,6 +17,7 @@ import type { ClassExtractor } from './class-types.js'; import type { ExportChecker } from './export-detection.js'; import type { FieldExtractor } from './field-extractor.js'; import type { MethodExtractor } from './method-types.js'; +import type { VariableExtractor } from './variable-types.js'; import type { ImportResolverFn } from './import-resolvers/types.js'; import type { NamedBindingExtractorFn } from './named-bindings/types.js'; import type { SyntaxNode } from './utils/ast-helpers.js'; @@ -170,6 +171,10 @@ interface LanguageProviderConfig { * declarations. Produces MethodInfo[] with name, parameters, visibility, isAbstract, * isFinal, annotations metadata. Default: undefined (no method extraction). */ readonly methodExtractor?: MethodExtractor; + /** Variable extractor for extracting metadata from module/file-scoped variable, + * constant, and static declarations. Produces VariableInfo with type, visibility, + * isConst, isStatic, isMutable metadata. Default: undefined (no variable extraction). */ + readonly variableExtractor?: VariableExtractor; /** Class/type extractor for deriving canonical qualified names for class-like symbols. * Uses the same provider-driven strategy pattern as method/field extraction so * namespace/package/module rules stay language-specific. */ diff --git a/gitnexus/src/core/ingestion/languages/c-cpp.ts b/gitnexus/src/core/ingestion/languages/c-cpp.ts index 02ae4064e..b823cefcc 100644 --- a/gitnexus/src/core/ingestion/languages/c-cpp.ts +++ b/gitnexus/src/core/ingestion/languages/c-cpp.ts @@ -38,6 +38,8 @@ import { } from '../field-extractors/configs/c-cpp.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { cMethodConfig, cppMethodConfig } from '../method-extractors/configs/c-cpp.js'; +import { createVariableExtractor } from '../variable-extractors/generic.js'; +import { cVariableConfig, cppVariableConfig } from '../variable-extractors/configs/c-cpp.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { cCallConfig, cppCallConfig } from '../call-extractors/configs/c-cpp.js'; @@ -324,6 +326,7 @@ export const cProvider = defineLanguage({ ...cMethodConfig, extractFunctionName: cCppExtractFunctionName, }), + variableExtractor: createVariableExtractor(cVariableConfig), classExtractor: cClassExtractor, labelOverride: cppLabelOverride, builtInNames: C_BUILT_INS, @@ -344,6 +347,7 @@ export const cppProvider = defineLanguage({ ...cppMethodConfig, extractFunctionName: cCppExtractFunctionName, }), + variableExtractor: createVariableExtractor(cppVariableConfig), classExtractor: cppClassExtractor, labelOverride: cppLabelOverride, builtInNames: C_BUILT_INS, diff --git a/gitnexus/src/core/ingestion/languages/csharp.ts b/gitnexus/src/core/ingestion/languages/csharp.ts index 08fccbc15..d7330fc87 100644 --- a/gitnexus/src/core/ingestion/languages/csharp.ts +++ b/gitnexus/src/core/ingestion/languages/csharp.ts @@ -21,6 +21,8 @@ import { createFieldExtractor } from '../field-extractors/generic.js'; import { csharpConfig as csharpFieldConfig } from '../field-extractors/configs/csharp.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { csharpMethodConfig } from '../method-extractors/configs/csharp.js'; +import { createVariableExtractor } from '../variable-extractors/generic.js'; +import { csharpVariableConfig } from '../variable-extractors/configs/csharp.js'; const BUILT_INS: ReadonlySet = new Set([ 'Console', @@ -130,6 +132,7 @@ export const csharpProvider = defineLanguage({ callExtractor: createCallExtractor(csharpCallConfig), fieldExtractor: createFieldExtractor(csharpFieldConfig), methodExtractor: createMethodExtractor(csharpMethodConfig), + variableExtractor: createVariableExtractor(csharpVariableConfig), classExtractor: createClassExtractor(csharpClassConfig), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/dart.ts b/gitnexus/src/core/ingestion/languages/dart.ts index 107f918a5..1deb488b8 100644 --- a/gitnexus/src/core/ingestion/languages/dart.ts +++ b/gitnexus/src/core/ingestion/languages/dart.ts @@ -25,6 +25,8 @@ import { createFieldExtractor } from '../field-extractors/generic.js'; import { dartConfig as dartFieldConfig } from '../field-extractors/configs/dart.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { dartMethodConfig } from '../method-extractors/configs/dart.js'; +import { createVariableExtractor } from '../variable-extractors/generic.js'; +import { dartVariableConfig } from '../variable-extractors/configs/dart.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { dartCallConfig } from '../call-extractors/configs/dart.js'; @@ -97,6 +99,7 @@ export const dartProvider = defineLanguage({ callExtractor: createCallExtractor(dartCallConfig), fieldExtractor: createFieldExtractor(dartFieldConfig), methodExtractor: createMethodExtractor(dartMethodConfig), + variableExtractor: createVariableExtractor(dartVariableConfig), classExtractor: createClassExtractor(dartClassConfig), enclosingFunctionFinder: dartEnclosingFunctionFinder, builtInNames: BUILT_INS, diff --git a/gitnexus/src/core/ingestion/languages/go.ts b/gitnexus/src/core/ingestion/languages/go.ts index 390d528df..ae92ff995 100644 --- a/gitnexus/src/core/ingestion/languages/go.ts +++ b/gitnexus/src/core/ingestion/languages/go.ts @@ -21,6 +21,8 @@ import { createFieldExtractor } from '../field-extractors/generic.js'; import { goConfig as goFieldConfig } from '../field-extractors/configs/go.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { goMethodConfig } from '../method-extractors/configs/go.js'; +import { createVariableExtractor } from '../variable-extractors/generic.js'; +import { goVariableConfig } from '../variable-extractors/configs/go.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { goCallConfig } from '../call-extractors/configs/go.js'; @@ -35,5 +37,6 @@ export const goProvider = defineLanguage({ callExtractor: createCallExtractor(goCallConfig), fieldExtractor: createFieldExtractor(goFieldConfig), methodExtractor: createMethodExtractor(goMethodConfig), + variableExtractor: createVariableExtractor(goVariableConfig), classExtractor: createClassExtractor(goClassConfig), }); diff --git a/gitnexus/src/core/ingestion/languages/java.ts b/gitnexus/src/core/ingestion/languages/java.ts index b79322f8a..b8446b76a 100644 --- a/gitnexus/src/core/ingestion/languages/java.ts +++ b/gitnexus/src/core/ingestion/languages/java.ts @@ -22,6 +22,8 @@ import { createFieldExtractor } from '../field-extractors/generic.js'; import { javaConfig } from '../field-extractors/configs/jvm.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { javaMethodConfig } from '../method-extractors/configs/jvm.js'; +import { createVariableExtractor } from '../variable-extractors/generic.js'; +import { javaVariableConfig } from '../variable-extractors/configs/jvm.js'; export const javaProvider = defineLanguage({ id: SupportedLanguages.Java, @@ -36,5 +38,6 @@ export const javaProvider = defineLanguage({ callExtractor: createCallExtractor(javaCallConfig), fieldExtractor: createFieldExtractor(javaConfig), methodExtractor: createMethodExtractor(javaMethodConfig), + variableExtractor: createVariableExtractor(javaVariableConfig), classExtractor: createClassExtractor(javaClassConfig), }); diff --git a/gitnexus/src/core/ingestion/languages/kotlin.ts b/gitnexus/src/core/ingestion/languages/kotlin.ts index 97dcddc38..7ab962cc5 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin.ts @@ -24,6 +24,8 @@ import { createFieldExtractor } from '../field-extractors/generic.js'; import { kotlinConfig } from '../field-extractors/configs/jvm.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { kotlinMethodConfig } from '../method-extractors/configs/jvm.js'; +import { createVariableExtractor } from '../variable-extractors/generic.js'; +import { kotlinVariableConfig } from '../variable-extractors/configs/jvm.js'; /** Check if a Kotlin function_declaration capture is inside a class_body (i.e., a method). * Kotlin grammar uses function_declaration for both top-level functions and class methods. @@ -111,6 +113,7 @@ export const kotlinProvider = defineLanguage({ callExtractor: createCallExtractor(kotlinCallConfig), fieldExtractor: createFieldExtractor(kotlinConfig), methodExtractor: createMethodExtractor(kotlinMethodConfig), + variableExtractor: createVariableExtractor(kotlinVariableConfig), classExtractor: createClassExtractor(kotlinClassConfig), builtInNames: BUILT_INS, labelOverride: (functionNode, defaultLabel) => { diff --git a/gitnexus/src/core/ingestion/languages/php.ts b/gitnexus/src/core/ingestion/languages/php.ts index 26c3a83af..e3af7c62d 100644 --- a/gitnexus/src/core/ingestion/languages/php.ts +++ b/gitnexus/src/core/ingestion/languages/php.ts @@ -21,6 +21,8 @@ import { createFieldExtractor } from '../field-extractors/generic.js'; import { phpConfig as phpFieldConfig } from '../field-extractors/configs/php.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { phpMethodConfig } from '../method-extractors/configs/php.js'; +import { createVariableExtractor } from '../variable-extractors/generic.js'; +import { phpVariableConfig } from '../variable-extractors/configs/php.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { phpCallConfig } from '../call-extractors/configs/php.js'; @@ -243,6 +245,7 @@ export const phpProvider = defineLanguage({ callExtractor: createCallExtractor(phpCallConfig), fieldExtractor: createFieldExtractor(phpFieldConfig), methodExtractor: createMethodExtractor(phpMethodConfig), + variableExtractor: createVariableExtractor(phpVariableConfig), classExtractor: createClassExtractor(phpClassConfig), descriptionExtractor: phpDescriptionExtractor, isRouteFile: isPhpRouteFile, diff --git a/gitnexus/src/core/ingestion/languages/python.ts b/gitnexus/src/core/ingestion/languages/python.ts index c2bf6119b..1e4f7e8c8 100644 --- a/gitnexus/src/core/ingestion/languages/python.ts +++ b/gitnexus/src/core/ingestion/languages/python.ts @@ -23,6 +23,8 @@ import { createFieldExtractor } from '../field-extractors/generic.js'; import { pythonConfig as pythonFieldConfig } from '../field-extractors/configs/python.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { pythonMethodConfig } from '../method-extractors/configs/python.js'; +import { createVariableExtractor } from '../variable-extractors/generic.js'; +import { pythonVariableConfig } from '../variable-extractors/configs/python.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { pythonCallConfig } from '../call-extractors/configs/python.js'; @@ -69,6 +71,7 @@ export const pythonProvider = defineLanguage({ callExtractor: createCallExtractor(pythonCallConfig), fieldExtractor: createFieldExtractor(pythonFieldConfig), methodExtractor: createMethodExtractor(pythonMethodConfig), + variableExtractor: createVariableExtractor(pythonVariableConfig), classExtractor: createClassExtractor(pythonClassConfig), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/ruby.ts b/gitnexus/src/core/ingestion/languages/ruby.ts index 139484b86..6f3fe4488 100644 --- a/gitnexus/src/core/ingestion/languages/ruby.ts +++ b/gitnexus/src/core/ingestion/languages/ruby.ts @@ -22,6 +22,8 @@ import { createFieldExtractor } from '../field-extractors/generic.js'; import { rubyConfig as rubyFieldConfig } from '../field-extractors/configs/ruby.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { rubyMethodConfig } from '../method-extractors/configs/ruby.js'; +import { createVariableExtractor } from '../variable-extractors/generic.js'; +import { rubyVariableConfig } from '../variable-extractors/configs/ruby.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { rubyCallConfig } from '../call-extractors/configs/ruby.js'; @@ -132,6 +134,7 @@ export const rubyProvider = defineLanguage({ ...rubyMethodConfig, extractFunctionName: rubyExtractFunctionName, }), + variableExtractor: createVariableExtractor(rubyVariableConfig), classExtractor: createClassExtractor(rubyClassConfig), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/rust.ts b/gitnexus/src/core/ingestion/languages/rust.ts index 999e99eca..21586d692 100644 --- a/gitnexus/src/core/ingestion/languages/rust.ts +++ b/gitnexus/src/core/ingestion/languages/rust.ts @@ -25,6 +25,8 @@ import { createFieldExtractor } from '../field-extractors/generic.js'; import { rustConfig as rustFieldConfig } from '../field-extractors/configs/rust.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { rustMethodConfig } from '../method-extractors/configs/rust.js'; +import { createVariableExtractor } from '../variable-extractors/generic.js'; +import { rustVariableConfig } from '../variable-extractors/configs/rust.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { rustCallConfig } from '../call-extractors/configs/rust.js'; @@ -129,6 +131,7 @@ export const rustProvider = defineLanguage({ ...rustMethodConfig, extractFunctionName: rustExtractFunctionName, }), + variableExtractor: createVariableExtractor(rustVariableConfig), classExtractor: createClassExtractor(rustClassConfig), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/swift.ts b/gitnexus/src/core/ingestion/languages/swift.ts index 2c34e9047..a3139897c 100644 --- a/gitnexus/src/core/ingestion/languages/swift.ts +++ b/gitnexus/src/core/ingestion/languages/swift.ts @@ -25,6 +25,8 @@ import { createFieldExtractor } from '../field-extractors/generic.js'; import { swiftConfig as swiftFieldConfig } from '../field-extractors/configs/swift.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { swiftMethodConfig } from '../method-extractors/configs/swift.js'; +import { createVariableExtractor } from '../variable-extractors/generic.js'; +import { swiftVariableConfig } from '../variable-extractors/configs/swift.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { swiftCallConfig } from '../call-extractors/configs/swift.js'; @@ -249,6 +251,7 @@ export const swiftProvider = defineLanguage({ ...swiftMethodConfig, extractFunctionName: swiftExtractFunctionName, }), + variableExtractor: createVariableExtractor(swiftVariableConfig), classExtractor: createClassExtractor(swiftClassConfig), implicitImportWirer: wireSwiftImplicitImports, builtInNames: BUILT_INS, diff --git a/gitnexus/src/core/ingestion/languages/typescript.ts b/gitnexus/src/core/ingestion/languages/typescript.ts index 318c2b37b..69b4ca84c 100644 --- a/gitnexus/src/core/ingestion/languages/typescript.ts +++ b/gitnexus/src/core/ingestion/languages/typescript.ts @@ -29,6 +29,11 @@ import { typescriptMethodConfig, javascriptMethodConfig, } from '../method-extractors/configs/typescript-javascript.js'; +import { createVariableExtractor } from '../variable-extractors/generic.js'; +import { + typescriptVariableConfig, + javascriptVariableConfig, +} from '../variable-extractors/configs/typescript-javascript.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { typescriptCallConfig, @@ -171,6 +176,7 @@ export const typescriptProvider = defineLanguage({ ...typescriptMethodConfig, extractFunctionName: tsExtractFunctionName, }), + variableExtractor: createVariableExtractor(typescriptVariableConfig), classExtractor: createClassExtractor(typescriptClassConfig), builtInNames: BUILT_INS, }); @@ -189,6 +195,7 @@ export const javascriptProvider = defineLanguage({ ...javascriptMethodConfig, extractFunctionName: tsExtractFunctionName, }), + variableExtractor: createVariableExtractor(javascriptVariableConfig), classExtractor: createClassExtractor(javascriptClassConfig), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/vue.ts b/gitnexus/src/core/ingestion/languages/vue.ts index 77608870c..4164f6b02 100644 --- a/gitnexus/src/core/ingestion/languages/vue.ts +++ b/gitnexus/src/core/ingestion/languages/vue.ts @@ -22,6 +22,8 @@ import { extractTsNamedBindings } from '../named-bindings/typescript.js'; import { TYPESCRIPT_QUERIES } from '../tree-sitter-queries.js'; import { typescriptFieldExtractor } from '../field-extractors/typescript.js'; import { BUILT_INS as TS_BUILT_INS } from './typescript.js'; +import { createVariableExtractor } from '../variable-extractors/generic.js'; +import { typescriptVariableConfig } from '../variable-extractors/configs/typescript-javascript.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { typescriptCallConfig } from '../call-extractors/configs/typescript-javascript.js'; @@ -71,6 +73,7 @@ export const vueProvider = defineLanguage({ namedBindingExtractor: extractTsNamedBindings, callExtractor: createCallExtractor(typescriptCallConfig), fieldExtractor: typescriptFieldExtractor, + variableExtractor: createVariableExtractor(typescriptVariableConfig), classExtractor: vueClassExtractor, builtInNames: VUE_BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index 99fd3b21c..18b163777 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -61,6 +61,22 @@ export const TYPESCRIPT_QUERIES = ` name: (identifier) @name value: (function_expression)))) @definition.function +; Variable/constant declarations (non-function values). +; Overlap with @definition.function patterns is handled by parse-worker dedup. +(lexical_declaration + (variable_declarator + name: (identifier) @name)) @definition.const + +(export_statement + declaration: (lexical_declaration + (variable_declarator + name: (identifier) @name))) @definition.const + +; var declarations (mutable, function-scoped) +(variable_declaration + (variable_declarator + name: (identifier) @name)) @definition.variable + (import_statement source: (string) @import.source) @import @@ -203,6 +219,22 @@ export const JAVASCRIPT_QUERIES = ` name: (identifier) @name value: (function_expression)))) @definition.function +; Variable/constant declarations (non-function values). +; Overlap with @definition.function patterns is handled by parse-worker dedup. +(lexical_declaration + (variable_declarator + name: (identifier) @name)) @definition.const + +(export_statement + declaration: (lexical_declaration + (variable_declarator + name: (identifier) @name))) @definition.const + +; var declarations (mutable, function-scoped) +(variable_declaration + (variable_declarator + name: (identifier) @name)) @definition.variable + (import_statement source: (string) @import.source) @import @@ -306,6 +338,12 @@ export const PYTHON_QUERIES = ` left: (identifier) @name type: (type)) @definition.property) +; Plain variable assignments without type annotation: x = 5, MAX_SIZE = 100 +; Overlap with @definition.property (typed) is handled by parse-worker dedup. +(expression_statement + (assignment + left: (identifier) @name)) @definition.variable + ; Heritage queries - Python class inheritance (class_definition name: (identifier) @heritage.class @@ -371,6 +409,11 @@ export const JAVA_QUERIES = ` ; Constructor calls: new Foo() (object_creation_expression type: (type_identifier) @call.name) @call +; Local variable declarations inside method bodies +(local_variable_declaration + declarator: (variable_declarator + name: (identifier) @name)) @definition.variable + ; Heritage - extends class (class_declaration name: (identifier) @heritage.class (superclass (type_identifier) @heritage.extends)) @heritage @@ -416,6 +459,11 @@ export const C_QUERIES = ` ; Calls (call_expression function: (identifier) @call.name) @call (call_expression function: (field_expression field: (field_identifier) @call.name)) @call + +; Variable declarations: int x = 5; or int x; +(declaration + declarator: (init_declarator + declarator: (identifier) @name)) @definition.variable `; // Go queries - works with tree-sitter-go @@ -450,6 +498,13 @@ export const GO_QUERIES = ` (call_expression function: (identifier) @call.name) @call (call_expression function: (selector_expression field: (field_identifier) @call.name)) @call +; Const/var declarations +(const_declaration (const_spec name: (identifier) @name)) @definition.const +(var_declaration (var_spec name: (identifier) @name)) @definition.variable + +; Short variable declaration: x := 5 +(short_var_declaration left: (expression_list (identifier) @name)) @definition.variable + ; Struct literal construction: User{Name: "Alice"} (composite_literal type: (type_identifier) @call.name) @call @@ -572,6 +627,11 @@ export const CPP_QUERIES = ` ; Constructor calls: new User() (new_expression type: (type_identifier) @call.name) @call +; Variable declarations: int x = 5; or auto x = 5; +(declaration + declarator: (init_declarator + declarator: (identifier) @name)) @definition.variable + ; Heritage (class_specifier name: (type_identifier) @heritage.class (base_class_clause (type_identifier) @heritage.extends)) @heritage @@ -634,6 +694,12 @@ export const CSHARP_QUERIES = ` ; Target-typed new (C# 9): User u = new("x", 5) (variable_declaration type: (identifier) @call.name (variable_declarator (implicit_object_creation_expression) @call)) +; Local variable declarations +(local_declaration_statement + (variable_declaration + (variable_declarator + (identifier) @name))) @definition.variable + ; Heritage (class_declaration name: (identifier) @heritage.class (base_list (identifier) @heritage.extends)) @heritage @@ -781,6 +847,11 @@ export const PHP_QUERIES = ` ; Constructor call: new User() (object_creation_expression (name) @call.name) @call +; Const declarations at class scope +(const_declaration + (const_element + (name) @name)) @definition.const + ; ── Heritage: extends ──────────────────────────────────────────────────────── (class_declaration name: (name) @heritage.class @@ -849,6 +920,10 @@ export const RUBY_QUERIES = ` (call method: (identifier) @call.name) @call +; ── Constant assignment: MAX_SIZE = 100, ITEMS = [...] ─────────────────────── +(assignment + left: (constant) @name) @definition.const + ; ── Bare calls without parens (identifiers at statement level are method calls) ─ ; NOTE: This may over-capture variable reads as calls (e.g. 'result' at ; statement level). Ruby's grammar makes bare identifiers ambiguous — they @@ -1123,6 +1198,12 @@ export const DART_QUERIES = ` (setter_signature name: (identifier) @name)) @definition.property +; ── Top-level variable declarations (const maxSize = 100, final x = 5, var y = 0) ── +(declaration + (initialized_identifier_list + (initialized_identifier + (identifier) @name))) @definition.variable + ; ── Imports ────────────────────────────────────────────────────────────────── (import_or_export (library_import diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index 2d4b794ae..a36ed103d 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -24,6 +24,7 @@ export const DEFINITION_CAPTURE_KEYS = [ 'definition.type', 'definition.const', 'definition.static', + 'definition.variable', 'definition.typedef', 'definition.macro', 'definition.union', @@ -190,6 +191,7 @@ export function getLabelFromCaptures( if (captureMap['definition.type']) return 'TypeAlias'; if (captureMap['definition.const']) return 'Const'; if (captureMap['definition.static']) return 'Static'; + if (captureMap['definition.variable']) return 'Variable'; if (captureMap['definition.typedef']) return 'Typedef'; if (captureMap['definition.macro']) return 'Macro'; if (captureMap['definition.union']) return 'Union'; diff --git a/gitnexus/src/core/ingestion/variable-extractors/configs/c-cpp.ts b/gitnexus/src/core/ingestion/variable-extractors/configs/c-cpp.ts new file mode 100644 index 000000000..29bf952ed --- /dev/null +++ b/gitnexus/src/core/ingestion/variable-extractors/configs/c-cpp.ts @@ -0,0 +1,93 @@ +// gitnexus/src/core/ingestion/variable-extractors/configs/c-cpp.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { VariableExtractionConfig } from '../../variable-types.js'; +import type { VariableVisibility } from '../../variable-types.js'; +import { hasKeyword } from '../../field-extractors/configs/helpers.js'; +import { extractSimpleTypeName } from '../../type-extractors/shared.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +/** + * C/C++ variable extraction config. + * + * Handles global/namespace-scoped variable declarations: + * - `int x = 5;` + * - `const int MAX = 100;` + * - `static int counter = 0;` + * - `constexpr int SIZE = 10;` (C++) + * - `extern int shared;` + * + * tree-sitter-c/cpp uses declaration for variable declarations. + */ + +function extractCVarName(node: SyntaxNode): string | undefined { + // declaration → declarator (init_declarator or identifier) + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'init_declarator') { + const declarator = child.childForFieldName('declarator'); + if (declarator?.type === 'identifier') return declarator.text; + if (declarator?.type === 'pointer_declarator') { + const inner = declarator.namedChildren.find((c: SyntaxNode) => c.type === 'identifier'); + return inner?.text; + } + } + if (child?.type === 'identifier') return child.text; + } + return undefined; +} + +function extractCVarType(node: SyntaxNode): string | undefined { + const typeNode = node.childForFieldName('type'); + if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim(); + // Fallback: first primitive_type or type_identifier child + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if ( + child?.type === 'primitive_type' || + child?.type === 'type_identifier' || + child?.type === 'sized_type_specifier' + ) { + return child.text?.trim(); + } + } + return undefined; +} + +const shared: Omit = { + constNodeTypes: [], + staticNodeTypes: [], + variableNodeTypes: ['declaration'], + + extractName: extractCVarName, + extractType: extractCVarType, + + extractVisibility(node): VariableVisibility { + // C/C++ visibility is file-scoped by default (static = file-private) + if (hasKeyword(node, 'static')) return 'private'; + if (hasKeyword(node, 'extern')) return 'public'; + return 'public'; + }, + + isConst(node) { + return hasKeyword(node, 'const') || hasKeyword(node, 'constexpr'); + }, + + isStatic(node) { + return hasKeyword(node, 'static'); + }, + + isMutable(node) { + return !hasKeyword(node, 'const') && !hasKeyword(node, 'constexpr'); + }, +}; + +export const cVariableConfig: VariableExtractionConfig = { + ...shared, + language: SupportedLanguages.C, +}; + +export const cppVariableConfig: VariableExtractionConfig = { + ...shared, + language: SupportedLanguages.CPlusPlus, +}; diff --git a/gitnexus/src/core/ingestion/variable-extractors/configs/csharp.ts b/gitnexus/src/core/ingestion/variable-extractors/configs/csharp.ts new file mode 100644 index 000000000..002207cca --- /dev/null +++ b/gitnexus/src/core/ingestion/variable-extractors/configs/csharp.ts @@ -0,0 +1,64 @@ +// gitnexus/src/core/ingestion/variable-extractors/configs/csharp.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { VariableExtractionConfig } from '../../variable-types.js'; +import type { VariableVisibility } from '../../variable-types.js'; +import { collectModifierTexts } from '../../field-extractors/configs/helpers.js'; +import { extractSimpleTypeName } from '../../type-extractors/shared.js'; + +/** + * C# variable extraction config. + * + * C# does not have true top-level variables (pre-C# 9). In C# 9+ top-level + * statements, local_declaration_statement can appear at program scope. + * Class-scoped fields are handled by the field extractor. + */ +export const csharpVariableConfig: VariableExtractionConfig = { + language: SupportedLanguages.CSharp, + constNodeTypes: [], + staticNodeTypes: [], + variableNodeTypes: ['local_declaration_statement'], + + extractName(node) { + // local_declaration_statement → variable_declaration → variable_declarator → identifier + const varDecl = node.namedChildren.find((c) => c.type === 'variable_declaration'); + if (!varDecl) return undefined; + const declarator = varDecl.namedChildren.find((c) => c.type === 'variable_declarator'); + const name = declarator?.childForFieldName('name'); + return name?.type === 'identifier' ? name.text : undefined; + }, + + extractType(node) { + const varDecl = node.namedChildren.find((c) => c.type === 'variable_declaration'); + if (!varDecl) return undefined; + const typeNode = varDecl.childForFieldName('type'); + if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim(); + return undefined; + }, + + extractVisibility(node): VariableVisibility { + const mods = collectModifierTexts(node); + if (mods.has('public')) return 'public'; + if (mods.has('private')) return 'private'; + if (mods.has('protected') && mods.has('internal')) return 'protected internal'; + if (mods.has('private') && mods.has('protected')) return 'private protected'; + if (mods.has('protected')) return 'protected'; + if (mods.has('internal')) return 'internal'; + return 'private'; + }, + + isConst(node) { + const mods = collectModifierTexts(node); + return mods.has('const'); + }, + + isStatic(node) { + const mods = collectModifierTexts(node); + return mods.has('static'); + }, + + isMutable(node) { + const mods = collectModifierTexts(node); + return !mods.has('const') && !mods.has('readonly'); + }, +}; diff --git a/gitnexus/src/core/ingestion/variable-extractors/configs/dart.ts b/gitnexus/src/core/ingestion/variable-extractors/configs/dart.ts new file mode 100644 index 000000000..a7e52afa4 --- /dev/null +++ b/gitnexus/src/core/ingestion/variable-extractors/configs/dart.ts @@ -0,0 +1,101 @@ +// gitnexus/src/core/ingestion/variable-extractors/configs/dart.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { VariableExtractionConfig } from '../../variable-types.js'; +import type { VariableVisibility } from '../../variable-types.js'; +import { extractSimpleTypeName } from '../../type-extractors/shared.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +/** + * Dart variable extraction config. + * + * Dart has top-level variable and constant declarations: + * - `const maxSize = 100;` + * - `final String name = "dart";` + * - `var counter = 0;` + * - `int x = 5;` + * + * tree-sitter-dart uses: + * - declaration (with initialized_identifier_list) for file-scope variables + */ + +function extractDartVarName(node: SyntaxNode): string | undefined { + // declaration → initialized_variable_definition → identifier + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'initialized_variable_definition') { + const name = child.childForFieldName('name'); + if (name) return name.text; + // Fallback: first identifier + for (let j = 0; j < child.namedChildCount; j++) { + const gc = child.namedChild(j); + if (gc?.type === 'identifier') return gc.text; + } + } + // declaration → initialized_identifier_list → initialized_identifier → identifier + if (child?.type === 'initialized_identifier_list') { + for (let j = 0; j < child.namedChildCount; j++) { + const gc = child.namedChild(j); + if (gc?.type === 'initialized_identifier') { + const ident = gc.namedChildren.find((c: SyntaxNode) => c.type === 'identifier'); + if (ident) return ident.text; + } + } + } + } + return undefined; +} + +function extractDartVarType(node: SyntaxNode): string | undefined { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'initialized_variable_definition') { + const typeNode = child.childForFieldName('type'); + if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim(); + } + } + // Look for type_identifier directly on the node + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'type_identifier') return child.text; + } + return undefined; +} + +function hasDartKeyword(node: SyntaxNode, keyword: string): boolean { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child?.text === keyword) return true; + } + return false; +} + +export const dartVariableConfig: VariableExtractionConfig = { + language: SupportedLanguages.Dart, + constNodeTypes: [], + staticNodeTypes: [], + variableNodeTypes: ['declaration'], + + extractName: extractDartVarName, + extractType: extractDartVarType, + + extractVisibility(node): VariableVisibility { + const name = extractDartVarName(node); + if (!name) return 'public'; + // Dart convention: underscore prefix = library-private + return name.startsWith('_') ? 'private' : 'public'; + }, + + isConst(node) { + return hasDartKeyword(node, 'const') || hasDartKeyword(node, 'final'); + }, + + isStatic(_node) { + // Top-level Dart variables are not static + return false; + }, + + isMutable(node) { + return !hasDartKeyword(node, 'const') && !hasDartKeyword(node, 'final'); + }, +}; diff --git a/gitnexus/src/core/ingestion/variable-extractors/configs/go.ts b/gitnexus/src/core/ingestion/variable-extractors/configs/go.ts new file mode 100644 index 000000000..2277aecfc --- /dev/null +++ b/gitnexus/src/core/ingestion/variable-extractors/configs/go.ts @@ -0,0 +1,91 @@ +// gitnexus/src/core/ingestion/variable-extractors/configs/go.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { VariableExtractionConfig } from '../../variable-types.js'; +import type { VariableVisibility } from '../../variable-types.js'; +import { extractSimpleTypeName } from '../../type-extractors/shared.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +/** + * Go variable extraction config. + * + * Go has package-scoped var and const declarations: + * - `var x int = 5` + * - `const MaxSize = 100` + * - `var ( ... )` grouped declarations + * + * tree-sitter-go uses: + * - var_declaration → var_spec → identifier, type + * - const_declaration → const_spec → identifier, type + * + * Visibility: uppercase first letter = exported (public), lowercase = unexported (package). + */ + +function extractGoVarName(node: SyntaxNode): string | undefined { + // var_declaration/const_declaration → var_spec/const_spec → identifier + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'var_spec' || child?.type === 'const_spec') { + const name = child.childForFieldName('name'); + if (name) return name.text; + // Fallback: first identifier child + for (let j = 0; j < child.namedChildCount; j++) { + const gc = child.namedChild(j); + if (gc?.type === 'identifier') return gc.text; + } + } + } + // short_var_declaration: x := 5 → expression_list → identifier + if (node.type === 'short_var_declaration') { + const left = node.childForFieldName('left'); + if (left?.type === 'expression_list') { + const firstIdent = left.namedChildren.find((c: SyntaxNode) => c.type === 'identifier'); + if (firstIdent) return firstIdent.text; + } + } + return undefined; +} + +function extractGoVarType(node: SyntaxNode): string | undefined { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'var_spec' || child?.type === 'const_spec') { + const typeNode = child.childForFieldName('type'); + if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim(); + } + } + return undefined; +} + +export const goVariableConfig: VariableExtractionConfig = { + language: SupportedLanguages.Go, + constNodeTypes: ['const_declaration'], + staticNodeTypes: [], + variableNodeTypes: ['var_declaration', 'short_var_declaration'], + + extractName: extractGoVarName, + extractType: extractGoVarType, + + extractVisibility(node): VariableVisibility { + const name = extractGoVarName(node); + if (!name) return 'package'; + // Go visibility: uppercase first letter = exported + const firstChar = name.charAt(0); + return firstChar === firstChar.toUpperCase() && firstChar !== firstChar.toLowerCase() + ? 'public' + : 'package'; + }, + + isConst(node) { + return node.type === 'const_declaration'; + }, + + isStatic(_node) { + // Go does not have static declarations + return false; + }, + + isMutable(node) { + return node.type !== 'const_declaration'; + }, +}; diff --git a/gitnexus/src/core/ingestion/variable-extractors/configs/jvm.ts b/gitnexus/src/core/ingestion/variable-extractors/configs/jvm.ts new file mode 100644 index 000000000..6ebaa3024 --- /dev/null +++ b/gitnexus/src/core/ingestion/variable-extractors/configs/jvm.ts @@ -0,0 +1,124 @@ +// gitnexus/src/core/ingestion/variable-extractors/configs/jvm.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { VariableExtractionConfig } from '../../variable-types.js'; +import type { VariableVisibility } from '../../variable-types.js'; +import { hasModifier } from '../../field-extractors/configs/helpers.js'; +import { extractSimpleTypeName } from '../../type-extractors/shared.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +/** + * Java variable extraction config. + * + * Java does not have true module-level variables — all declarations are + * class-scoped. However, `static final` fields at class scope act like + * constants. These are already handled by the field extractor. This config + * covers any rare local_variable_declaration captures at file scope + * (e.g., in scripts or top-level code blocks in JShell). + */ +export const javaVariableConfig: VariableExtractionConfig = { + language: SupportedLanguages.Java, + constNodeTypes: [], + staticNodeTypes: [], + variableNodeTypes: ['local_variable_declaration'], + + extractName(node) { + const declarator = node.namedChildren.find((c) => c.type === 'variable_declarator'); + const name = declarator?.childForFieldName('name'); + return name?.type === 'identifier' ? name.text : undefined; + }, + + extractType(node) { + const typeNode = node.childForFieldName('type'); + if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim(); + return undefined; + }, + + extractVisibility(node): VariableVisibility { + if (hasModifier(node, 'modifiers', 'public')) return 'public'; + if (hasModifier(node, 'modifiers', 'private')) return 'private'; + if (hasModifier(node, 'modifiers', 'protected')) return 'protected'; + return 'package'; + }, + + isConst(node) { + return hasModifier(node, 'modifiers', 'final'); + }, + + isStatic(node) { + return hasModifier(node, 'modifiers', 'static'); + }, + + isMutable(node) { + return !hasModifier(node, 'modifiers', 'final'); + }, +}; + +/** + * Kotlin variable extraction config. + * + * Kotlin has true top-level val/var declarations outside classes. + * tree-sitter-kotlin uses 'property_declaration' for both. + */ +export const kotlinVariableConfig: VariableExtractionConfig = { + language: SupportedLanguages.Kotlin, + constNodeTypes: [], + staticNodeTypes: [], + variableNodeTypes: ['property_declaration'], + + extractName(node) { + // property_declaration → variable_declaration → simple_identifier + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'variable_declaration') { + const ident = child.namedChildren.find((c: SyntaxNode) => c.type === 'simple_identifier'); + return ident?.text; + } + } + return undefined; + }, + + extractType(node) { + // Look for type annotation in variable_declaration child + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'variable_declaration') { + const typeNode = child.namedChildren.find( + (c: SyntaxNode) => c.type === 'user_type' || c.type === 'nullable_type', + ); + if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim(); + } + } + return undefined; + }, + + extractVisibility(node): VariableVisibility { + if (hasModifier(node, 'modifiers', 'public')) return 'public'; + if (hasModifier(node, 'modifiers', 'private')) return 'private'; + if (hasModifier(node, 'modifiers', 'protected')) return 'protected'; + if (hasModifier(node, 'modifiers', 'internal')) return 'internal'; + return 'public'; // Kotlin default is public + }, + + isConst(node) { + // val is immutable; also check for `const` modifier + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child?.text === 'val') return true; + } + return hasModifier(node, 'modifiers', 'const'); + }, + + isStatic(_node) { + // Top-level Kotlin properties are not static in the Java sense + return false; + }, + + isMutable(node) { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child?.text === 'var') return true; + } + return false; + }, +}; diff --git a/gitnexus/src/core/ingestion/variable-extractors/configs/php.ts b/gitnexus/src/core/ingestion/variable-extractors/configs/php.ts new file mode 100644 index 000000000..9c940d4f1 --- /dev/null +++ b/gitnexus/src/core/ingestion/variable-extractors/configs/php.ts @@ -0,0 +1,67 @@ +// gitnexus/src/core/ingestion/variable-extractors/configs/php.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { VariableExtractionConfig } from '../../variable-types.js'; +import type { VariableVisibility } from '../../variable-types.js'; +import { hasKeyword } from '../../field-extractors/configs/helpers.js'; + +/** + * PHP variable extraction config. + * + * PHP has const declarations at namespace/file scope and global variables: + * - `const MAX_SIZE = 100;` + * - `define('MAX_SIZE', 100);` + * - `$variable = value;` + * + * tree-sitter-php uses: + * - const_declaration at namespace/program scope + * - expression_statement containing assignment_expression for variables + */ +export const phpVariableConfig: VariableExtractionConfig = { + language: SupportedLanguages.PHP, + constNodeTypes: ['const_declaration'], + staticNodeTypes: [], + variableNodeTypes: ['expression_statement'], + + extractName(node) { + if (node.type === 'const_declaration') { + // const_declaration → const_element → name (identifier) + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'const_element') { + const name = child.childForFieldName('name'); + return name?.text; + } + } + return undefined; + } + // expression_statement → assignment_expression → variable_name + const inner = node.firstNamedChild; + if (inner?.type === 'assignment_expression') { + const left = inner.childForFieldName('left'); + if (left?.type === 'variable_name') return left.text; + } + return undefined; + }, + + extractType(_node) { + // PHP is dynamically typed — no inline type annotations for variables + return undefined; + }, + + extractVisibility(_node): VariableVisibility { + return 'public'; + }, + + isConst(node) { + return node.type === 'const_declaration'; + }, + + isStatic(node) { + return hasKeyword(node, 'static'); + }, + + isMutable(node) { + return node.type !== 'const_declaration'; + }, +}; diff --git a/gitnexus/src/core/ingestion/variable-extractors/configs/python.ts b/gitnexus/src/core/ingestion/variable-extractors/configs/python.ts new file mode 100644 index 000000000..d0f7722fb --- /dev/null +++ b/gitnexus/src/core/ingestion/variable-extractors/configs/python.ts @@ -0,0 +1,109 @@ +// gitnexus/src/core/ingestion/variable-extractors/configs/python.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { VariableExtractionConfig } from '../../variable-types.js'; +import type { VariableVisibility } from '../../variable-types.js'; +import { extractSimpleTypeName } from '../../type-extractors/shared.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +/** + * Python variable extraction config. + * + * Handles module-level assignments and annotated assignments: + * - `MAX_SIZE = 100` → const by UPPER_CASE convention + * - `name: str = "default"` → annotated assignment with type + * - `_private_var = 42` → protected by convention + * - `__private_var = 42` → private by convention + * + * tree-sitter-python uses: + * - expression_statement containing assignment or type nodes + */ + +function extractNameFromPython(node: SyntaxNode): string | undefined { + const inner = node.firstNamedChild; + if (!inner) return undefined; + + // Annotated assignment: name: str = "default" + // AST: expression_statement > type > identifier + if (inner.type === 'type') { + const ident = inner.childForFieldName('name') ?? inner.firstNamedChild; + return ident?.type === 'identifier' ? ident.text : undefined; + } + + // Plain assignment: x = 5 + if (inner.type === 'assignment') { + const left = inner.childForFieldName('left'); + if (left?.type === 'identifier') return left.text; + } + + return undefined; +} + +function extractTypeFromPython(node: SyntaxNode): string | undefined { + const inner = node.firstNamedChild; + if (!inner) return undefined; + + // Standalone annotated type without assignment: `name: str` + if (inner.type === 'type') { + const typeNode = inner.childForFieldName('type') ?? inner.namedChild(1); + if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim(); + } + + // Annotated assignment: `name: str = "hello"` + // AST: expression_statement > assignment > [identifier, type > identifier, ...] + if (inner.type === 'assignment') { + for (let i = 0; i < inner.childCount; i++) { + const child = inner.child(i); + if (child?.type === 'type') { + const typeId = child.firstNamedChild; + if (typeId) return extractSimpleTypeName(typeId) ?? typeId.text?.trim(); + } + } + } + + return undefined; +} + +function extractVisFromPython(node: SyntaxNode): VariableVisibility { + const name = extractNameFromPython(node); + if (!name) return 'public'; + // Dunder names (__name__, __all__) are public Python conventions + if (name.startsWith('__') && name.endsWith('__')) return 'public'; + // Double underscore prefix (name mangled) = private + if (name.startsWith('__')) return 'private'; + // Single underscore prefix = protected by convention + if (name.startsWith('_')) return 'protected'; + return 'public'; +} + +export const pythonVariableConfig: VariableExtractionConfig = { + language: SupportedLanguages.Python, + constNodeTypes: [], + staticNodeTypes: [], + // expression_statement is broad — isVariableDeclaration returns true for + // all expression_statement nodes, but extract() safely filters non-assignments + // by returning null when extractNameFromPython finds no assignment target. + variableNodeTypes: ['expression_statement'], + + extractName: extractNameFromPython, + extractType: extractTypeFromPython, + extractVisibility: extractVisFromPython, + + isConst(node) { + // Python convention: UPPER_CASE names are constants + const name = extractNameFromPython(node); + if (!name) return false; + return name === name.toUpperCase() && /^[A-Z][A-Z0-9_]*$/.test(name); + }, + + isStatic(_node) { + return false; + }, + + isMutable(node) { + const name = extractNameFromPython(node); + if (!name) return true; + // By convention, UPPER_CASE names are immutable constants + return !(name === name.toUpperCase() && /^[A-Z][A-Z0-9_]*$/.test(name)); + }, +}; diff --git a/gitnexus/src/core/ingestion/variable-extractors/configs/ruby.ts b/gitnexus/src/core/ingestion/variable-extractors/configs/ruby.ts new file mode 100644 index 000000000..4c57cca0d --- /dev/null +++ b/gitnexus/src/core/ingestion/variable-extractors/configs/ruby.ts @@ -0,0 +1,57 @@ +// gitnexus/src/core/ingestion/variable-extractors/configs/ruby.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { VariableExtractionConfig } from '../../variable-types.js'; +import type { VariableVisibility } from '../../variable-types.js'; + +/** + * Ruby variable extraction config. + * + * Ruby module-level constants use UPPER_CASE identifiers or start with + * an uppercase letter. Ruby uses: + * - assignment for variable declarations at module scope + * - Constants: `MAX_SIZE = 100` or `Config = ...` + * - Global variables: `$global = ...` + */ +export const rubyVariableConfig: VariableExtractionConfig = { + language: SupportedLanguages.Ruby, + constNodeTypes: [], + staticNodeTypes: [], + variableNodeTypes: ['assignment'], + + extractName(node) { + const left = node.childForFieldName('left'); + if (!left) return undefined; + if (left.type === 'identifier' || left.type === 'constant') return left.text; + if (left.type === 'global_variable') return left.text; + return undefined; + }, + + extractType(_node) { + // Ruby is dynamically typed — no type annotations at module level + return undefined; + }, + + extractVisibility(_node): VariableVisibility { + const left = _node.childForFieldName('left'); + if (!left) return 'public'; + // Constants (uppercase start) and global variables are effectively public + if (left.type === 'constant' || left.type === 'global_variable') return 'public'; + return 'private'; + }, + + isConst(node) { + const left = node.childForFieldName('left'); + return left?.type === 'constant'; + }, + + isStatic(_node) { + return false; + }, + + isMutable(node) { + const left = node.childForFieldName('left'); + // Constants are immutable by convention + return left?.type !== 'constant'; + }, +}; diff --git a/gitnexus/src/core/ingestion/variable-extractors/configs/rust.ts b/gitnexus/src/core/ingestion/variable-extractors/configs/rust.ts new file mode 100644 index 000000000..f296756a9 --- /dev/null +++ b/gitnexus/src/core/ingestion/variable-extractors/configs/rust.ts @@ -0,0 +1,82 @@ +// gitnexus/src/core/ingestion/variable-extractors/configs/rust.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { VariableExtractionConfig } from '../../variable-types.js'; +import type { VariableVisibility } from '../../variable-types.js'; +import { extractSimpleTypeName } from '../../type-extractors/shared.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +/** + * Rust variable extraction config. + * + * Rust has module-scoped const, static, and let declarations: + * - `const MAX_SIZE: usize = 100;` + * - `static COUNTER: AtomicUsize = AtomicUsize::new(0);` + * - `static mut BUFFER: Vec = Vec::new();` + * - `let x = 5;` (block-scoped, but included for completeness) + * + * tree-sitter-rust uses: + * - const_item → identifier, type + * - static_item → identifier, type + * - let_declaration → identifier, type + */ + +function hasVisibilityModifier(node: SyntaxNode): boolean { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'visibility_modifier') return true; + } + return false; +} + +function hasMutKeyword(node: SyntaxNode): boolean { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child?.text === 'mut') return true; + } + return false; +} + +export const rustVariableConfig: VariableExtractionConfig = { + language: SupportedLanguages.Rust, + constNodeTypes: ['const_item'], + staticNodeTypes: ['static_item'], + variableNodeTypes: ['let_declaration'], + + extractName(node) { + const name = node.childForFieldName('name'); + if (name) return name.text; + // Fallback: first identifier child + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'identifier') return child.text; + } + return undefined; + }, + + extractType(node) { + const typeNode = node.childForFieldName('type'); + if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim(); + return undefined; + }, + + extractVisibility(node): VariableVisibility { + return hasVisibilityModifier(node) ? 'public' : 'private'; + }, + + isConst(node) { + return node.type === 'const_item'; + }, + + isStatic(node) { + return node.type === 'static_item'; + }, + + isMutable(node) { + if (node.type === 'const_item') return false; + if (node.type === 'static_item') return hasMutKeyword(node); + // let_declaration: check for mut keyword + if (node.type === 'let_declaration') return hasMutKeyword(node); + return true; + }, +}; diff --git a/gitnexus/src/core/ingestion/variable-extractors/configs/swift.ts b/gitnexus/src/core/ingestion/variable-extractors/configs/swift.ts new file mode 100644 index 000000000..018800670 --- /dev/null +++ b/gitnexus/src/core/ingestion/variable-extractors/configs/swift.ts @@ -0,0 +1,99 @@ +// gitnexus/src/core/ingestion/variable-extractors/configs/swift.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { VariableExtractionConfig } from '../../variable-types.js'; +import type { VariableVisibility } from '../../variable-types.js'; +import { extractSimpleTypeName } from '../../type-extractors/shared.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +/** + * Swift variable extraction config. + * + * Swift has top-level let/var declarations: + * - `let maxSize = 100` + * - `var counter = 0` + * - `public let apiKey: String = "..."` + * + * tree-sitter-swift uses: + * - property_declaration for both class and top-level declarations + */ + +function extractSwiftVarName(node: SyntaxNode): string | undefined { + // property_declaration → pattern → ... → simple_identifier / identifier + const pattern = node.namedChildren.find((c: SyntaxNode) => c.type === 'pattern'); + if (pattern) { + const ident = pattern.namedChildren.find( + (c: SyntaxNode) => c.type === 'simple_identifier' || c.type === 'identifier', + ); + if (ident) return ident.text; + } + // Fallback: look for simple_identifier directly + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'simple_identifier') return child.text; + } + return undefined; +} + +function extractSwiftVarType(node: SyntaxNode): string | undefined { + // Look for type_annotation child + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'type_annotation') { + const typeNode = child.firstNamedChild; + if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim(); + } + } + return undefined; +} + +function hasSwiftKeyword(node: SyntaxNode, keyword: string): boolean { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child?.text === keyword) return true; + } + return false; +} + +const SWIFT_VISIBILITY = new Set(['public', 'private', 'internal', 'fileprivate', 'open']); + +export const swiftVariableConfig: VariableExtractionConfig = { + language: SupportedLanguages.Swift, + constNodeTypes: [], + staticNodeTypes: [], + variableNodeTypes: ['property_declaration'], + + extractName: extractSwiftVarName, + extractType: extractSwiftVarType, + + extractVisibility(node): VariableVisibility { + // Check modifiers for visibility keywords + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'modifiers') { + for (let j = 0; j < child.childCount; j++) { + const mod = child.child(j); + if (mod && SWIFT_VISIBILITY.has(mod.text)) return mod.text as VariableVisibility; + } + } + } + // Direct keyword check + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child && SWIFT_VISIBILITY.has(child.text)) return child.text as VariableVisibility; + } + return 'internal'; // Swift default visibility + }, + + isConst(node) { + return hasSwiftKeyword(node, 'let'); + }, + + isStatic(node) { + return hasSwiftKeyword(node, 'static') || hasSwiftKeyword(node, 'class'); + }, + + isMutable(node) { + return hasSwiftKeyword(node, 'var'); + }, +}; diff --git a/gitnexus/src/core/ingestion/variable-extractors/configs/typescript-javascript.ts b/gitnexus/src/core/ingestion/variable-extractors/configs/typescript-javascript.ts new file mode 100644 index 000000000..0477899a2 --- /dev/null +++ b/gitnexus/src/core/ingestion/variable-extractors/configs/typescript-javascript.ts @@ -0,0 +1,94 @@ +// gitnexus/src/core/ingestion/variable-extractors/configs/typescript-javascript.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { VariableExtractionConfig } from '../../variable-types.js'; +import type { VariableVisibility } from '../../variable-types.js'; +import { hasKeyword, typeFromAnnotation } from '../../field-extractors/configs/helpers.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +/** + * TypeScript/JavaScript variable extraction config. + * + * Handles module-scoped const/let/var declarations: + * - `export const X = ...` → public, const + * - `const X = ...` → private, const + * - `let x = ...` → private, mutable + * - `var x = ...` → private, mutable + * + * tree-sitter node structure: + * lexical_declaration (const/let) → variable_declarator → identifier (name) + * variable_declaration (var) → variable_declarator → identifier (name) + */ + +function extractNameFromDecl(node: SyntaxNode): string | undefined { + // lexical_declaration / variable_declaration → variable_declarator → name + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'variable_declarator') { + const name = child.childForFieldName('name'); + if (name?.type === 'identifier') return name.text; + } + } + return undefined; +} + +function extractTypeFromDecl(node: SyntaxNode): string | undefined { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'variable_declarator') { + return typeFromAnnotation(child); + } + } + return undefined; +} + +function extractVisFromDecl(node: SyntaxNode): VariableVisibility { + // Check parent for export_statement wrapper + const parent = node.parent; + if (parent?.type === 'export_statement') return 'public'; + // Check for 'export' keyword as direct child + if (hasKeyword(node, 'export')) return 'public'; + return 'private'; +} + +const shared: Omit = { + constNodeTypes: ['lexical_declaration'], + staticNodeTypes: [], + variableNodeTypes: ['variable_declaration'], + + extractName: extractNameFromDecl, + extractType: extractTypeFromDecl, + extractVisibility: extractVisFromDecl, + + isConst(node) { + // lexical_declaration with 'const' keyword + if (node.type === 'lexical_declaration') { + return hasKeyword(node, 'const'); + } + return false; + }, + + isStatic(_node) { + // JS/TS module-level variables are not static in the class sense + return false; + }, + + isMutable(node) { + // var or let declarations are mutable; const is not + if (node.type === 'variable_declaration') return true; + if (node.type === 'lexical_declaration') { + return hasKeyword(node, 'let'); + } + return false; + }, +}; + +export const typescriptVariableConfig: VariableExtractionConfig = { + ...shared, + language: SupportedLanguages.TypeScript, +}; + +export const javascriptVariableConfig: VariableExtractionConfig = { + ...shared, + language: SupportedLanguages.JavaScript, +}; diff --git a/gitnexus/src/core/ingestion/variable-extractors/generic.ts b/gitnexus/src/core/ingestion/variable-extractors/generic.ts new file mode 100644 index 000000000..1128805bd --- /dev/null +++ b/gitnexus/src/core/ingestion/variable-extractors/generic.ts @@ -0,0 +1,108 @@ +// gitnexus/src/core/ingestion/variable-extractors/generic.ts + +/** + * Generic table-driven variable extractor factory. + * + * Follows the same config+factory pattern as field-extractors/generic.ts. + * Define a VariableExtractionConfig per language and generate extractors + * from configs. The factory converts node type arrays to Sets at construction + * time for O(1) lookups. + */ + +import type { SyntaxNode } from '../utils/ast-helpers.js'; +import type { + VariableExtractionConfig, + VariableExtractor, + VariableExtractorContext, + VariableInfo, + VariableScope, +} from '../variable-types.js'; + +/** + * Create a VariableExtractor from a declarative config. + */ +export function createVariableExtractor(config: VariableExtractionConfig): VariableExtractor { + const staticNodeSet = new Set(config.staticNodeTypes); + // Combined set for fast isVariableDeclaration checks + const allNodeTypes = new Set([ + ...config.constNodeTypes, + ...config.staticNodeTypes, + ...config.variableNodeTypes, + ]); + + function determineScope(node: SyntaxNode): VariableScope { + // Walk up to determine scope: + // - 'module': node is inside a top-level program/module/source_file container + // - 'block': node is inside a function, method, or block scope + // - 'file': fallback when no recognizable container is found (e.g., standalone snippets) + let current = node.parent; + while (current) { + const t = current.type; + // Top-level program/module nodes indicate module/file scope + if ( + t === 'program' || + t === 'source_file' || + t === 'module' || + t === 'translation_unit' || + t === 'compilation_unit' + ) { + return 'module'; + } + // Function/method/block boundaries indicate block scope + if ( + t === 'function_declaration' || + t === 'function_definition' || + t === 'function_item' || + t === 'method_declaration' || + t === 'method_definition' || + t === 'arrow_function' || + t === 'function_expression' || + t === 'lambda' || + t === 'block' || + t === 'function_body' || + t === 'compound_statement' + ) { + return 'block'; + } + current = current.parent; + } + return 'file'; + } + + return { + language: config.language, + + isVariableDeclaration(node: SyntaxNode): boolean { + return allNodeTypes.has(node.type); + }, + + extract(node: SyntaxNode, context: VariableExtractorContext): VariableInfo | null { + if (!allNodeTypes.has(node.type)) return null; + + const name = config.extractName(node); + if (!name) return null; + + const type = config.extractType(node) ?? null; + const visibility = config.extractVisibility(node); + // isConst/isStatic: node type membership is a hint, but config.isConst/isStatic + // has final say. For languages where const and non-const share a node type + // (e.g., TS lexical_declaration for both const and let), config.isConst disambiguates. + const isConst = config.isConst(node); + const isStatic = staticNodeSet.has(node.type) || config.isStatic(node); + const isMutable = config.isMutable(node); + const scope = determineScope(node); + + return { + name, + type, + visibility, + isConst, + isStatic, + isMutable, + scope, + sourceFile: context.filePath, + line: node.startPosition.row + 1, + }; + }, + }; +} diff --git a/gitnexus/src/core/ingestion/variable-types.ts b/gitnexus/src/core/ingestion/variable-types.ts new file mode 100644 index 000000000..a2006080b --- /dev/null +++ b/gitnexus/src/core/ingestion/variable-types.ts @@ -0,0 +1,91 @@ +// gitnexus/src/core/ingestion/variable-types.ts + +import type { SupportedLanguages } from 'gitnexus-shared'; +import type { FieldVisibility } from './field-types.js'; +import type { SyntaxNode } from './utils/ast-helpers.js'; + +// Reuse FieldVisibility — same set of language visibility levels +export type VariableVisibility = FieldVisibility; + +/** + * Scope level for a variable declaration. + * - 'module': module/package scope (TypeScript `export const`, Go package-level) + * - 'file': file scope (C/C++ static file-scope, Python module-level) + * - 'block': block-scoped (JS `let`/`const` inside a function) + */ +export type VariableScope = 'module' | 'file' | 'block'; + +/** + * Represents a module/file-scoped variable, constant, or static declaration. + */ +export interface VariableInfo { + /** Variable name */ + name: string; + /** Declared type annotation (may be null if untyped) */ + type: string | null; + /** Visibility modifier */ + visibility: VariableVisibility; + /** Is this a constant (const, val, final)? */ + isConst: boolean; + /** Is this a static declaration? */ + isStatic: boolean; + /** Is this mutable (let, var vs const, val)? */ + isMutable: boolean; + /** Scope of the declaration */ + scope: VariableScope; + /** Source file path */ + sourceFile: string; + /** Line number (1-based) */ + line: number; +} + +/** + * Context for variable extraction. + */ +export interface VariableExtractorContext { + /** Current file path */ + filePath: string; + /** Language ID */ + language: SupportedLanguages; +} + +/** + * Variable extractor interface — extracts structured metadata from + * module/file-scoped variable, constant, and static declarations. + */ +export interface VariableExtractor { + /** Language this extractor handles */ + language: SupportedLanguages; + /** Extract variable metadata from a declaration node. + * Returns null if the node is not a recognized variable declaration. */ + extract(node: SyntaxNode, context: VariableExtractorContext): VariableInfo | null; + /** Check if a node is a recognized variable declaration type. */ + isVariableDeclaration(node: SyntaxNode): boolean; +} + +/** + * Declarative config for building a variable extractor via the factory. + * Follows the same pattern as FieldExtractionConfig and MethodExtractionConfig. + */ +export interface VariableExtractionConfig { + /** Language this config applies to */ + language: SupportedLanguages; + /** AST node types for const declarations (e.g., 'const_item', 'lexical_declaration') */ + constNodeTypes: string[]; + /** AST node types for static declarations (e.g., 'static_item') */ + staticNodeTypes: string[]; + /** AST node types for variable declarations (e.g., 'variable_declaration') */ + variableNodeTypes: string[]; + /** Extract the variable name from a declaration node */ + extractName: (node: SyntaxNode) => string | undefined; + /** Extract type annotation from a declaration node */ + extractType: (node: SyntaxNode) => string | undefined; + /** Extract visibility from a declaration node */ + extractVisibility: (node: SyntaxNode) => VariableVisibility; + /** Check if a declaration is const/immutable */ + isConst: (node: SyntaxNode) => boolean; + /** Check if a declaration is static */ + isStatic: (node: SyntaxNode) => boolean; + /** Check if a declaration is mutable */ + isMutable: (node: SyntaxNode) => boolean; +} diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index dc5f6a62d..130f489ac 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -68,6 +68,7 @@ import type { NamedBinding } from '../named-bindings/types.js'; import type { NodeLabel } from 'gitnexus-shared'; import type { FieldInfo, FieldExtractorContext } from '../field-types.js'; import type { MethodInfo, MethodExtractorContext } from '../method-types.js'; +import type { VariableExtractorContext } from '../variable-types.js'; import { buildMethodProps, arityForIdFromInfo, @@ -1462,6 +1463,11 @@ const processFileGroup = ( // Per-file map: decorator end-line → decorator info, for associating with definitions const fileDecorators = new Map(); + // Track start indices of definition nodes already processed by higher-priority captures + // (e.g. @definition.function) to avoid duplicate nodes when @definition.const/@definition.variable + // patterns overlap with the same source range. + const processedDefinitionNodes = new Set(); + for (const match of matches) { const captureMap: Record = {}; for (const c of match.captures) { @@ -1929,6 +1935,21 @@ const processFileGroup = ( }) : null; const nodeLabel = extractedClassSymbol?.type ?? defaultNodeLabel; + + // Dedup: variable captures (Const/Static/Variable) may overlap with higher-priority + // captures (e.g. `const fn = () => {}` matches both @definition.function and @definition.const). + // Skip variable captures whose definition node was already processed. + if ( + (nodeLabel === 'Const' || nodeLabel === 'Static' || nodeLabel === 'Variable') && + definitionNode && + processedDefinitionNodes.has(definitionNode.startIndex) + ) { + continue; + } + if (definitionNode) { + processedDefinitionNodes.add(definitionNode.startIndex); + } + // Synthesize name for constructors without explicit @name capture (e.g. Swift init) if (!nameNode && nodeLabel !== 'Constructor' && !extractedClassSymbol) continue; const nodeName = extractedClassSymbol?.name ?? (nameNode ? nameNode.text : 'init'); @@ -2111,6 +2132,27 @@ const processFileGroup = ( } } + // Variable/Const/Static metadata extraction via VariableExtractor + if ( + (nodeLabel === 'Const' || nodeLabel === 'Static' || nodeLabel === 'Variable') && + definitionNode && + provider.variableExtractor + ) { + const varCtx: VariableExtractorContext = { + filePath: file.path, + language, + }; + const varInfo = provider.variableExtractor.extract(definitionNode, varCtx); + if (varInfo) { + if (varInfo.type) declaredType = varInfo.type; + methodProps.visibility = varInfo.visibility; + methodProps.isStatic = varInfo.isStatic; + methodProps.isConst = varInfo.isConst; + methodProps.isMutable = varInfo.isMutable; + methodProps.scope = varInfo.scope; + } + } + result.nodes.push({ id: nodeId, label: nodeLabel, diff --git a/gitnexus/src/core/lbug/csv-generator.ts b/gitnexus/src/core/lbug/csv-generator.ts index 63a1bb947..616a00cac 100644 --- a/gitnexus/src/core/lbug/csv-generator.ts +++ b/gitnexus/src/core/lbug/csv-generator.ts @@ -292,6 +292,7 @@ export const streamAllCSVsToDisk = async ( 'TypeAlias', 'Const', 'Static', + 'Variable', 'Property', 'Record', 'Delegate', diff --git a/gitnexus/src/core/lbug/schema.ts b/gitnexus/src/core/lbug/schema.ts index c0ba10d4d..297fe44ce 100644 --- a/gitnexus/src/core/lbug/schema.ts +++ b/gitnexus/src/core/lbug/schema.ts @@ -166,6 +166,7 @@ export const IMPL_SCHEMA = CODE_ELEMENT_BASE('Impl'); export const TYPE_ALIAS_SCHEMA = CODE_ELEMENT_BASE('TypeAlias'); export const CONST_SCHEMA = CODE_ELEMENT_BASE('Const'); export const STATIC_SCHEMA = CODE_ELEMENT_BASE('Static'); +export const VARIABLE_SCHEMA = CODE_ELEMENT_BASE('Variable'); export const PROPERTY_SCHEMA = CODE_ELEMENT_BASE('Property'); export const RECORD_SCHEMA = CODE_ELEMENT_BASE('Record'); export const DELEGATE_SCHEMA = CODE_ELEMENT_BASE('Delegate'); @@ -234,6 +235,7 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( FROM File TO \`TypeAlias\`, FROM File TO \`Const\`, FROM File TO \`Static\`, + FROM File TO \`Variable\`, FROM File TO \`Property\`, FROM File TO \`Record\`, FROM File TO \`Delegate\`, @@ -365,6 +367,7 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( FROM \`TypeAlias\` TO Class, FROM \`Const\` TO Community, FROM \`Static\` TO Community, + FROM \`Variable\` TO Community, FROM \`Property\` TO Community, FROM \`Record\` TO Method, FROM \`Record\` TO \`Constructor\`, @@ -408,6 +411,7 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( FROM \`Trait\` TO Process, FROM \`Const\` TO Process, FROM \`Static\` TO Process, + FROM \`Variable\` TO Process, FROM \`Property\` TO Process, FROM \`Record\` TO Process, FROM \`Delegate\` TO Process, @@ -488,6 +492,7 @@ export const NODE_SCHEMA_QUERIES = [ TYPE_ALIAS_SCHEMA, CONST_SCHEMA, STATIC_SCHEMA, + VARIABLE_SCHEMA, PROPERTY_SCHEMA, RECORD_SCHEMA, DELEGATE_SCHEMA, diff --git a/gitnexus/test/fixtures/pipeline-golden/mini-repo/expected-graph.json b/gitnexus/test/fixtures/pipeline-golden/mini-repo/expected-graph.json index 7c9818696..0d66fd3a7 100644 --- a/gitnexus/test/fixtures/pipeline-golden/mini-repo/expected-graph.json +++ b/gitnexus/test/fixtures/pipeline-golden/mini-repo/expected-graph.json @@ -1,29 +1,29 @@ { "capture": "initial capture (U8, post-U1–U7)", "fixture": "mini-repo", - "totalFileCount": 9, - "symbols": 57, - "relationships": 92, + "totalFileCount": 7, + "symbols": 37, + "relationships": 74, "processes": 4, "byType": { "Class": 1, "Community": 4, - "File": 9, + "Const": 4, + "File": 7, "Folder": 1, "Function": 12, "Interface": 3, "Method": 1, - "Process": 4, - "Section": 22 + "Process": 4 }, "byRelType": { "CALLS": 9, - "CONTAINS": 29, - "DEFINES": 17, + "CONTAINS": 7, + "DEFINES": 21, "HAS_METHOD": 1, "IMPORTS": 12, "MEMBER_OF": 12, "STEP_IN_PROCESS": 12 }, - "edgeDigest": "fdf012d4b4197377ab43e27217f9fe08ec46352945140f8b3406dacc3294b715" + "edgeDigest": "a418debec537cf959fe56fd1fbbbfb59a640398cdb3c61ce0bcb8056c1f45110" } diff --git a/gitnexus/test/integration/cli-e2e.test.ts b/gitnexus/test/integration/cli-e2e.test.ts index 678d8f271..7ee6f7a4e 100644 --- a/gitnexus/test/integration/cli-e2e.test.ts +++ b/gitnexus/test/integration/cli-e2e.test.ts @@ -52,9 +52,11 @@ beforeAll(() => { }); afterAll(() => { - // Clean up .git/ and .gitnexus/ directories created during the test - for (const dir of ['.git', '.gitnexus']) { - const fullPath = path.join(MINI_REPO, dir); + // Clean up all files/dirs created by analyze (git init, .gitnexus output, + // AI context files, skill files, .gitignore) so parallel tests like + // pipeline-graph-golden see a pristine fixture. + for (const entry of ['.git', '.gitnexus', '.claude', 'AGENTS.md', 'CLAUDE.md', '.gitignore']) { + const fullPath = path.join(MINI_REPO, entry); if (fs.existsSync(fullPath)) { fs.rmSync(fullPath, { recursive: true, force: true }); } diff --git a/gitnexus/test/integration/pipeline-graph-golden.test.ts b/gitnexus/test/integration/pipeline-graph-golden.test.ts index f008bdadb..1a8e9d238 100644 --- a/gitnexus/test/integration/pipeline-graph-golden.test.ts +++ b/gitnexus/test/integration/pipeline-graph-golden.test.ts @@ -18,14 +18,15 @@ * * Nothing path-dependent, time-dependent, or id-opaque leaks into the snapshot. */ -import { describe, it, expect, beforeAll } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import path from 'path'; import fs from 'fs'; +import os from 'os'; import crypto from 'crypto'; import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; import type { PipelineResult } from '../../src/types/pipeline.js'; -const MINI_REPO = path.resolve(__dirname, '..', 'fixtures', 'mini-repo'); +const FIXTURE_SRC = path.resolve(__dirname, '..', 'fixtures', 'mini-repo'); const GOLDEN_DIR = path.resolve(__dirname, '..', 'fixtures', 'pipeline-golden', 'mini-repo'); const GOLDEN_FILE = path.join(GOLDEN_DIR, 'expected-graph.json'); @@ -128,12 +129,23 @@ function diffCounts( describe('pipeline graph golden', () => { let result: PipelineResult; let snapshot: GraphSnapshot; + let tmpDir: string; beforeAll(async () => { - result = await runPipelineFromRepo(MINI_REPO, () => {}); + // Copy the fixture to a temp directory so parallel tests (cli-e2e) + // that create AGENTS.md / CLAUDE.md / .claude/ in the shared fixture + // don't pollute the golden snapshot. + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-golden-')); + fs.cpSync(FIXTURE_SRC, tmpDir, { recursive: true }); + + result = await runPipelineFromRepo(tmpDir, () => {}); snapshot = buildSnapshot(result); }, 60000); + afterAll(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + it('matches committed golden snapshot on mini-repo', () => { if (UPDATE || !fs.existsSync(GOLDEN_FILE)) { fs.mkdirSync(GOLDEN_DIR, { recursive: true }); diff --git a/gitnexus/test/unit/schema.test.ts b/gitnexus/test/unit/schema.test.ts index 78f0b6ccb..cab80984a 100644 --- a/gitnexus/test/unit/schema.test.ts +++ b/gitnexus/test/unit/schema.test.ts @@ -53,6 +53,7 @@ describe('LadybugDB Schema', () => { 'TypeAlias', 'Const', 'Static', + 'Variable', 'Property', 'Record', 'Delegate', @@ -67,8 +68,8 @@ describe('LadybugDB Schema', () => { }); it('has expected total count', () => { - // 9 core + 18 multi-language + Route + Tool = 30 - expect(NODE_TABLES).toHaveLength(30); + // 9 core + 19 multi-language + Route + Tool = 31 + expect(NODE_TABLES).toHaveLength(31); }); }); @@ -201,7 +202,7 @@ describe('LadybugDB Schema', () => { describe('schema query ordering', () => { it('NODE_SCHEMA_QUERIES has correct count', () => { - expect(NODE_SCHEMA_QUERIES).toHaveLength(30); + expect(NODE_SCHEMA_QUERIES).toHaveLength(31); }); it('REL_SCHEMA_QUERIES has one relation table', () => { @@ -209,8 +210,8 @@ describe('LadybugDB Schema', () => { }); it('SCHEMA_QUERIES includes all node + rel + embedding schemas', () => { - // 30 node + 1 rel + 1 embedding = 32 - expect(SCHEMA_QUERIES).toHaveLength(32); + // 31 node + 1 rel + 1 embedding = 33 + expect(SCHEMA_QUERIES).toHaveLength(33); }); it('node schemas come before relation schemas in SCHEMA_QUERIES', () => { diff --git a/gitnexus/test/unit/tree-sitter-queries.test.ts b/gitnexus/test/unit/tree-sitter-queries.test.ts index ffb207bdd..64212c25c 100644 --- a/gitnexus/test/unit/tree-sitter-queries.test.ts +++ b/gitnexus/test/unit/tree-sitter-queries.test.ts @@ -10,6 +10,7 @@ import { CSHARP_QUERIES, RUST_QUERIES, PHP_QUERIES, + RUBY_QUERIES, SWIFT_QUERIES, DART_QUERIES, } from '../../src/core/ingestion/tree-sitter-queries.js'; @@ -356,4 +357,77 @@ describe('tree-sitter queries', () => { expect(DART_QUERIES).toContain('function_expression_body'); }); }); + + // --------------------------------------------------------------------------- + // Variable/constant declaration capture tests + // --------------------------------------------------------------------------- + + describe('Variable/constant declaration captures', () => { + it('TypeScript captures const/let as @definition.const', () => { + expect(TYPESCRIPT_QUERIES).toContain('@definition.const'); + expect(TYPESCRIPT_QUERIES).toContain('lexical_declaration'); + }); + + it('TypeScript captures var as @definition.variable', () => { + expect(TYPESCRIPT_QUERIES).toContain('@definition.variable'); + expect(TYPESCRIPT_QUERIES).toContain('variable_declaration'); + }); + + it('JavaScript captures const/let as @definition.const', () => { + expect(JAVASCRIPT_QUERIES).toContain('@definition.const'); + }); + + it('JavaScript captures var as @definition.variable', () => { + expect(JAVASCRIPT_QUERIES).toContain('@definition.variable'); + }); + + it('Python captures plain assignments as @definition.variable', () => { + expect(PYTHON_QUERIES).toContain('@definition.variable'); + }); + + it('Go captures const_declaration and var_declaration', () => { + expect(GO_QUERIES).toContain('@definition.const'); + expect(GO_QUERIES).toContain('@definition.variable'); + expect(GO_QUERIES).toContain('short_var_declaration'); + }); + + it('Java captures local_variable_declaration', () => { + expect(JAVA_QUERIES).toContain('local_variable_declaration'); + expect(JAVA_QUERIES).toContain('@definition.variable'); + }); + + it('C captures init_declarator as @definition.variable', () => { + expect(C_QUERIES).toContain('init_declarator'); + expect(C_QUERIES).toContain('@definition.variable'); + }); + + it('C++ captures init_declarator as @definition.variable', () => { + expect(CPP_QUERIES).toContain('init_declarator'); + expect(CPP_QUERIES).toContain('@definition.variable'); + }); + + it('C# captures local_declaration_statement', () => { + expect(CSHARP_QUERIES).toContain('local_declaration_statement'); + expect(CSHARP_QUERIES).toContain('@definition.variable'); + }); + + it('Rust retains const_item and static_item captures', () => { + expect(RUST_QUERIES).toContain('@definition.const'); + expect(RUST_QUERIES).toContain('@definition.static'); + }); + + it('PHP captures const_declaration', () => { + expect(PHP_QUERIES).toContain('const_declaration'); + expect(PHP_QUERIES).toContain('@definition.const'); + }); + + it('Ruby captures constant assignments', () => { + expect(RUBY_QUERIES).toContain('@definition.const'); + }); + + it('Dart captures declaration as @definition.variable', () => { + expect(DART_QUERIES).toContain('(declaration'); + expect(DART_QUERIES).toContain('@definition.variable'); + }); + }); }); diff --git a/gitnexus/test/unit/variable-extraction.test.ts b/gitnexus/test/unit/variable-extraction.test.ts new file mode 100644 index 000000000..d40993e2c --- /dev/null +++ b/gitnexus/test/unit/variable-extraction.test.ts @@ -0,0 +1,633 @@ +import { describe, it, expect } from 'vitest'; +import { createVariableExtractor } from '../../src/core/ingestion/variable-extractors/generic.js'; +import { + typescriptVariableConfig, + javascriptVariableConfig, +} from '../../src/core/ingestion/variable-extractors/configs/typescript-javascript.js'; +import { pythonVariableConfig } from '../../src/core/ingestion/variable-extractors/configs/python.js'; +import { goVariableConfig } from '../../src/core/ingestion/variable-extractors/configs/go.js'; +import { rustVariableConfig } from '../../src/core/ingestion/variable-extractors/configs/rust.js'; +import { + cVariableConfig, + cppVariableConfig, +} from '../../src/core/ingestion/variable-extractors/configs/c-cpp.js'; +import { rubyVariableConfig } from '../../src/core/ingestion/variable-extractors/configs/ruby.js'; +import type { VariableExtractorContext } from '../../src/core/ingestion/variable-types.js'; +import { SupportedLanguages } from '../../src/config/supported-languages.js'; +import Parser from 'tree-sitter'; +import TypeScript from 'tree-sitter-typescript'; +import Python from 'tree-sitter-python'; +import Go from 'tree-sitter-go'; +import Rust from 'tree-sitter-rust'; +import Cpp from 'tree-sitter-cpp'; +import C from 'tree-sitter-c'; +import Ruby from 'tree-sitter-ruby'; + +const parser = new Parser(); + +// --------------------------------------------------------------------------- +// TypeScript config +// --------------------------------------------------------------------------- + +describe('VariableExtractor — TypeScript', () => { + const extractor = createVariableExtractor(typescriptVariableConfig); + const ctx: VariableExtractorContext = { + filePath: 'test.ts', + language: SupportedLanguages.TypeScript, + }; + + it('extracts const declaration', () => { + parser.setLanguage(TypeScript.typescript); + const tree = parser.parse('const MAX_SIZE = 100;'); + const node = tree.rootNode.child(0)!; + expect(extractor.isVariableDeclaration(node)).toBe(true); + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('MAX_SIZE'); + expect(info!.isConst).toBe(true); + expect(info!.isMutable).toBe(false); + expect(info!.visibility).toBe('private'); + }); + + it('extracts let declaration as mutable', () => { + parser.setLanguage(TypeScript.typescript); + const tree = parser.parse('let counter = 0;'); + const node = tree.rootNode.child(0)!; + expect(extractor.isVariableDeclaration(node)).toBe(true); + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('counter'); + expect(info!.isConst).toBe(false); + expect(info!.isMutable).toBe(true); + }); + + it('extracts typed const declaration', () => { + parser.setLanguage(TypeScript.typescript); + const tree = parser.parse('const name: string = "hello";'); + const node = tree.rootNode.child(0)!; + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('name'); + expect(info!.type).toBe('string'); + expect(info!.isConst).toBe(true); + }); + + it('detects export as public visibility', () => { + parser.setLanguage(TypeScript.typescript); + const tree = parser.parse('export const API_KEY = "abc";'); + // export_statement wraps lexical_declaration + const exportStatement = tree.rootNode.child(0)!; + // The lexical_declaration is the child of export_statement + const declNode = exportStatement.namedChildren.find((c) => c.type === 'lexical_declaration'); + expect(declNode).toBeDefined(); + const info = extractor.extract(declNode!, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('API_KEY'); + expect(info!.visibility).toBe('public'); + }); + + it('rejects non-variable nodes', () => { + parser.setLanguage(TypeScript.typescript); + const tree = parser.parse('function foo() {}'); + const node = tree.rootNode.child(0)!; + expect(extractor.isVariableDeclaration(node)).toBe(false); + expect(extractor.extract(node, ctx)).toBeNull(); + }); + + it('extracts var declaration as mutable variable', () => { + parser.setLanguage(TypeScript.typescript); + const tree = parser.parse('var x = 5;'); + const node = tree.rootNode.child(0)!; + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('x'); + expect(info!.isMutable).toBe(true); + expect(info!.isConst).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// JavaScript config +// --------------------------------------------------------------------------- + +describe('VariableExtractor — JavaScript', () => { + const extractor = createVariableExtractor(javascriptVariableConfig); + const ctx: VariableExtractorContext = { + filePath: 'test.js', + language: SupportedLanguages.JavaScript, + }; + + it('extracts const declaration', () => { + parser.setLanguage(TypeScript.typescript); // JS subset of TS grammar + const tree = parser.parse('const PORT = 3000;'); + const node = tree.rootNode.child(0)!; + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('PORT'); + expect(info!.isConst).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Python config +// --------------------------------------------------------------------------- + +describe('VariableExtractor — Python', () => { + const extractor = createVariableExtractor(pythonVariableConfig); + const ctx: VariableExtractorContext = { + filePath: 'test.py', + language: SupportedLanguages.Python, + }; + + it('extracts UPPER_CASE constant', () => { + parser.setLanguage(Python); + const tree = parser.parse('MAX_SIZE = 100'); + const node = tree.rootNode.child(0)!; + expect(extractor.isVariableDeclaration(node)).toBe(true); + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('MAX_SIZE'); + expect(info!.isConst).toBe(true); + expect(info!.isMutable).toBe(false); + expect(info!.visibility).toBe('public'); + }); + + it('extracts regular assignment as mutable', () => { + parser.setLanguage(Python); + const tree = parser.parse('counter = 0'); + const node = tree.rootNode.child(0)!; + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('counter'); + expect(info!.isConst).toBe(false); + expect(info!.isMutable).toBe(true); + }); + + it('extracts annotated assignment with type', () => { + parser.setLanguage(Python); + const tree = parser.parse('name: str = "hello"'); + const node = tree.rootNode.child(0)!; + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('name'); + expect(info!.type).toBe('str'); + }); + + it('detects underscore prefix as protected', () => { + parser.setLanguage(Python); + const tree = parser.parse('_internal = 42'); + const node = tree.rootNode.child(0)!; + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.visibility).toBe('protected'); + }); + + it('detects double underscore prefix as private', () => { + parser.setLanguage(Python); + const tree = parser.parse('__secret = 42'); + const node = tree.rootNode.child(0)!; + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.visibility).toBe('private'); + }); + + it('does not treat dunder names as private', () => { + parser.setLanguage(Python); + const tree = parser.parse('__name__ = "main"'); + const node = tree.rootNode.child(0)!; + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.visibility).toBe('public'); + }); +}); + +// --------------------------------------------------------------------------- +// Go config +// --------------------------------------------------------------------------- + +describe('VariableExtractor — Go', () => { + const extractor = createVariableExtractor(goVariableConfig); + const ctx: VariableExtractorContext = { + filePath: 'test.go', + language: SupportedLanguages.Go, + }; + + it('extracts const declaration', () => { + parser.setLanguage(Go); + const tree = parser.parse('package main\nconst MaxSize = 100'); + // Find const_declaration + let constNode = null; + for (let i = 0; i < tree.rootNode.namedChildCount; i++) { + const child = tree.rootNode.namedChild(i); + if (child?.type === 'const_declaration') { + constNode = child; + break; + } + } + expect(constNode).not.toBeNull(); + expect(extractor.isVariableDeclaration(constNode!)).toBe(true); + + const info = extractor.extract(constNode!, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('MaxSize'); + expect(info!.isConst).toBe(true); + expect(info!.isMutable).toBe(false); + expect(info!.visibility).toBe('public'); // uppercase = exported + }); + + it('extracts var declaration', () => { + parser.setLanguage(Go); + const tree = parser.parse('package main\nvar counter int = 0'); + let varNode = null; + for (let i = 0; i < tree.rootNode.namedChildCount; i++) { + const child = tree.rootNode.namedChild(i); + if (child?.type === 'var_declaration') { + varNode = child; + break; + } + } + expect(varNode).not.toBeNull(); + + const info = extractor.extract(varNode!, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('counter'); + expect(info!.isConst).toBe(false); + expect(info!.isMutable).toBe(true); + expect(info!.type).toBe('int'); + }); + + it('detects lowercase as package-private', () => { + parser.setLanguage(Go); + const tree = parser.parse('package main\nconst maxSize = 100'); + let constNode = null; + for (let i = 0; i < tree.rootNode.namedChildCount; i++) { + const child = tree.rootNode.namedChild(i); + if (child?.type === 'const_declaration') { + constNode = child; + break; + } + } + expect(constNode).not.toBeNull(); + + const info = extractor.extract(constNode!, ctx); + expect(info).not.toBeNull(); + expect(info!.visibility).toBe('package'); + }); +}); + +// --------------------------------------------------------------------------- +// Rust config +// --------------------------------------------------------------------------- + +describe('VariableExtractor — Rust', () => { + const extractor = createVariableExtractor(rustVariableConfig); + const ctx: VariableExtractorContext = { + filePath: 'test.rs', + language: SupportedLanguages.Rust, + }; + + it('extracts const_item', () => { + parser.setLanguage(Rust); + const tree = parser.parse('const MAX_SIZE: usize = 100;'); + const node = tree.rootNode.child(0)!; + expect(extractor.isVariableDeclaration(node)).toBe(true); + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('MAX_SIZE'); + expect(info!.isConst).toBe(true); + expect(info!.isStatic).toBe(false); + expect(info!.isMutable).toBe(false); + expect(info!.type).toBe('usize'); + expect(info!.visibility).toBe('private'); + }); + + it('extracts static_item', () => { + parser.setLanguage(Rust); + const tree = parser.parse('static COUNTER: i32 = 0;'); + const node = tree.rootNode.child(0)!; + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('COUNTER'); + expect(info!.isStatic).toBe(true); + expect(info!.isConst).toBe(false); + expect(info!.isMutable).toBe(false); + }); + + it('extracts pub const as public', () => { + parser.setLanguage(Rust); + const tree = parser.parse('pub const API_VERSION: &str = "v1";'); + const node = tree.rootNode.child(0)!; + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.visibility).toBe('public'); + expect(info!.isConst).toBe(true); + }); + + it('extracts static mut as mutable', () => { + parser.setLanguage(Rust); + const tree = parser.parse('static mut BUFFER: Vec = Vec::new();'); + const node = tree.rootNode.child(0)!; + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.isStatic).toBe(true); + expect(info!.isMutable).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// C/C++ config +// --------------------------------------------------------------------------- + +describe('VariableExtractor — C', () => { + const extractor = createVariableExtractor(cVariableConfig); + const ctx: VariableExtractorContext = { + filePath: 'test.c', + language: SupportedLanguages.C, + }; + + it('extracts const declaration', () => { + parser.setLanguage(C); + const tree = parser.parse('const int MAX_SIZE = 100;'); + const node = tree.rootNode.child(0)!; + expect(extractor.isVariableDeclaration(node)).toBe(true); + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('MAX_SIZE'); + expect(info!.isConst).toBe(true); + expect(info!.isMutable).toBe(false); + }); + + it('extracts static variable', () => { + parser.setLanguage(C); + const tree = parser.parse('static int counter = 0;'); + const node = tree.rootNode.child(0)!; + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('counter'); + expect(info!.isStatic).toBe(true); + expect(info!.visibility).toBe('private'); // static = file-private + }); +}); + +describe('VariableExtractor — C++', () => { + const extractor = createVariableExtractor(cppVariableConfig); + const ctx: VariableExtractorContext = { + filePath: 'test.cpp', + language: SupportedLanguages.CPlusPlus, + }; + + it('extracts constexpr declaration', () => { + parser.setLanguage(Cpp); + const tree = parser.parse('constexpr int SIZE = 10;'); + const node = tree.rootNode.child(0)!; + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('SIZE'); + expect(info!.isConst).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Ruby config +// --------------------------------------------------------------------------- + +describe('VariableExtractor — Ruby', () => { + const extractor = createVariableExtractor(rubyVariableConfig); + const ctx: VariableExtractorContext = { + filePath: 'test.rb', + language: SupportedLanguages.Ruby, + }; + + it('extracts Ruby constant assignment', () => { + parser.setLanguage(Ruby); + const tree = parser.parse('MAX_SIZE = 100'); + const node = tree.rootNode.child(0)!; + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('MAX_SIZE'); + expect(info!.isConst).toBe(true); + expect(info!.isMutable).toBe(false); + expect(info!.visibility).toBe('public'); + }); + + it('extracts regular variable assignment', () => { + parser.setLanguage(Ruby); + const tree = parser.parse('counter = 0'); + const node = tree.rootNode.child(0)!; + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('counter'); + expect(info!.isConst).toBe(false); + expect(info!.isMutable).toBe(true); + expect(info!.visibility).toBe('private'); + }); +}); + +// --------------------------------------------------------------------------- +// Factory generic tests +// --------------------------------------------------------------------------- + +describe('createVariableExtractor — factory', () => { + const factoryCtx: VariableExtractorContext = { + filePath: 'test.ts', + language: SupportedLanguages.TypeScript, + }; + + it('creates extractor with correct language', () => { + const extractor = createVariableExtractor(typescriptVariableConfig); + expect(extractor.language).toBe(SupportedLanguages.TypeScript); + }); + + it('returns null for non-variable nodes', () => { + const extractor = createVariableExtractor(typescriptVariableConfig); + parser.setLanguage(TypeScript.typescript); + const tree = parser.parse('class Foo {}'); + const node = tree.rootNode.child(0)!; + expect(extractor.extract(node, factoryCtx)).toBeNull(); + }); + + it('line number is 1-based', () => { + const extractor = createVariableExtractor(typescriptVariableConfig); + parser.setLanguage(TypeScript.typescript); + const tree = parser.parse('const x = 1;'); + const node = tree.rootNode.child(0)!; + const info = extractor.extract(node, factoryCtx); + expect(info).not.toBeNull(); + expect(info!.line).toBe(1); + expect(info!.sourceFile).toBe('test.ts'); + }); + + it('sets scope to module for top-level declarations', () => { + const extractor = createVariableExtractor(typescriptVariableConfig); + parser.setLanguage(TypeScript.typescript); + const tree = parser.parse('const x = 1;'); + const node = tree.rootNode.child(0)!; + const info = extractor.extract(node, factoryCtx); + expect(info).not.toBeNull(); + // rootNode is 'program' → module scope + expect(info!.scope).toBe('module'); + }); +}); + +// --------------------------------------------------------------------------- +// Block-scoped variable extraction tests +// --------------------------------------------------------------------------- + +describe('VariableExtractor — block-scoped declarations', () => { + it('TypeScript: detects block scope for const inside function', () => { + const extractor = createVariableExtractor(typescriptVariableConfig); + const ctx: VariableExtractorContext = { + filePath: 'test.ts', + language: SupportedLanguages.TypeScript, + }; + parser.setLanguage(TypeScript.typescript); + const tree = parser.parse('function foo() { const x = 5; }'); + // program > function_declaration > statement_block > lexical_declaration + const fnBody = tree.rootNode.child(0)!.childForFieldName('body')!; + const constDecl = fnBody.namedChildren.find((c) => c.type === 'lexical_declaration'); + expect(constDecl).toBeDefined(); + const info = extractor.extract(constDecl!, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('x'); + expect(info!.scope).toBe('block'); + expect(info!.isConst).toBe(true); + expect(info!.isMutable).toBe(false); + }); + + it('TypeScript: detects block scope for let inside arrow function', () => { + const extractor = createVariableExtractor(typescriptVariableConfig); + const ctx: VariableExtractorContext = { + filePath: 'test.ts', + language: SupportedLanguages.TypeScript, + }; + parser.setLanguage(TypeScript.typescript); + const tree = parser.parse('const fn = () => { let y = 10; };'); + // program > lexical_declaration > variable_declarator > arrow_function > statement_block + const lexDecl = tree.rootNode.child(0)!; + const varDeclarator = lexDecl.namedChildren.find((c) => c.type === 'variable_declarator')!; + const arrowFn = varDeclarator.childForFieldName('value')!; + const body = arrowFn.childForFieldName('body')!; + const letDecl = body.namedChildren.find((c) => c.type === 'lexical_declaration'); + expect(letDecl).toBeDefined(); + const info = extractor.extract(letDecl!, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('y'); + expect(info!.scope).toBe('block'); + expect(info!.isMutable).toBe(true); + expect(info!.isConst).toBe(false); + }); + + it('Go: detects block scope for short var declaration inside function', () => { + const extractor = createVariableExtractor(goVariableConfig); + const ctx: VariableExtractorContext = { + filePath: 'test.go', + language: SupportedLanguages.Go, + }; + parser.setLanguage(Go); + const tree = parser.parse('package main\nfunc foo() { x := 5 }'); + // source_file > function_declaration > block > short_var_declaration + const funcDecl = tree.rootNode.namedChildren.find((c) => c.type === 'function_declaration')!; + const body = funcDecl.childForFieldName('body')!; + const shortVarDecl = body.namedChildren.find((c) => c.type === 'short_var_declaration'); + expect(shortVarDecl).toBeDefined(); + const info = extractor.extract(shortVarDecl!, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('x'); + expect(info!.scope).toBe('block'); + expect(info!.isMutable).toBe(true); + }); + + it('Rust: detects block scope for let inside function', () => { + const extractor = createVariableExtractor(rustVariableConfig); + const ctx: VariableExtractorContext = { + filePath: 'test.rs', + language: SupportedLanguages.Rust, + }; + parser.setLanguage(Rust); + const tree = parser.parse('fn foo() { let mut x = 5; }'); + // source_file > function_item > block > let_declaration + const funcItem = tree.rootNode.namedChildren.find((c) => c.type === 'function_item')!; + const body = funcItem.childForFieldName('body')!; + const letDecl = body.namedChildren.find((c) => c.type === 'let_declaration'); + expect(letDecl).toBeDefined(); + const info = extractor.extract(letDecl!, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('x'); + expect(info!.scope).toBe('block'); + expect(info!.isMutable).toBe(true); + }); + + it('C: detects block scope for declaration inside function', () => { + const extractor = createVariableExtractor(cVariableConfig); + const ctx: VariableExtractorContext = { + filePath: 'test.c', + language: SupportedLanguages.C, + }; + parser.setLanguage(C); + const tree = parser.parse('void foo() { int x = 5; }'); + // translation_unit > function_definition > compound_statement > declaration + const funcDef = tree.rootNode.namedChildren.find((c) => c.type === 'function_definition')!; + const body = funcDef.childForFieldName('body')!; + const decl = body.namedChildren.find((c) => c.type === 'declaration'); + expect(decl).toBeDefined(); + const info = extractor.extract(decl!, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('x'); + expect(info!.scope).toBe('block'); + expect(info!.isMutable).toBe(true); + }); + + it('Python: detects block scope for assignment inside function', () => { + const extractor = createVariableExtractor(pythonVariableConfig); + const ctx: VariableExtractorContext = { + filePath: 'test.py', + language: SupportedLanguages.Python, + }; + parser.setLanguage(Python); + const tree = parser.parse('def foo():\n x = 5'); + // module > function_definition > block > expression_statement > assignment + const funcDef = tree.rootNode.namedChildren.find((c) => c.type === 'function_definition')!; + const body = funcDef.childForFieldName('body')!; + const exprStmt = body.namedChildren.find((c) => c.type === 'expression_statement'); + expect(exprStmt).toBeDefined(); + const info = extractor.extract(exprStmt!, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('x'); + expect(info!.scope).toBe('block'); + }); + + it('Python: rejects non-assignment expression statements (e.g. function calls)', () => { + const extractor = createVariableExtractor(pythonVariableConfig); + const ctx: VariableExtractorContext = { + filePath: 'test.py', + language: SupportedLanguages.Python, + }; + parser.setLanguage(Python); + const tree = parser.parse('print("hello")'); + const exprStmt = tree.rootNode.child(0)!; + expect(exprStmt.type).toBe('expression_statement'); + // extract() should return null because this is a call, not an assignment + const info = extractor.extract(exprStmt, ctx); + expect(info).toBeNull(); + }); +}); From a32f5b6adb8f36ea266b29bf5766be33204c32ad Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:45:44 +0100 Subject: [PATCH 61/67] refactor(SM-20): wire SemanticModel as first-class resolution input (#885) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * refactor(SM-20): fix O(n²) BFS in gatherAncestors, complete barrel exports, consolidate imports Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/1246d49e-6c67-4c79-935a-4732394b9a7a --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- gitnexus/src/core/ingestion/call-processor.ts | 12 +++++++----- gitnexus/src/core/ingestion/field-types.ts | 2 +- gitnexus/src/core/ingestion/model/index.ts | 16 +++++++++++++--- gitnexus/src/core/ingestion/model/resolve.ts | 8 ++++++-- gitnexus/src/core/ingestion/parsing-processor.ts | 3 +-- gitnexus/src/core/ingestion/type-env.ts | 2 +- 6 files changed, 29 insertions(+), 14 deletions(-) diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index bb7186697..6c9191970 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -1,7 +1,12 @@ import { KnowledgeGraph } from '../graph/types.js'; import { ASTCache } from './ast-cache.js'; -import type { SymbolDefinition, SymbolTableReader } from './model/symbol-table.js'; -import { CLASS_TYPES, CALL_TARGET_TYPES } from './model/symbol-table.js'; +import type { + SymbolDefinition, + SymbolTableReader, + HeritageMap, + ExtractedHeritage, +} from './model/index.js'; +import { CLASS_TYPES, CALL_TARGET_TYPES, lookupMethodByOwnerWithMRO } from './model/index.js'; import Parser from 'tree-sitter'; import type { ResolutionContext } from './model/resolution-context.js'; import { TIER_CONFIDENCE, type ResolutionTier } from './model/resolution-context.js'; @@ -32,7 +37,6 @@ import { } from './utils/call-analysis.js'; import { buildTypeEnv, isSubclassOf } from './type-env.js'; import type { ConstructorBinding, TypeEnvironment } from './type-env.js'; -import type { HeritageMap } from './model/heritage-map.js'; import type { BindingAccumulator } from './binding-accumulator.js'; import { getTreeSitterBufferSize } from './constants.js'; import type { @@ -42,13 +46,11 @@ import type { ExtractedFetchCall, FileConstructorBindings, } from './workers/parse-worker.js'; -import type { ExtractedHeritage } from './model/heritage-map.js'; import { normalizeFetchURL, routeMatches } from './route-extractors/nextjs.js'; import { extractTemplateComponents } from './vue-sfc-extractor.js'; import { extractReturnTypeName, stripNullable } from './type-extractors/shared.js'; import type { LiteralTypeInferrer } from './type-extractors/types.js'; import type { SyntaxNode } from './utils/ast-helpers.js'; -import { lookupMethodByOwnerWithMRO } from './model/resolve.js'; /** Per-file resolved type bindings for exported symbols. * Populated during call processing, consumed by Phase 14 re-resolution pass. */ diff --git a/gitnexus/src/core/ingestion/field-types.ts b/gitnexus/src/core/ingestion/field-types.ts index 8dd1a9f6c..7867ae8a6 100644 --- a/gitnexus/src/core/ingestion/field-types.ts +++ b/gitnexus/src/core/ingestion/field-types.ts @@ -1,7 +1,7 @@ // gitnexus/src/core/ingestion/field-types.ts import type { TypeEnvironment } from './type-env.js'; -import type { SymbolTableReader } from './model/symbol-table.js'; +import type { SymbolTableReader } from './model/index.js'; import { SupportedLanguages } from 'gitnexus-shared'; /** diff --git a/gitnexus/src/core/ingestion/model/index.ts b/gitnexus/src/core/ingestion/model/index.ts index 27f939171..49f200285 100644 --- a/gitnexus/src/core/ingestion/model/index.ts +++ b/gitnexus/src/core/ingestion/model/index.ts @@ -26,6 +26,15 @@ export { type SymbolTableReader, type SymbolTableWriter, createSymbolTable, + type SymbolDefinition, + type AddMetadata, + CLASS_TYPES, + CLASS_TYPES_TUPLE, + type ClassLikeLabel, + FREE_CALLABLE_TYPES, + FREE_CALLABLE_TUPLE, + type FreeCallableLabel, + CALL_TARGET_TYPES, } from './symbol-table.js'; // Type registry (classes, structs, interfaces, enums, records, impls) @@ -63,11 +72,12 @@ export { isFileInPackageDir, } from './resolution-context.js'; -// Heritage types. `buildHeritageMap` + `resolveExtendsType` are exported -// directly from `heritage-map.ts` and are not re-surfaced here to keep -// the barrel narrow. +// Heritage types and builder. `buildHeritageMap` + `resolveExtendsType` are +// exported directly from `heritage-map.ts` and are not re-surfaced here to +// keep the barrel narrow. export { type ExtractedHeritage, + type HeritageMap, type HeritageResolutionStrategy, type HeritageStrategyLookup, } from './heritage-map.js'; diff --git a/gitnexus/src/core/ingestion/model/resolve.ts b/gitnexus/src/core/ingestion/model/resolve.ts index 0fcf1e8b3..746a2b2de 100644 --- a/gitnexus/src/core/ingestion/model/resolve.ts +++ b/gitnexus/src/core/ingestion/model/resolve.ts @@ -22,14 +22,18 @@ import type { MroStrategy } from 'gitnexus-shared'; /** * Gather all ancestor IDs in BFS / topological order. * Returns the linearized list of ancestor IDs (excluding the class itself). + * + * Uses a head-pointer BFS (`queue[head++]`) instead of `Array.shift()` to + * avoid O(n) per-dequeue re-indexing — matching `buildParentMapFromHeritage`. */ function gatherAncestors(classId: string, parentMap: Map): string[] { const visited = new Set(); const order: string[] = []; const queue: string[] = [...(parentMap.get(classId) ?? [])]; + let head = 0; - while (queue.length > 0) { - const id = queue.shift()!; + while (head < queue.length) { + const id = queue[head++]!; if (visited.has(id)) continue; visited.add(id); order.push(id); diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index a13d66818..90186c659 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -4,7 +4,7 @@ import Parser from 'tree-sitter'; import { loadParser, loadLanguage, isLanguageAvailable } from '../tree-sitter/parser-loader.js'; import { getProvider } from './languages/index.js'; import { generateId } from '../../lib/utils.js'; -import type { SymbolTableReader, SymbolTableWriter } from './model/symbol-table.js'; +import type { SymbolTableReader, SymbolTableWriter, ExtractedHeritage } from './model/index.js'; // SymbolTableReader is used for the FieldExtractorContext stub; the // parsing functions themselves need Writer because they call .add(). import { ASTCache } from './ast-cache.js'; @@ -46,7 +46,6 @@ import type { FileScopeBindings, ExtractedORMQuery, } from './workers/parse-worker.js'; -import type { ExtractedHeritage } from './model/heritage-map.js'; import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from './constants.js'; export type FileProgressCallback = (current: number, total: number, filePath: string) => void; diff --git a/gitnexus/src/core/ingestion/type-env.ts b/gitnexus/src/core/ingestion/type-env.ts index 368de762b..998e7a59e 100644 --- a/gitnexus/src/core/ingestion/type-env.ts +++ b/gitnexus/src/core/ingestion/type-env.ts @@ -21,7 +21,7 @@ import { stripNullable, extractReturnTypeName, } from './type-extractors/shared.js'; -import type { SemanticModel } from './model/semantic-model.js'; +import type { SemanticModel } from './model/index.js'; import type { NodeLabel } from 'gitnexus-shared'; /** From f221f933417abf2a081862cc0cd10be39e21e499 Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Thu, 16 Apr 2026 18:36:24 +0100 Subject: [PATCH 62/67] feat(extractors): detect jQuery $.ajax/$.get/$.post and axios object-form as HTTP consumers (#887) * feat(extractors): detect jQuery $.ajax/$.get/$.post and axios object-form as HTTP consumers The JS/TS HTTP consumer extractor currently recognises fetch() and axios.() but misses three patterns extremely common in Laravel and legacy frontends: - jQuery shorthand: $.get(url), $.post(url, data) - jQuery ajax form: $.ajax({ url, method }) / $.ajax({ url, type }) - axios object form: axios({ method, url }) Missing them means the frontend->backend cross-link disappears from `group sync`, breaking impact analysis for whole classes of repos. Implementation (node.ts): - 3 new PatternSpecs alongside the existing FETCH_/AXIOS_ specs - NodePatternBundle extended with jqueryShorthand / jqueryAjax / axiosObject slots, compiled for JS / TS / TSX grammars - readStringProp() helper walks object-literal `pair` children and resolves `url` / `method` / `type` keys independent of order, sidestepping the positional S-expression constraint on the query form proposed in the issue - 3 new scan loops in scanBundle() emit HttpDetection with framework 'jquery' (new) or 'axios' (existing), confidence 0.7 to match the existing source-scan consumers, defaulting method to GET when absent (matches both jQuery and axios runtime) Tests (http-route-extractor.test.ts): 4 new cases -- 3 positive (shorthand, ajax with method:/type: and default GET, object-form with swapped key order and default GET) plus 1 negative control that asserts unrelated \$.fn.extend / \$.each / non-axios helper calls with {url, method} literals produce zero consumer contracts. Closes #828 * test(extractors): cover jQuery $.ajax with template-literal URL Extend the existing $.ajax fixture with `url: \`/api/orders/\${id}\`` and assert the consumer is emitted as http::GET::/api/orders/{param}. This makes jQuery + template-URL explicit rather than implicit via the axios object test (readStringProp already accepts template_string for both; this is coverage, not new behaviour). Addresses the single non-blocking finding on PR #887. --- .../group/extractors/http-patterns/node.ts | 129 ++++++++++++++++++ .../unit/group/http-route-extractor.test.ts | 118 ++++++++++++++++ 2 files changed, 247 insertions(+) diff --git a/gitnexus/src/core/group/extractors/http-patterns/node.ts b/gitnexus/src/core/group/extractors/http-patterns/node.ts index 587f48e8c..fbf988665 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/node.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/node.ts @@ -17,6 +17,9 @@ import type { HttpDetection, HttpLanguagePlugin } from './types.js'; * - Express `router.get(...)` / `app.post(...)` providers * - `fetch(url)` / `fetch(url, { method: 'POST' })` consumers * - `axios.get(url)` / `axios.delete(url)` consumers + * - `axios({ method, url })` object-form consumers + * - jQuery `$.get(url)` / `$.post(url, ...)` shorthand consumers + * - jQuery `$.ajax({ url, method | type })` consumers * * Because the JavaScript and TypeScript tree-sitter grammars share * node type names for every construct we query, pattern sources are @@ -103,6 +106,48 @@ const AXIOS_SPEC: PatternSpec> = { `, }; +// ─── Consumer: jQuery shorthand $.get(url) / $.post(url, ...) ──────── +// `$` is a valid JS identifier, so tree-sitter parses `$.get(...)` as a +// call_expression whose function is a member_expression on identifier `$`. +const JQUERY_SHORTHAND_SPEC: PatternSpec> = { + meta: {}, + query: ` + (call_expression + function: (member_expression + object: (identifier) @obj (#eq? @obj "$") + property: (property_identifier) @http_method (#match? @http_method "^(get|post)$")) + arguments: (arguments . [(string) (template_string)] @path)) + `, +}; + +// ─── Consumer: jQuery $.ajax({ url, method|type }) ─────────────────── +// The query captures the options object only; key/value pairs are read +// programmatically via `readStringProp` below, which tolerates any key +// order and accepts either `method:` or `type:` (jQuery supports both). +const JQUERY_AJAX_SPEC: PatternSpec> = { + meta: {}, + query: ` + (call_expression + function: (member_expression + object: (identifier) @obj (#eq? @obj "$") + property: (property_identifier) @fn (#eq? @fn "ajax")) + arguments: (arguments (object) @options)) + `, +}; + +// ─── Consumer: axios({ method, url }) object form ──────────────────── +// Distinct from AXIOS_SPEC above because the call target is an identifier +// (`axios`) rather than a member expression (`axios.get`). As with the +// jQuery ajax form, option keys are resolved programmatically. +const AXIOS_OBJECT_SPEC: PatternSpec> = { + meta: {}, + query: ` + (call_expression + function: (identifier) @fn (#eq? @fn "axios") + arguments: (arguments (object) @options)) + `, +}; + interface NodePatternBundle { controller: CompiledPatterns>; methodDecorator: CompiledPatterns>; @@ -110,6 +155,9 @@ interface NodePatternBundle { fetchNoOptions: CompiledPatterns>; fetchWithOptions: CompiledPatterns>; axios: CompiledPatterns>; + jqueryShorthand: CompiledPatterns>; + jqueryAjax: CompiledPatterns>; + axiosObject: CompiledPatterns>; } function compileBundle(language: unknown, name: string): NodePatternBundle { @@ -126,6 +174,9 @@ function compileBundle(language: unknown, name: string): NodePatternBundle { fetchNoOptions: mk(FETCH_NO_OPTIONS_SPEC, 'fetch-no-options'), fetchWithOptions: mk(FETCH_WITH_OPTIONS_SPEC, 'fetch-with-options'), axios: mk(AXIOS_SPEC, 'axios'), + jqueryShorthand: mk(JQUERY_SHORTHAND_SPEC, 'jquery-shorthand'), + jqueryAjax: mk(JQUERY_AJAX_SPEC, 'jquery-ajax'), + axiosObject: mk(AXIOS_OBJECT_SPEC, 'axios-object'), }; } @@ -160,6 +211,28 @@ function joinPath(prefix: string, sub: string): string { return `/${cleanPrefix}/${cleanSub}`; } +/** + * Walk `pair` children of an `object` literal and return the unquoted + * string/template_string value for the first pair whose key matches one + * of `keyNames`. Returns null when no matching pair is present or the + * value is not a string literal. Used by the jQuery ajax / axios object + * consumers to resolve `url` / `method` / `type` keys in any order. + */ +function readStringProp(objectNode: Parser.SyntaxNode, keyNames: readonly string[]): string | null { + for (let i = 0; i < objectNode.namedChildCount; i++) { + const pair = objectNode.namedChild(i); + if (!pair || pair.type !== 'pair') continue; + const keyNode = pair.childForFieldName('key'); + const valueNode = pair.childForFieldName('value'); + if (!keyNode || !valueNode) continue; + if (!keyNames.includes(keyNode.text)) continue; + if (valueNode.type !== 'string' && valueNode.type !== 'template_string') continue; + const lit = unquoteLiteral(valueNode.text); + if (lit !== null) return lit; + } + return null; +} + /** * For a standalone `decorator` node (child of class_body / program), * find the related `class_declaration` node that it decorates. In @@ -351,6 +424,62 @@ function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection }); } + // Consumer: jQuery shorthand $.get(url) / $.post(url, ...) + for (const match of runCompiledPatterns(bundle.jqueryShorthand, tree)) { + const methodNode = match.captures.http_method; + const pathNode = match.captures.path; + if (!methodNode || !pathNode) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'consumer', + framework: 'jquery', + method: methodNode.text.toUpperCase(), + path, + name: null, + confidence: 0.7, + }); + } + + // Consumer: jQuery $.ajax({ url, method|type }). jQuery accepts either + // `method:` or `type:`; both default to GET when absent. + for (const match of runCompiledPatterns(bundle.jqueryAjax, tree)) { + const optionsNode = match.captures.options; + if (!optionsNode) continue; + const path = readStringProp(optionsNode, ['url']); + if (path === null) continue; + const rawMethod = readStringProp(optionsNode, ['method', 'type']); + const method = (rawMethod ?? 'GET').toUpperCase(); + out.push({ + role: 'consumer', + framework: 'jquery', + method, + path, + name: null, + confidence: 0.7, + }); + } + + // Consumer: axios({ method, url }) object form. Structurally distinct + // from axios.(url) (identifier vs member_expression call), so no + // dedup against the member-form loop above is required. + for (const match of runCompiledPatterns(bundle.axiosObject, tree)) { + const optionsNode = match.captures.options; + if (!optionsNode) continue; + const path = readStringProp(optionsNode, ['url']); + if (path === null) continue; + const rawMethod = readStringProp(optionsNode, ['method']); + const method = (rawMethod ?? 'GET').toUpperCase(); + out.push({ + role: 'consumer', + framework: 'axios', + method, + path, + name: null, + confidence: 0.7, + }); + } + return out; } diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index 653b4952c..8ff914fcc 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -290,6 +290,124 @@ export const deleteUser = (id: string) => axios.delete(\`/api/users/\${id}\`); ).toBeDefined(); }); + it('extracts jQuery $.get and $.post shorthand', async () => { + const dir = path.join(tmpDir, 'jquery-shorthand'); + fs.mkdirSync(path.join(dir, 'public/js'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'public/js/users.js'), + ` +function loadUsers() { + $.get('/api/users', function (data) { console.log(data); }); +} + +function createUser(payload) { + $.post('/api/users', payload); +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + const getRoute = consumers.find((c) => c.contractId === 'http::GET::/api/users'); + expect(getRoute).toBeDefined(); + expect(getRoute?.meta.framework).toBe('jquery'); + + const postRoute = consumers.find((c) => c.contractId === 'http::POST::/api/users'); + expect(postRoute).toBeDefined(); + expect(postRoute?.meta.framework).toBe('jquery'); + }); + + it('extracts jQuery $.ajax with method: and type: keys and default GET', async () => { + const dir = path.join(tmpDir, 'jquery-ajax'); + fs.mkdirSync(path.join(dir, 'public/js'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'public/js/orders.js'), + ` +$.ajax({ url: '/api/orders', method: 'PUT', data: {} }); +$.ajax({ url: '/api/items', type: 'DELETE' }); +$.ajax({ url: '/api/default' }); + +function reloadOrder(id) { + return $.ajax({ url: \`/api/orders/\${id}\`, method: 'GET' }); +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers.find((c) => c.contractId === 'http::PUT::/api/orders')).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::DELETE::/api/items')).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::GET::/api/default')).toBeDefined(); + // Template-literal URL inside $.ajax is normalized to {param} the same + // way the fetch/axios paths do — confirms readStringProp accepts + // template_string values for jQuery ajax, not just for axios object form. + expect( + consumers.find((c) => c.contractId === 'http::GET::/api/orders/{param}'), + ).toBeDefined(); + }); + + it('extracts axios({ method, url }) object form regardless of key order', async () => { + const dir = path.join(tmpDir, 'axios-object'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/orders.ts'), + ` +import axios from 'axios'; + +export function createOrder(data: unknown) { + return axios({ method: 'POST', url: '/api/orders', data }); +} + +export function updateUser(id: string, data: unknown) { + return axios({ url: \`/api/users/\${id}\`, method: 'PUT', data }); +} + +export function listDefaults() { + return axios({ url: '/api/defaults' }); +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers.find((c) => c.contractId === 'http::POST::/api/orders')).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::PUT::/api/users/{param}')).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::GET::/api/defaults')).toBeDefined(); + }); + + it('does not emit consumers for unrelated object-literal calls (negative control)', async () => { + const dir = path.join(tmpDir, 'jquery-axios-negative'); + fs.mkdirSync(path.join(dir, 'public/js'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'public/js/misc.js'), + ` +// jQuery but not an ajax/get/post call +$.fn.extend({ url: '/nope', method: 'POST' }); +$.each([1, 2, 3], function (i, v) { return v; }); + +// Not axios and not $ — unrelated helper that happens to take { url, method } +function myHelper(opts) { return opts; } +myHelper({ url: '/nope', method: 'POST' }); + +// Bare object literal, not a call argument at all +const cfg = { url: '/nope', method: 'POST' }; +console.log(cfg); +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + // None of the above should have produced any HTTP consumer contracts. + const nopeConsumers = consumers.filter( + (c) => typeof c.meta.path === 'string' && c.meta.path.includes('/nope'), + ); + expect(nopeConsumers).toHaveLength(0); + }); + it('extracts Python requests calls', async () => { const dir = path.join(tmpDir, 'python-consumer'); fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); From 43098784cfbcbdc44ae5893109c4fc2e23f45c29 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 19:31:44 +0100 Subject: [PATCH 63/67] refactor(ingestion): split ImportSemantics into per-strategy hooks (Strategies 1-4) (#886) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * refactor(ingestion): split ImportSemantics into per-strategy hooks - Add ImportResolverStrategy and ImportResolutionConfig types - Create createImportResolver factory (resolver-factory.ts) - Add createStandardStrategy to standard.ts - Extract per-language strategies from existing resolvers: goPackageStrategy, javaJvmStrategy, kotlinJvmStrategy, rustModuleStrategy, pythonImportStrategy, csharpNamespaceStrategy, phpPsr4Strategy, swiftPackageStrategy, dartPackageStrategy, dartRelativeStrategy, rubyRequireStrategy - Create per-language config files in import-resolvers/configs/ - Update all 15 language providers to use createImportResolver(config) - Add 38 unit tests for factory and strategy composition - All 3640+ existing tests pass, tsc --noEmit passes Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c3aec32d-2155-4808-88df-9cd6b2384174 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * chore: remove unused resolver imports from language providers Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c3aec32d-2155-4808-88df-9cd6b2384174 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * docs: add error propagation note to createImportResolver JSDoc Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c3aec32d-2155-4808-88df-9cd6b2384174 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * refactor: consolidate strategies into configs, remove legacy resolvers - Move all strategies from per-language files into their config files - Remove swift.ts and vue.ts (no shared helpers needed) - Remove legacy monolithic resolver functions from all per-language files - Remove unused legacy wrapper functions from standard.ts - Per-language files now only contain shared internal helpers - Fix lint warning in languages/php.ts (no-non-null-assertion) - Update test imports to reference configs/ instead of per-language files - All 3262+ tests pass, tsc --noEmit passes, zero lint errors Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/f8da6bc2-957c-4d20-87ba-402fa223c6c8 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * refactor: address review feedback — remove dart.ts shim, JSDoc language field, update ARCHITECTURE.md - Add JSDoc to ImportResolutionConfig.language clarifying it's documentation-only metadata not used by the factory - Remove dart.ts legacy shim (was only kept for backward-compat tests) - Rewrite dart-import-resolver.test.ts to test production strategies (dartPackageStrategy/dartRelativeStrategy) directly, including full factory composition via dartImportConfig - Fix lint warning (no-explicit-any) by using buildSuffixIndex in makeCtx - Update ARCHITECTURE.md to mention import-resolvers/configs/ as the extension point for per-language import resolution Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/53f09a4f-1ff1-4a3e-a29c-fda9cdb4c4ef Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: address review comments — revert php.ts, tighten dart test assertion - Revert php.ts: restore stack.pop()! (the while guard guarantees non-empty) - Tighten dart relative import test to assert exact result instead of permissive null-or-files check Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/53f09a4f-1ff1-4a3e-a29c-fda9cdb4c4ef Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * refactor(ingestion): strengthen import-resolver tests and document Vue config intent Address non-blocking follow-ups from PR #886 review: - Add inline comment to vueImportConfig explaining intentional language: Vue / TypeScript-strategy mismatch (Vue SFCs are preprocessed into TS upstream of import resolution). - Replace 11 tautological typeof === 'function' assertions with behavioral tests for goPackageStrategy, kotlinJvmStrategy, and csharpNamespaceStrategy, including full-chain strategy-order guards via createImportResolver(config). - Apply prettier formatting to sibling configs touched during factory introduction. Test: 37 passed (previously 26), tsc --noEmit clean. * test(ingestion): tighten import-resolver assertions and close coverage gaps Apply ce-review findings on commit f4be87fb: - Tighten dirSuffix assertions from toContain() to exact toEqual() shape, catching format regressions (slash normalization, prefix trimming) the loose matcher would miss. - Collapse 'if (result?.kind === "package") { expect(dirSuffix)... }' conditional-dead-branch pattern into single toEqual() assertions. - Add goPackageStrategy fall-through test: module prefix matches but package directory contains no .go files -> null (documented branch in configs/go.ts:27 had no coverage). - Honestly relabel kotlinImportConfig full-chain test as a behavioral smoke test rather than a strategy-order guard — standard.ts:137 returns null for '.*' imports so reordering is not observable via wildcard inputs. Added Kotlin member-import test for extra coverage. - Add behavioral tests for javaJvmStrategy, rustModuleStrategy, phpPsr4Strategy, swiftPackageStrategy, rubyRequireStrategy (10 tests across 5 describe blocks) so strategy unwiring would be caught. - Extend makeCtx() with optional overrides: Partial parameter for declarative per-test config setup. Test: 50 passed (previously 37), tsc --noEmit clean. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> Co-authored-by: Gergo Magyar --- ARCHITECTURE.md | 4 +- .../import-resolvers/configs/c-cpp.ts | 18 + .../import-resolvers/configs/csharp.ts | 36 ++ .../import-resolvers/configs/dart.ts | 58 ++ .../ingestion/import-resolvers/configs/go.ts | 35 ++ .../ingestion/import-resolvers/configs/jvm.ts | 115 ++++ .../ingestion/import-resolvers/configs/php.ts | 26 + .../import-resolvers/configs/python.ts | 30 + .../import-resolvers/configs/ruby.ts | 20 + .../import-resolvers/configs/rust.ts | 56 ++ .../import-resolvers/{ => configs}/swift.ts | 22 +- .../configs/typescript-javascript.ts | 28 + .../core/ingestion/import-resolvers/csharp.ts | 35 +- .../core/ingestion/import-resolvers/dart.ts | 50 -- .../src/core/ingestion/import-resolvers/go.ts | 34 +- .../core/ingestion/import-resolvers/jvm.ts | 115 +--- .../core/ingestion/import-resolvers/php.ts | 24 +- .../core/ingestion/import-resolvers/python.ts | 26 +- .../import-resolvers/resolver-factory.ts | 35 ++ .../core/ingestion/import-resolvers/ruby.ts | 22 +- .../core/ingestion/import-resolvers/rust.ts | 56 +- .../ingestion/import-resolvers/standard.ts | 23 +- .../core/ingestion/import-resolvers/types.ts | 26 + .../core/ingestion/import-resolvers/vue.ts | 13 - .../src/core/ingestion/languages/c-cpp.ts | 7 +- .../src/core/ingestion/languages/csharp.ts | 5 +- gitnexus/src/core/ingestion/languages/dart.ts | 5 +- gitnexus/src/core/ingestion/languages/go.ts | 5 +- gitnexus/src/core/ingestion/languages/java.ts | 5 +- .../src/core/ingestion/languages/kotlin.ts | 5 +- gitnexus/src/core/ingestion/languages/php.ts | 5 +- .../src/core/ingestion/languages/python.ts | 5 +- gitnexus/src/core/ingestion/languages/ruby.ts | 5 +- gitnexus/src/core/ingestion/languages/rust.ts | 5 +- .../src/core/ingestion/languages/swift.ts | 5 +- .../core/ingestion/languages/typescript.ts | 10 +- gitnexus/src/core/ingestion/languages/vue.ts | 5 +- .../test/unit/dart-import-resolver.test.ts | 128 ++++- .../test/unit/import-resolver-factory.test.ts | 542 ++++++++++++++++++ 39 files changed, 1219 insertions(+), 430 deletions(-) create mode 100644 gitnexus/src/core/ingestion/import-resolvers/configs/c-cpp.ts create mode 100644 gitnexus/src/core/ingestion/import-resolvers/configs/csharp.ts create mode 100644 gitnexus/src/core/ingestion/import-resolvers/configs/dart.ts create mode 100644 gitnexus/src/core/ingestion/import-resolvers/configs/go.ts create mode 100644 gitnexus/src/core/ingestion/import-resolvers/configs/jvm.ts create mode 100644 gitnexus/src/core/ingestion/import-resolvers/configs/php.ts create mode 100644 gitnexus/src/core/ingestion/import-resolvers/configs/python.ts create mode 100644 gitnexus/src/core/ingestion/import-resolvers/configs/ruby.ts create mode 100644 gitnexus/src/core/ingestion/import-resolvers/configs/rust.ts rename gitnexus/src/core/ingestion/import-resolvers/{ => configs}/swift.ts (53%) create mode 100644 gitnexus/src/core/ingestion/import-resolvers/configs/typescript-javascript.ts delete mode 100644 gitnexus/src/core/ingestion/import-resolvers/dart.ts create mode 100644 gitnexus/src/core/ingestion/import-resolvers/resolver-factory.ts delete mode 100644 gitnexus/src/core/ingestion/import-resolvers/vue.ts create mode 100644 gitnexus/test/unit/import-resolver-factory.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1be9468c3..76fd3bcb6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -59,7 +59,7 @@ Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`). | Embeddings | `src/core/embeddings/` + `src/core/run-analyze.ts` | | Wiki generation | `src/core/wiki/` | | Language support | `src/core/ingestion/languages/` + `tree-sitter-queries.ts` + `gitnexus-shared/src/languages.ts` | -| Import resolution | `src/core/ingestion/import-processor.ts` + `model/resolution-context.ts` | +| Import resolution | `src/core/ingestion/import-processor.ts` + `import-resolvers/configs/` + `model/resolution-context.ts` | | Call resolution/MRO | `src/core/ingestion/call-processor.ts` + `model/resolve.ts` | | Type extraction | `src/core/ingestion/type-extractors/` | | Worker pool | `src/core/ingestion/workers/` | @@ -178,6 +178,8 @@ Per-language tree-sitter queries use different AST node names but produce the ** ### Import resolution +Per-language import resolution uses the **configs + factory** pattern (like call/method/class extractors). Each language declares an `ImportResolutionConfig` in `import-resolvers/configs/`, listing an ordered chain of `ImportResolverStrategy` functions. `createImportResolver()` (in `resolver-factory.ts`) composes them: first non-null result wins. Low-level helpers shared across strategies live alongside the configs in `import-resolvers/` (e.g. `go.ts`, `rust.ts`, `python.ts`). + Unified 3-tier algorithm (`model/resolution-context.ts`), per-language `importSemantics` controls which tier activates: | Tier | Confidence | Mechanism | diff --git a/gitnexus/src/core/ingestion/import-resolvers/configs/c-cpp.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/c-cpp.ts new file mode 100644 index 000000000..bcfddee0b --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/c-cpp.ts @@ -0,0 +1,18 @@ +/** + * C / C++ import resolution configs. + * Both use standard resolution for #include directives. + */ + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ImportResolutionConfig } from '../types.js'; +import { createStandardStrategy } from '../standard.js'; + +export const cImportConfig: ImportResolutionConfig = { + language: SupportedLanguages.C, + strategies: [createStandardStrategy(SupportedLanguages.C)], +}; + +export const cppImportConfig: ImportResolutionConfig = { + language: SupportedLanguages.CPlusPlus, + strategies: [createStandardStrategy(SupportedLanguages.CPlusPlus)], +}; diff --git a/gitnexus/src/core/ingestion/import-resolvers/configs/csharp.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/csharp.ts new file mode 100644 index 000000000..cb5f77145 --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/csharp.ts @@ -0,0 +1,36 @@ +/** + * C# import resolution config. + * Namespace-based strategy via .csproj configs, then standard fallback. + */ + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ImportResolutionConfig, ImportResolverStrategy } from '../types.js'; +import { createStandardStrategy } from '../standard.js'; +import { resolveCSharpImportInternal, resolveCSharpNamespaceDir } from '../csharp.js'; + +/** C# namespace-based resolution strategy via .csproj configs. */ +export const csharpNamespaceStrategy: ImportResolverStrategy = (rawImportPath, _filePath, ctx) => { + const csharpConfigs = ctx.configs.csharpConfigs; + if (csharpConfigs.length > 0) { + const resolvedFiles = resolveCSharpImportInternal( + rawImportPath, + csharpConfigs, + ctx.normalizedFileList, + ctx.allFileList, + ctx.index, + ); + if (resolvedFiles.length > 1) { + const dirSuffix = resolveCSharpNamespaceDir(rawImportPath, csharpConfigs); + if (dirSuffix) { + return { kind: 'package', files: resolvedFiles, dirSuffix }; + } + } + if (resolvedFiles.length > 0) return { kind: 'files', files: resolvedFiles }; + } + return null; +}; + +export const csharpImportConfig: ImportResolutionConfig = { + language: SupportedLanguages.CSharp, + strategies: [csharpNamespaceStrategy, createStandardStrategy(SupportedLanguages.CSharp)], +}; diff --git a/gitnexus/src/core/ingestion/import-resolvers/configs/dart.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/dart.ts new file mode 100644 index 000000000..06dcea98a --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/dart.ts @@ -0,0 +1,58 @@ +/** + * Dart import resolution config. + * SDK/package strategy first, then relative import strategy (with ./ prepending). + */ + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ImportResolutionConfig, ImportResolverStrategy } from '../types.js'; +import { resolveStandard } from '../standard.js'; + +/** + * Dart SDK and package: import strategy. + * Absorbs dart: SDK imports and external packages (returns empty result to stop chain). + * Returns null for relative imports to let the next strategy handle them. + */ +export const dartPackageStrategy: ImportResolverStrategy = (rawImportPath, _filePath, ctx) => { + // Strip surrounding quotes from configurable_uri capture + const stripped = rawImportPath.replace(/^['"]|['"]$/g, ''); + + // Skip dart: SDK imports (dart:async, dart:io, etc.) + if (stripped.startsWith('dart:')) return { kind: 'files', files: [] }; + + // Local package: imports → resolve to lib/ + if (stripped.startsWith('package:')) { + const slashIdx = stripped.indexOf('/'); + if (slashIdx === -1) return { kind: 'files', files: [] }; + const relPath = stripped.slice(slashIdx + 1); + const candidates = [`lib/${relPath}`, relPath]; + const files: string[] = []; + for (const candidate of candidates) { + for (const fp of ctx.allFileList) { + if (fp.endsWith('/' + candidate) || fp === candidate) { + files.push(fp); + break; + } + } + if (files.length > 0) break; + } + if (files.length > 0) return { kind: 'files', files }; + return { kind: 'files', files: [] }; // external package + } + + return null; +}; + +/** + * Dart relative import strategy — prepends "./" for bare relative paths, + * then delegates to standard resolution. + */ +export const dartRelativeStrategy: ImportResolverStrategy = (rawImportPath, filePath, ctx) => { + const stripped = rawImportPath.replace(/^['"]|['"]$/g, ''); + const relPath = stripped.startsWith('.') ? stripped : './' + stripped; + return resolveStandard(relPath, filePath, ctx, SupportedLanguages.Dart); +}; + +export const dartImportConfig: ImportResolutionConfig = { + language: SupportedLanguages.Dart, + strategies: [dartPackageStrategy, dartRelativeStrategy], +}; diff --git a/gitnexus/src/core/ingestion/import-resolvers/configs/go.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/go.ts new file mode 100644 index 000000000..7eee17988 --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/go.ts @@ -0,0 +1,35 @@ +/** + * Go import resolution config. + * Go-specific package strategy (go.mod), then standard fallback. + */ + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ImportResolutionConfig, ImportResolverStrategy } from '../types.js'; +import { createStandardStrategy } from '../standard.js'; +import { resolveGoPackageDir, resolveGoPackage } from '../go.js'; + +/** Go-specific package resolution strategy — resolves go.mod-based package imports. */ +export const goPackageStrategy: ImportResolverStrategy = (rawImportPath, _filePath, ctx) => { + const goModule = ctx.configs.goModule; + if (goModule && rawImportPath.startsWith(goModule.modulePath)) { + const pkgSuffix = resolveGoPackageDir(rawImportPath, goModule); + if (pkgSuffix) { + const pkgFiles = resolveGoPackage( + rawImportPath, + goModule, + ctx.normalizedFileList, + ctx.allFileList, + ); + if (pkgFiles.length > 0) { + return { kind: 'package', files: pkgFiles, dirSuffix: pkgSuffix }; + } + } + // Fall through if no files found (package might be external) + } + return null; +}; + +export const goImportConfig: ImportResolutionConfig = { + language: SupportedLanguages.Go, + strategies: [goPackageStrategy, createStandardStrategy(SupportedLanguages.Go)], +}; diff --git a/gitnexus/src/core/ingestion/import-resolvers/configs/jvm.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/jvm.ts new file mode 100644 index 000000000..47e590fe8 --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/jvm.ts @@ -0,0 +1,115 @@ +/** + * Java / Kotlin import resolution configs. + * JVM-specific wildcard/member strategy, then standard fallback. + */ + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ImportResolutionConfig, ImportResolverStrategy } from '../types.js'; +import { createStandardStrategy } from '../standard.js'; +import { resolveJvmWildcard, resolveJvmMemberImport, KOTLIN_EXTENSIONS } from '../jvm.js'; + +/** Java JVM resolution strategy — wildcard and member import resolution. */ +export const javaJvmStrategy: ImportResolverStrategy = (rawImportPath, _filePath, ctx) => { + if (rawImportPath.endsWith('.*')) { + const matchedFiles = resolveJvmWildcard( + rawImportPath, + ctx.normalizedFileList, + ctx.allFileList, + ['.java'], + ctx.index, + ); + if (matchedFiles.length > 0) return { kind: 'files', files: matchedFiles }; + } else { + const memberResolved = resolveJvmMemberImport( + rawImportPath, + ctx.normalizedFileList, + ctx.allFileList, + ['.java'], + ctx.index, + ); + if (memberResolved) return { kind: 'files', files: [memberResolved] }; + } + return null; +}; + +/** + * Kotlin JVM resolution strategy — wildcard/member with Java-interop + top-level function imports. + */ +export const kotlinJvmStrategy: ImportResolverStrategy = (rawImportPath, _filePath, ctx) => { + if (rawImportPath.endsWith('.*')) { + const matchedFiles = resolveJvmWildcard( + rawImportPath, + ctx.normalizedFileList, + ctx.allFileList, + KOTLIN_EXTENSIONS, + ctx.index, + ); + if (matchedFiles.length === 0) { + const javaMatches = resolveJvmWildcard( + rawImportPath, + ctx.normalizedFileList, + ctx.allFileList, + ['.java'], + ctx.index, + ); + if (javaMatches.length > 0) return { kind: 'files', files: javaMatches }; + } + if (matchedFiles.length > 0) return { kind: 'files', files: matchedFiles }; + } else { + let memberResolved = resolveJvmMemberImport( + rawImportPath, + ctx.normalizedFileList, + ctx.allFileList, + KOTLIN_EXTENSIONS, + ctx.index, + ); + if (!memberResolved) { + memberResolved = resolveJvmMemberImport( + rawImportPath, + ctx.normalizedFileList, + ctx.allFileList, + ['.java'], + ctx.index, + ); + } + if (memberResolved) return { kind: 'files', files: [memberResolved] }; + + // Kotlin: top-level function imports (e.g. import models.getUser) have only 2 segments, + // which resolveJvmMemberImport skips (requires >=3). Fall back to package-directory scan + // for lowercase last segments (function/property imports). Uppercase last segments + // (class imports like models.User) fall through to standard suffix resolution. + const segments = rawImportPath.split('.'); + const lastSeg = segments[segments.length - 1]; + if (segments.length >= 2 && lastSeg[0] && lastSeg[0] === lastSeg[0].toLowerCase()) { + const pkgWildcard = segments.slice(0, -1).join('.') + '.*'; + let dirFiles = resolveJvmWildcard( + pkgWildcard, + ctx.normalizedFileList, + ctx.allFileList, + KOTLIN_EXTENSIONS, + ctx.index, + ); + if (dirFiles.length === 0) { + dirFiles = resolveJvmWildcard( + pkgWildcard, + ctx.normalizedFileList, + ctx.allFileList, + ['.java'], + ctx.index, + ); + } + if (dirFiles.length > 0) return { kind: 'files', files: dirFiles }; + } + } + return null; +}; + +export const javaImportConfig: ImportResolutionConfig = { + language: SupportedLanguages.Java, + strategies: [javaJvmStrategy, createStandardStrategy(SupportedLanguages.Java)], +}; + +export const kotlinImportConfig: ImportResolutionConfig = { + language: SupportedLanguages.Kotlin, + strategies: [kotlinJvmStrategy, createStandardStrategy(SupportedLanguages.Kotlin)], +}; diff --git a/gitnexus/src/core/ingestion/import-resolvers/configs/php.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/php.ts new file mode 100644 index 000000000..5a8446f25 --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/php.ts @@ -0,0 +1,26 @@ +/** + * PHP import resolution config. + * PSR-4 strategy via composer.json — no standard fallback (PSR-4 includes its own suffix matching). + */ + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ImportResolutionConfig, ImportResolverStrategy } from '../types.js'; +import { resolvePhpImportInternal } from '../php.js'; + +/** PHP PSR-4 resolution strategy via composer.json autoload mappings. */ +export const phpPsr4Strategy: ImportResolverStrategy = (rawImportPath, _filePath, ctx) => { + const resolved = resolvePhpImportInternal( + rawImportPath, + ctx.configs.composerConfig, + ctx.allFilePaths, + ctx.normalizedFileList, + ctx.allFileList, + ctx.index, + ); + return resolved ? { kind: 'files', files: [resolved] } : null; +}; + +export const phpImportConfig: ImportResolutionConfig = { + language: SupportedLanguages.PHP, + strategies: [phpPsr4Strategy], +}; diff --git a/gitnexus/src/core/ingestion/import-resolvers/configs/python.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/python.ts new file mode 100644 index 000000000..85a17c594 --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/python.ts @@ -0,0 +1,30 @@ +/** + * Python import resolution config. + * PEP 328 relative + proximity-based strategy, then standard fallback. + */ + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ImportResolutionConfig, ImportResolverStrategy } from '../types.js'; +import { createStandardStrategy } from '../standard.js'; +import { resolvePythonImportInternal } from '../python.js'; + +/** + * Python import resolution strategy — PEP 328 relative + proximity-based bare imports. + * Returns null to continue chain for non-relative imports. + * Absorbs unresolved relative imports (returns empty result to stop the chain). + */ +export const pythonImportStrategy: ImportResolverStrategy = (rawImportPath, filePath, ctx) => { + const resolved = resolvePythonImportInternal(filePath, rawImportPath, ctx.allFilePaths); + if (resolved) { + ctx.resolveCache.set(`${filePath}::${rawImportPath}`, resolved); + return { kind: 'files', files: [resolved] }; + } + // PEP 328: unresolved relative imports should not fall through to suffix matching + if (rawImportPath.startsWith('.')) return { kind: 'files', files: [] }; + return null; +}; + +export const pythonImportConfig: ImportResolutionConfig = { + language: SupportedLanguages.Python, + strategies: [pythonImportStrategy, createStandardStrategy(SupportedLanguages.Python)], +}; diff --git a/gitnexus/src/core/ingestion/import-resolvers/configs/ruby.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/ruby.ts new file mode 100644 index 000000000..bf2507f70 --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/ruby.ts @@ -0,0 +1,20 @@ +/** + * Ruby import resolution config. + * Require/require_relative suffix matching — no standard fallback. + */ + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ImportResolutionConfig, ImportResolverStrategy } from '../types.js'; +import { suffixResolve } from '../utils.js'; + +/** Ruby require/require_relative resolution strategy. */ +export const rubyRequireStrategy: ImportResolverStrategy = (rawImportPath, _filePath, ctx) => { + const pathParts = rawImportPath.replace(/^\.\//, '').split('/').filter(Boolean); + const resolved = suffixResolve(pathParts, ctx.normalizedFileList, ctx.allFileList, ctx.index); + return resolved ? { kind: 'files', files: [resolved] } : null; +}; + +export const rubyImportConfig: ImportResolutionConfig = { + language: SupportedLanguages.Ruby, + strategies: [rubyRequireStrategy], +}; diff --git a/gitnexus/src/core/ingestion/import-resolvers/configs/rust.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/rust.ts new file mode 100644 index 000000000..daa2173e7 --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/rust.ts @@ -0,0 +1,56 @@ +/** + * Rust import resolution config. + * Rust module strategy (grouped imports, crate/super/self paths), then standard fallback. + */ + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ImportResolutionConfig, ImportResolverStrategy } from '../types.js'; +import { createStandardStrategy } from '../standard.js'; +import { resolveRustImportInternal } from '../rust.js'; + +/** Rust module resolution strategy — handles grouped imports and crate/super/self paths. */ +export const rustModuleStrategy: ImportResolverStrategy = (rawImportPath, filePath, ctx) => { + // Top-level grouped: use {crate::a, crate::b} + if (rawImportPath.startsWith('{') && rawImportPath.endsWith('}')) { + const inner = rawImportPath.slice(1, -1); + const parts = inner + .split(',') + .map((p) => p.trim()) + .filter(Boolean); + const resolved: string[] = []; + for (const part of parts) { + const r = resolveRustImportInternal(filePath, part, ctx.allFilePaths); + if (r) resolved.push(r); + } + return resolved.length > 0 ? { kind: 'files', files: resolved } : null; + } + + // Scoped grouped: use crate::models::{User, Repo} + const braceIdx = rawImportPath.indexOf('::{'); + if (braceIdx !== -1 && rawImportPath.endsWith('}')) { + const pathPrefix = rawImportPath.substring(0, braceIdx); + const braceContent = rawImportPath.substring(braceIdx + 3, rawImportPath.length - 1); + const items = braceContent + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + const resolved: string[] = []; + for (const item of items) { + // Handle `use crate::models::{User, Repo as R}` — strip alias for resolution + const itemName = item.includes(' as ') ? item.split(' as ')[0].trim() : item; + const r = resolveRustImportInternal(filePath, `${pathPrefix}::${itemName}`, ctx.allFilePaths); + if (r) resolved.push(r); + } + if (resolved.length > 0) return { kind: 'files', files: resolved }; + // Fallback: resolve the prefix path itself (e.g. crate::models -> models.rs) + const prefixResult = resolveRustImportInternal(filePath, pathPrefix, ctx.allFilePaths); + if (prefixResult) return { kind: 'files', files: [prefixResult] }; + } + + return null; +}; + +export const rustImportConfig: ImportResolutionConfig = { + language: SupportedLanguages.Rust, + strategies: [rustModuleStrategy, createStandardStrategy(SupportedLanguages.Rust)], +}; diff --git a/gitnexus/src/core/ingestion/import-resolvers/swift.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/swift.ts similarity index 53% rename from gitnexus/src/core/ingestion/import-resolvers/swift.ts rename to gitnexus/src/core/ingestion/import-resolvers/configs/swift.ts index 7ccc10458..f7d9ec195 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/swift.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/swift.ts @@ -1,16 +1,13 @@ /** - * Swift module import resolution. - * Handles module imports via Package.swift target map. + * Swift import resolution config. + * Package.swift target map strategy — no standard fallback (unresolved = external framework). */ -import type { ImportResult, ResolveCtx } from './types.js'; +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ImportResolutionConfig, ImportResolverStrategy } from '../types.js'; -/** Swift: module imports via Package.swift target map. */ -export function resolveSwiftImport( - rawImportPath: string, - _filePath: string, - ctx: ResolveCtx, -): ImportResult { +/** Swift Package.swift target map resolution strategy. */ +export const swiftPackageStrategy: ImportResolverStrategy = (rawImportPath, _filePath, ctx) => { const swiftPackageConfig = ctx.configs.swiftPackageConfig; if (swiftPackageConfig) { const targetDir = swiftPackageConfig.targets.get(rawImportPath); @@ -29,4 +26,9 @@ export function resolveSwiftImport( } } return null; // External framework (Foundation, UIKit, etc.) -} +}; + +export const swiftImportConfig: ImportResolutionConfig = { + language: SupportedLanguages.Swift, + strategies: [swiftPackageStrategy], +}; diff --git a/gitnexus/src/core/ingestion/import-resolvers/configs/typescript-javascript.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/typescript-javascript.ts new file mode 100644 index 000000000..b86b365fd --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/typescript-javascript.ts @@ -0,0 +1,28 @@ +/** + * TypeScript / JavaScript / Vue import resolution configs. + * All use standard resolution — TS/JS with tsconfig path aliases, + * Vue delegates to TypeScript's resolver. + */ + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ImportResolutionConfig } from '../types.js'; +import { createStandardStrategy } from '../standard.js'; + +export const typescriptImportConfig: ImportResolutionConfig = { + language: SupportedLanguages.TypeScript, + strategies: [createStandardStrategy(SupportedLanguages.TypeScript)], +}; + +export const javascriptImportConfig: ImportResolutionConfig = { + language: SupportedLanguages.JavaScript, + strategies: [createStandardStrategy(SupportedLanguages.JavaScript)], +}; + +// Vue SFCs are preprocessed into TypeScript upstream of import resolution, +// so the resolver intentionally runs as TypeScript. `language: Vue` here is +// documentation-only metadata (see `ImportResolutionConfig.language` JSDoc +// and ARCHITECTURE.md §Vue); it is not consumed by `createImportResolver`. +export const vueImportConfig: ImportResolutionConfig = { + language: SupportedLanguages.Vue, + strategies: [createStandardStrategy(SupportedLanguages.TypeScript)], +}; diff --git a/gitnexus/src/core/ingestion/import-resolvers/csharp.ts b/gitnexus/src/core/ingestion/import-resolvers/csharp.ts index 33cc9c828..79548d7f6 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/csharp.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/csharp.ts @@ -1,13 +1,12 @@ /** - * C# namespace import resolution. - * Handles using-directive resolution via .csproj root namespace stripping. + * C# namespace import resolution — internal helpers. + * + * Strategy lives in configs/csharp.ts. + * This file contains shared helpers for namespace-based resolution. */ import type { SuffixIndex } from './utils.js'; import { suffixResolve } from './utils.js'; -import { SupportedLanguages } from 'gitnexus-shared'; -import type { ImportResult, ResolveCtx } from './types.js'; -import { resolveStandard } from './standard.js'; import type { CSharpProjectConfig } from '../language-config.js'; /** @@ -126,29 +125,3 @@ export function resolveCSharpNamespaceDir( return null; } - -/** C#: namespace-based resolution via .csproj configs, with suffix-match fallback. */ -export function resolveCSharpImport( - rawImportPath: string, - filePath: string, - ctx: ResolveCtx, -): ImportResult { - const csharpConfigs = ctx.configs.csharpConfigs; - if (csharpConfigs.length > 0) { - const resolvedFiles = resolveCSharpImportInternal( - rawImportPath, - csharpConfigs, - ctx.normalizedFileList, - ctx.allFileList, - ctx.index, - ); - if (resolvedFiles.length > 1) { - const dirSuffix = resolveCSharpNamespaceDir(rawImportPath, csharpConfigs); - if (dirSuffix) { - return { kind: 'package', files: resolvedFiles, dirSuffix }; - } - } - if (resolvedFiles.length > 0) return { kind: 'files', files: resolvedFiles }; - } - return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.CSharp); -} diff --git a/gitnexus/src/core/ingestion/import-resolvers/dart.ts b/gitnexus/src/core/ingestion/import-resolvers/dart.ts deleted file mode 100644 index 2a3cf3f6b..000000000 --- a/gitnexus/src/core/ingestion/import-resolvers/dart.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Dart import resolution. - * Handles package: imports (local packages) and relative imports. - * SDK imports (dart:*) and external packages are skipped. - */ - -import type { ImportResult, ResolveCtx } from './types.js'; -import { resolveStandard } from './standard.js'; -import { SupportedLanguages } from 'gitnexus-shared'; - -export function resolveDartImport( - rawImportPath: string, - filePath: string, - ctx: ResolveCtx, -): ImportResult { - // Strip surrounding quotes from configurable_uri capture - const stripped = rawImportPath.replace(/^['"]|['"]$/g, ''); - - // Skip dart: SDK imports (dart:async, dart:io, etc.) - if (stripped.startsWith('dart:')) return null; - - // Local package: imports → resolve to lib/ - if (stripped.startsWith('package:')) { - const slashIdx = stripped.indexOf('/'); - if (slashIdx === -1) return null; - const relPath = stripped.slice(slashIdx + 1); - const candidates = [`lib/${relPath}`, relPath]; - const files: string[] = []; - for (const candidate of candidates) { - for (const fp of ctx.allFileList) { - if (fp.endsWith('/' + candidate) || fp === candidate) { - files.push(fp); - break; - } - } - if (files.length > 0) break; - } - if (files.length > 0) return { kind: 'files', files }; - return null; - } - - // Relative imports — use standard resolution. - // Dart relative imports don't require a leading "./" (e.g. `import 'models.dart'`). - // The standard resolver only recognises paths starting with "." as relative, so - // prepend "./" when the path doesn't already start with "." to ensure correct - // same-directory resolution (without this, "models.dart" would be mangled by the - // generic dot-to-slash conversion intended for Java-style package imports). - const relPath = stripped.startsWith('.') ? stripped : './' + stripped; - return resolveStandard(relPath, filePath, ctx, SupportedLanguages.Dart); -} diff --git a/gitnexus/src/core/ingestion/import-resolvers/go.ts b/gitnexus/src/core/ingestion/import-resolvers/go.ts index 1b1eb3ebc..c33c46422 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/go.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/go.ts @@ -1,11 +1,10 @@ /** - * Go package import resolution. - * Handles Go module path-based package imports. + * Go package import resolution — internal helpers. + * + * Strategy lives in configs/go.ts. + * This file contains the shared helpers used by the strategy. */ -import { SupportedLanguages } from 'gitnexus-shared'; -import type { ImportResult, ResolveCtx } from './types.js'; -import { resolveStandard } from './standard.js'; import type { GoModuleConfig } from '../language-config.js'; /** @@ -56,28 +55,3 @@ export function resolveGoPackage( return matches; } - -/** Go: package-level imports via go.mod module path. */ -export function resolveGoImport( - rawImportPath: string, - filePath: string, - ctx: ResolveCtx, -): ImportResult { - const goModule = ctx.configs.goModule; - if (goModule && rawImportPath.startsWith(goModule.modulePath)) { - const pkgSuffix = resolveGoPackageDir(rawImportPath, goModule); - if (pkgSuffix) { - const pkgFiles = resolveGoPackage( - rawImportPath, - goModule, - ctx.normalizedFileList, - ctx.allFileList, - ); - if (pkgFiles.length > 0) { - return { kind: 'package', files: pkgFiles, dirSuffix: pkgSuffix }; - } - } - // Fall through if no files found (package might be external) - } - return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Go); -} diff --git a/gitnexus/src/core/ingestion/import-resolvers/jvm.ts b/gitnexus/src/core/ingestion/import-resolvers/jvm.ts index 2dd7636ab..194cfdac8 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/jvm.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/jvm.ts @@ -1,13 +1,13 @@ /** - * JVM import resolution (Java + Kotlin). - * Handles wildcard imports, member/static imports, and Kotlin-specific patterns. + * JVM import resolution — internal helpers (Java + Kotlin). + * + * Strategies live in configs/jvm.ts. + * This file contains shared helpers for wildcard/member resolution + * and the Kotlin wildcard preprocessor. */ import type { SuffixIndex } from './utils.js'; import type { SyntaxNode } from '../utils/ast-helpers.js'; -import { SupportedLanguages } from 'gitnexus-shared'; -import type { ImportResult, ResolveCtx } from './types.js'; -import { resolveStandard } from './standard.js'; /** Kotlin file extensions for JVM resolver reuse */ export const KOTLIN_EXTENSIONS: readonly string[] = ['.kt', '.kts']; @@ -125,108 +125,3 @@ export function resolveJvmMemberImport( return null; } - -/** Java: JVM wildcard -> member import -> standard fallthrough */ -export function resolveJavaImport( - rawImportPath: string, - filePath: string, - ctx: ResolveCtx, -): ImportResult { - if (rawImportPath.endsWith('.*')) { - const matchedFiles = resolveJvmWildcard( - rawImportPath, - ctx.normalizedFileList, - ctx.allFileList, - ['.java'], - ctx.index, - ); - if (matchedFiles.length > 0) return { kind: 'files', files: matchedFiles }; - } else { - const memberResolved = resolveJvmMemberImport( - rawImportPath, - ctx.normalizedFileList, - ctx.allFileList, - ['.java'], - ctx.index, - ); - if (memberResolved) return { kind: 'files', files: [memberResolved] }; - } - return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Java); -} - -/** - * Kotlin: JVM wildcard/member with Java-interop fallback -> top-level function imports -> standard. - * Kotlin can import from .kt/.kts files OR from .java files (Java interop). - */ -export function resolveKotlinImport( - rawImportPath: string, - filePath: string, - ctx: ResolveCtx, -): ImportResult { - if (rawImportPath.endsWith('.*')) { - const matchedFiles = resolveJvmWildcard( - rawImportPath, - ctx.normalizedFileList, - ctx.allFileList, - KOTLIN_EXTENSIONS, - ctx.index, - ); - if (matchedFiles.length === 0) { - const javaMatches = resolveJvmWildcard( - rawImportPath, - ctx.normalizedFileList, - ctx.allFileList, - ['.java'], - ctx.index, - ); - if (javaMatches.length > 0) return { kind: 'files', files: javaMatches }; - } - if (matchedFiles.length > 0) return { kind: 'files', files: matchedFiles }; - } else { - let memberResolved = resolveJvmMemberImport( - rawImportPath, - ctx.normalizedFileList, - ctx.allFileList, - KOTLIN_EXTENSIONS, - ctx.index, - ); - if (!memberResolved) { - memberResolved = resolveJvmMemberImport( - rawImportPath, - ctx.normalizedFileList, - ctx.allFileList, - ['.java'], - ctx.index, - ); - } - if (memberResolved) return { kind: 'files', files: [memberResolved] }; - - // Kotlin: top-level function imports (e.g. import models.getUser) have only 2 segments, - // which resolveJvmMemberImport skips (requires >=3). Fall back to package-directory scan - // for lowercase last segments (function/property imports). Uppercase last segments - // (class imports like models.User) fall through to standard suffix resolution. - const segments = rawImportPath.split('.'); - const lastSeg = segments[segments.length - 1]; - if (segments.length >= 2 && lastSeg[0] && lastSeg[0] === lastSeg[0].toLowerCase()) { - const pkgWildcard = segments.slice(0, -1).join('.') + '.*'; - let dirFiles = resolveJvmWildcard( - pkgWildcard, - ctx.normalizedFileList, - ctx.allFileList, - KOTLIN_EXTENSIONS, - ctx.index, - ); - if (dirFiles.length === 0) { - dirFiles = resolveJvmWildcard( - pkgWildcard, - ctx.normalizedFileList, - ctx.allFileList, - ['.java'], - ctx.index, - ); - } - if (dirFiles.length > 0) return { kind: 'files', files: dirFiles }; - } - } - return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Kotlin); -} diff --git a/gitnexus/src/core/ingestion/import-resolvers/php.ts b/gitnexus/src/core/ingestion/import-resolvers/php.ts index 20517bbed..303bf5546 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/php.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/php.ts @@ -1,11 +1,12 @@ /** - * PHP PSR-4 import resolution. - * Handles use-statement resolution via composer.json autoload mappings. + * PHP PSR-4 import resolution — internal helpers. + * + * Strategy lives in configs/php.ts. + * This file contains the shared helper for PSR-4 resolution via composer.json. */ import type { SuffixIndex } from './utils.js'; import { suffixResolve } from './utils.js'; -import type { ImportResult, ResolveCtx } from './types.js'; import type { ComposerConfig } from '../language-config.js'; /** Get or compute the sorted PSR-4 entries (cached after first call). */ @@ -91,20 +92,3 @@ export function resolvePhpImportInternal( const pathParts = normalized.split('/').filter(Boolean); return suffixResolve(pathParts, normalizedFileList, allFileList, index); } - -/** PHP: namespace-based resolution via composer.json PSR-4. */ -export function resolvePhpImport( - rawImportPath: string, - _filePath: string, - ctx: ResolveCtx, -): ImportResult { - const resolved = resolvePhpImportInternal( - rawImportPath, - ctx.configs.composerConfig, - ctx.allFilePaths, - ctx.normalizedFileList, - ctx.allFileList, - ctx.index, - ); - return resolved ? { kind: 'files', files: [resolved] } : null; -} diff --git a/gitnexus/src/core/ingestion/import-resolvers/python.ts b/gitnexus/src/core/ingestion/import-resolvers/python.ts index 4e9c4bbb3..264103a09 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/python.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/python.ts @@ -1,12 +1,12 @@ /** * Python import resolution — PEP 328 relative imports and proximity-based bare imports. * Import system spec: PEP 302 (original), PEP 451 (current). + * + * Strategy lives in configs/python.ts. + * This file contains the shared internal helper used by the strategy and tests. */ import { tryResolveWithExtensions } from './utils.js'; -import { SupportedLanguages } from 'gitnexus-shared'; -import type { ImportResult, ResolveCtx } from './types.js'; -import { resolveStandard } from './standard.js'; /** * Resolve a Python import to a file path (low-level helper). @@ -74,23 +74,3 @@ export function resolvePythonImportInternal( return null; } - -/** - * Python: relative imports (PEP 328) + proximity-based bare imports. - * Falls through to standard suffix resolution when proximity finds no match. - */ -export function resolvePythonImport( - rawImportPath: string, - filePath: string, - ctx: ResolveCtx, -): ImportResult { - const resolved = resolvePythonImportInternal(filePath, rawImportPath, ctx.allFilePaths); - if (resolved) { - // Store in resolveCache so other files importing the same module skip the - // ancestor walk. The cache key matches resolveStandard's convention. - ctx.resolveCache.set(`${filePath}::${rawImportPath}`, resolved); - return { kind: 'files', files: [resolved] }; - } - if (rawImportPath.startsWith('.')) return null; // relative but unresolved -- don't suffix-match - return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Python); -} diff --git a/gitnexus/src/core/ingestion/import-resolvers/resolver-factory.ts b/gitnexus/src/core/ingestion/import-resolvers/resolver-factory.ts new file mode 100644 index 000000000..4caf748a7 --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/resolver-factory.ts @@ -0,0 +1,35 @@ +/** + * Import resolver factory — creates a composable import resolver from + * an ordered list of strategies. + * + * Mirrors the method-extractors/generic.ts and call-extractors/generic.ts + * pattern: declare a config per language, produce a runtime resolver via factory. + * + * Each strategy is tried in order. The first non-null result wins. + * A result with an empty `files` array is treated as "handled but unresolved" + * (stops the chain without producing import edges). + */ + +import type { ImportResolverFn, ImportResolutionConfig } from './types.js'; + +/** + * Create an ImportResolverFn from a declarative config. + * + * Chains strategies in declaration order — first non-null result wins. + * Returns null only if every strategy returns null. + * + * Error behaviour: if a strategy throws, the error propagates immediately + * and remaining strategies are not tried. Strategies are expected to be + * pure data transforms that never throw; any unexpected exception indicates + * a bug in the strategy implementation. + */ +export function createImportResolver(config: ImportResolutionConfig): ImportResolverFn { + const { strategies } = config; + return (rawImportPath, filePath, ctx) => { + for (const strategy of strategies) { + const result = strategy(rawImportPath, filePath, ctx); + if (result) return result; + } + return null; + }; +} diff --git a/gitnexus/src/core/ingestion/import-resolvers/ruby.ts b/gitnexus/src/core/ingestion/import-resolvers/ruby.ts index b8ada4cf4..4bf47d31f 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/ruby.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/ruby.ts @@ -1,11 +1,12 @@ /** - * Ruby require/require_relative import resolution. - * Handles path resolution for Ruby's require and require_relative calls. + * Ruby require/require_relative import resolution — internal helpers. + * + * Strategy lives in configs/ruby.ts. + * This file only contains the low-level helper used by the strategy. */ import type { SuffixIndex } from './utils.js'; import { suffixResolve } from './utils.js'; -import type { ImportResult, ResolveCtx } from './types.js'; /** * Resolve a Ruby require/require_relative path to a matching .rb file (low-level helper). @@ -22,18 +23,3 @@ export function resolveRubyImportInternal( const pathParts = importPath.replace(/^\.\//, '').split('/').filter(Boolean); return suffixResolve(pathParts, normalizedFileList, allFileList, index); } - -/** Ruby: require / require_relative. */ -export function resolveRubyImport( - rawImportPath: string, - _filePath: string, - ctx: ResolveCtx, -): ImportResult { - const resolved = resolveRubyImportInternal( - rawImportPath, - ctx.normalizedFileList, - ctx.allFileList, - ctx.index, - ); - return resolved ? { kind: 'files', files: [resolved] } : null; -} diff --git a/gitnexus/src/core/ingestion/import-resolvers/rust.ts b/gitnexus/src/core/ingestion/import-resolvers/rust.ts index 632e19352..2d1dd864a 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/rust.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/rust.ts @@ -1,12 +1,10 @@ /** - * Rust module import resolution. - * Handles crate::, super::, self:: prefix paths and :: separators. + * Rust module import resolution — internal helpers. + * + * Strategy lives in configs/rust.ts. + * This file contains shared helpers used by the strategy and standard.ts. */ -import { SupportedLanguages } from 'gitnexus-shared'; -import type { ImportResult, ResolveCtx } from './types.js'; -import { resolveStandard } from './standard.js'; - /** * Resolve Rust use-path to a file (low-level helper). * Handles crate::, super::, self:: prefixes and :: path separators. @@ -84,49 +82,3 @@ export function tryRustModulePath(modulePath: string, allFiles: Set): st return null; } - -/** Rust: expand grouped imports: use {crate::a, crate::b} and use crate::models::{User, Repo}. */ -export function resolveRustImport( - rawImportPath: string, - filePath: string, - ctx: ResolveCtx, -): ImportResult { - // Top-level grouped: use {crate::a, crate::b} - if (rawImportPath.startsWith('{') && rawImportPath.endsWith('}')) { - const inner = rawImportPath.slice(1, -1); - const parts = inner - .split(',') - .map((p) => p.trim()) - .filter(Boolean); - const resolved: string[] = []; - for (const part of parts) { - const r = resolveRustImportInternal(filePath, part, ctx.allFilePaths); - if (r) resolved.push(r); - } - return resolved.length > 0 ? { kind: 'files', files: resolved } : null; - } - - // Scoped grouped: use crate::models::{User, Repo} - const braceIdx = rawImportPath.indexOf('::{'); - if (braceIdx !== -1 && rawImportPath.endsWith('}')) { - const pathPrefix = rawImportPath.substring(0, braceIdx); - const braceContent = rawImportPath.substring(braceIdx + 3, rawImportPath.length - 1); - const items = braceContent - .split(',') - .map((s) => s.trim()) - .filter(Boolean); - const resolved: string[] = []; - for (const item of items) { - // Handle `use crate::models::{User, Repo as R}` — strip alias for resolution - const itemName = item.includes(' as ') ? item.split(' as ')[0].trim() : item; - const r = resolveRustImportInternal(filePath, `${pathPrefix}::${itemName}`, ctx.allFilePaths); - if (r) resolved.push(r); - } - if (resolved.length > 0) return { kind: 'files', files: resolved }; - // Fallback: resolve the prefix path itself (e.g. crate::models -> models.rs) - const prefixResult = resolveRustImportInternal(filePath, pathPrefix, ctx.allFilePaths); - if (prefixResult) return { kind: 'files', files: [prefixResult] }; - } - - return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Rust); -} diff --git a/gitnexus/src/core/ingestion/import-resolvers/standard.ts b/gitnexus/src/core/ingestion/import-resolvers/standard.ts index a29810a2f..f8aae9625 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/standard.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/standard.ts @@ -8,7 +8,7 @@ import type { SuffixIndex } from './utils.js'; import { tryResolveWithExtensions, suffixResolve } from './utils.js'; import { resolveRustImportInternal } from './rust.js'; import { SupportedLanguages } from 'gitnexus-shared'; -import type { ImportResult, ImportResolverFn, ResolveCtx } from './types.js'; +import type { ImportResult, ImportResolverStrategy, ResolveCtx } from './types.js'; import type { TsconfigPaths } from '../language-config.js'; /** Max entries in the resolve cache. Beyond this, entries are evicted. @@ -174,18 +174,11 @@ export function resolveStandard( return resolvedPath ? { kind: 'files', files: [resolvedPath] } : null; } -/** JavaScript: standard single-file resolution. */ -export const resolveJavascriptImport: ImportResolverFn = (raw, fp, ctx) => - resolveStandard(raw, fp, ctx, SupportedLanguages.JavaScript); +// ============================================================================ +// Strategy factory — composable hook for ImportResolutionConfig +// ============================================================================ -/** TypeScript: standard single-file resolution. */ -export const resolveTypescriptImport: ImportResolverFn = (raw, fp, ctx) => - resolveStandard(raw, fp, ctx, SupportedLanguages.TypeScript); - -/** C: standard single-file resolution for #include directives. */ -export const resolveCImport: ImportResolverFn = (raw, fp, ctx) => - resolveStandard(raw, fp, ctx, SupportedLanguages.C); - -/** C++: standard single-file resolution for #include directives. */ -export const resolveCppImport: ImportResolverFn = (raw, fp, ctx) => - resolveStandard(raw, fp, ctx, SupportedLanguages.CPlusPlus); +/** Create a reusable standard-resolution strategy for a given language. */ +export function createStandardStrategy(language: SupportedLanguages): ImportResolverStrategy { + return (raw, fp, ctx) => resolveStandard(raw, fp, ctx, language); +} diff --git a/gitnexus/src/core/ingestion/import-resolvers/types.ts b/gitnexus/src/core/ingestion/import-resolvers/types.ts index 3e68c38b2..66e23a79b 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/types.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/types.ts @@ -12,6 +12,7 @@ import type { } from '../language-config.js'; import type { SwiftPackageConfig } from '../language-config.js'; import type { SuffixIndex } from './utils.js'; +import type { SupportedLanguages } from 'gitnexus-shared'; /** * Result of resolving an import via language-specific dispatch. @@ -53,3 +54,28 @@ export type ImportResolverFn = ( filePath: string, resolveCtx: ResolveCtx, ) => ImportResult; + +/** + * A single import resolution strategy — one step in a composable chain. + * Same signature as ImportResolverFn. Returns null to let the next strategy + * in the chain try; returns a result (even with empty files) to stop the chain. + */ +export type ImportResolverStrategy = ImportResolverFn; + +/** + * Declarative config for composable import resolution — mirrors the + * MethodExtractionConfig / CallExtractionConfig pattern. + * + * Each language declares an ordered list of strategies to try. + * The factory (`createImportResolver`) chains them: first non-null result wins. + */ +export interface ImportResolutionConfig { + /** + * Documentation-only metadata identifying which language this config serves. + * **Not used by `createImportResolver`** — the factory only iterates `strategies`. + * Useful for logging, debugging, and compile-time exhaustiveness checks when + * mapping `SupportedLanguages → ImportResolutionConfig` in language providers. + */ + readonly language: SupportedLanguages; + readonly strategies: readonly ImportResolverStrategy[]; +} diff --git a/gitnexus/src/core/ingestion/import-resolvers/vue.ts b/gitnexus/src/core/ingestion/import-resolvers/vue.ts deleted file mode 100644 index c46e3f725..000000000 --- a/gitnexus/src/core/ingestion/import-resolvers/vue.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Vue import resolver — delegates to TypeScript's standard resolver. - * - * Vue