From b5d358f5005fc589e9a6d4165cce739698080530 Mon Sep 17 00:00:00 2001 From: Randy Wilson Date: Fri, 21 Aug 2026 19:14:06 -0400 Subject: [PATCH 1/4] Give every symbol a real identity and kill the zombie nodes for good Symbol identity is no longer a line number in disguise. Every Function, Class, Interface, Variable, Type, and Component now carries an opaque sym:v1 id hashed from its label, normalized path, lexical scope chain, declared name, and disambiguator, with length-prefixed encoding so no name or path can collide. Line numbers become mutable properties: editing a file no longer changes what a symbol IS. Scope is part of identity: members carry their owner chain, nested declarations their lexical chain, overloads a signature hash (class method overload declarations are now extracted at all, with calls binding to the one runtime implementation), declaration-merged forms share one id, and true duplicates fall back to a bounded ordinal. The pipeline builds a project symbol catalog and resolves every edge to endpoint ids before writing; unresolved edges are dropped, never guessed. The graph merges by id, sweeps stale ids from changed files even when inbound edges pin them, notices deleted files by set-difference, and deletes projects by ownership stamp instead of reachability, so force reindex genuinely resets. Ids flow unchanged through search (including the no-embedding text fallback), references, file relationships, embeddings, and the dashboard, which no longer synthesizes ids at all. Legacy idless knowledge entities are backfilled on write and tolerated on read. The proof the whole campaign was building toward now passes: shift a function with inbound CALLS and IMPORTS_SYMBOL edges down the file, reindex incrementally, and it is still one node with the same id, a new line number, and both edges intact. Two adversarial review rounds drove out six defects before merge, four of them integration seams between individually verified components. Co-Authored-By: Claude Fable 5 --- .../api/src/__tests__/graph-route.test.ts | 22 +- .../api/src/__tests__/search-route.test.ts | 57 +- packages/api/src/routes/graph.ts | 32 +- packages/api/src/routes/search.ts | 6 +- .../dependency-depth.integration.test.ts | 18 +- .../src/__tests__/embed-pass-node-id.test.ts | 89 ++ .../enrich-from-graph.integration.test.ts | 24 +- .../__tests__/enriched-search-node-id.test.ts | 41 + .../indexer-deleted-file.integration.test.ts | 124 ++ ...r-overload-call-target.integration.test.ts | 102 ++ ...er-single-file-preserves-file-node.test.ts | 4 + .../src/__tests__/linked-knowledge.test.ts | 14 +- .../src/__tests__/neighbor-expansion.test.ts | 24 +- .../__tests__/node-identity-catalog.test.ts | 50 + ...de-identity-final-gate.integration.test.ts | 206 ++++ .../pipeline-barrel-resolution.test.ts | 87 +- ...e-exports-and-imports-symbol-edges.test.ts | 17 +- .../pipeline-python-cross-file-calls.test.ts | 47 +- .../pipeline-python-review-wave-b.test.ts | 78 +- ...peline-tier2-ruby-calls-regression.test.ts | 11 +- packages/core/src/__tests__/service.test.ts | 32 +- .../src/__tests__/services-helpers.test.ts | 17 +- packages/core/src/embed-nodes.ts | 42 +- packages/core/src/embed-pass.ts | 17 +- packages/core/src/enrichedSearchV2.ts | 149 ++- packages/core/src/indexer.ts | 138 ++- packages/core/src/pipeline/pipeline.ts | 405 ++++++- .../core/src/services/graph-data-service.ts | 34 +- packages/core/src/services/helpers.ts | 5 +- .../src/components/dashboard/app-shell.tsx | 53 +- .../components/dashboard/entity-detail.tsx | 7 +- .../dashboard/explorer-navigation.test.tsx | 42 +- .../src/components/dashboard/graph-canvas.tsx | 44 +- .../dashboard/graph-explorer.test.tsx | 4 +- .../node-identity-source-guard.test.ts | 24 + .../dashboard/search-panel.test.tsx | 16 +- .../src/components/dashboard/search-panel.tsx | 26 +- packages/dashboard/src/lib/references.ts | 84 +- .../src/__tests__/calls-edge-by-class.test.ts | 18 +- .../graph/src/__tests__/falkordblite.test.ts | 3 + .../src/__tests__/full-graph-window.test.ts | 70 +- ...legacy-entity-identity.integration.test.ts | 91 ++ .../node-identity.integration.test.ts | 465 ++++++++ .../__tests__/remove-file-contents.test.ts | 44 +- .../symbol-references.integration.test.ts | 156 +-- packages/graph/src/drivers/falkordb-shared.ts | 9 +- packages/graph/src/knowledge-operations.ts | 12 +- packages/graph/src/operations.ts | 1019 ++++++++++------- packages/graph/src/queries.ts | 192 ++-- packages/graph/src/schema.ts | 46 +- .../src/__tests__/symbolIdentity.test.ts | 76 ++ packages/plugin-common/src/index.ts | 2 +- packages/plugin-common/src/symbolIdentity.ts | 264 +++++ packages/plugin-generic/src/index.ts | 106 +- .../plugin-go/__tests__/extractors.test.ts | 11 +- packages/plugin-go/src/index.ts | 115 +- .../plugin-languages/src/configs/csharp.ts | 152 ++- packages/plugin-languages/src/configs/java.ts | 80 +- packages/plugin-languages/src/configs/php.ts | 57 +- .../src/configs/symbolIdentity.ts | 66 ++ .../__tests__/extractors.test.ts | 9 +- packages/plugin-python/src/index.ts | 100 +- .../plugin-rust/__tests__/extractors.test.ts | 6 +- packages/plugin-rust/src/index.ts | 99 +- .../__tests__/calls-receiver-binding.test.ts | 72 ++ .../__tests__/extractors.test.ts | 180 ++- .../plugin-typescript/src/extractors/calls.ts | 4 + .../src/extractors/classes.ts | 151 ++- .../src/extractors/functions.ts | 123 +- .../plugin-typescript/src/extractors/index.ts | 30 +- .../src/extractors/inheritance.ts | 8 + .../plugin-typescript/src/extractors/jsx.ts | 57 +- .../src/extractors/renders.ts | 4 + .../src/extractors/type-aliases.ts | 140 ++- .../plugin-typescript/src/extractors/types.ts | 27 + .../src/extractors/variables.ts | 102 +- packages/types/src/edges.ts | 8 + packages/types/src/nodes.ts | 38 +- packages/types/src/plugin.ts | 12 + 79 files changed, 4863 insertions(+), 1553 deletions(-) create mode 100644 packages/core/src/__tests__/embed-pass-node-id.test.ts create mode 100644 packages/core/src/__tests__/enriched-search-node-id.test.ts create mode 100644 packages/core/src/__tests__/indexer-deleted-file.integration.test.ts create mode 100644 packages/core/src/__tests__/indexer-overload-call-target.integration.test.ts create mode 100644 packages/core/src/__tests__/node-identity-catalog.test.ts create mode 100644 packages/core/src/__tests__/node-identity-final-gate.integration.test.ts create mode 100644 packages/dashboard/src/components/dashboard/node-identity-source-guard.test.ts create mode 100644 packages/graph/src/__tests__/legacy-entity-identity.integration.test.ts create mode 100644 packages/graph/src/__tests__/node-identity.integration.test.ts create mode 100644 packages/plugin-common/src/__tests__/symbolIdentity.test.ts create mode 100644 packages/plugin-common/src/symbolIdentity.ts create mode 100644 packages/plugin-languages/src/configs/symbolIdentity.ts diff --git a/packages/api/src/__tests__/graph-route.test.ts b/packages/api/src/__tests__/graph-route.test.ts index a1b557a4..b8371470 100644 --- a/packages/api/src/__tests__/graph-route.test.ts +++ b/packages/api/src/__tests__/graph-route.test.ts @@ -49,6 +49,14 @@ describe('graph route numeric boundaries', () => { }, ); + it('forwards the persisted symbol id as the references lookup identity', async () => { + const id = 'sym:v1:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const response = await graphRoutes.request(`/api/graph/references?id=${encodeURIComponent(id)}`); + + expect(response.status).toBe(200); + expect(mockedReferences).toHaveBeenCalledWith({ id, limit: undefined }); + }); + it('accepts the full graph upper limit', async () => { const response = await graphRoutes.request('/api/graph/full?limit=1000'); @@ -78,16 +86,12 @@ describe('graph route numeric boundaries', () => { }, ); - it.each(['NaN', 'Infinity', '0', '-1', '1.5', '10000001'])( - 'rejects reference startLine=%s before touching the graph', - async (startLine) => { - const result = await errorFor(`/api/graph/references?name=run&startLine=${startLine}`); + it('rejects the removed name-based references lookup', async () => { + const result = await errorFor('/api/graph/references?name=run'); - expect(result.status).toBe(400); - expect(result.error).toBe('startLine must be a positive integer between 1 and 10000000'); - expect(mockedReferences).not.toHaveBeenCalled(); - }, - ); + expect(result).toEqual({ status: 400, error: 'id parameter is required' }); + expect(mockedReferences).not.toHaveBeenCalled(); + }); }); describe('GET /api/graph/file-relationships', () => { diff --git a/packages/api/src/__tests__/search-route.test.ts b/packages/api/src/__tests__/search-route.test.ts index 26761d0b..164fe5e3 100644 --- a/packages/api/src/__tests__/search-route.test.ts +++ b/packages/api/src/__tests__/search-route.test.ts @@ -44,9 +44,9 @@ const KNOWN_LABELS = new Set(['Class', 'Interface', 'Function', 'Variable']); function threeRawHits(): EnrichedV2Result { return { hits: [ - { name: 'GraphClient', nodeType: 'Class' }, - { name: 'ClientOptions', nodeType: 'Interface' }, - { name: 'createGraphClient', nodeType: 'Function' }, + { id: 'sym:v1:' + 'a'.repeat(64), name: 'GraphClient', nodeType: 'Class' }, + { id: 'sym:v1:' + 'b'.repeat(64), name: 'ClientOptions', nodeType: 'Interface' }, + { id: 'sym:v1:' + 'c'.repeat(64), name: 'createGraphClient', nodeType: 'Function' }, ], meta: { query: 'graph client', vectorHits: 3, durationMs: 5 }, }; @@ -148,7 +148,7 @@ describe('GET /api/search: types filter emptying the page', () => { mockedSearch.mockResolvedValue(threeRawHits()); mockedGetGraphClient.mockResolvedValue( fakeGraphClient([ - { name: 'clientCache', nodeType: 'Variable', filePath: '/x.ts', startLine: 1, endLine: 1, isExported: true }, + { id: 'sym:v1:' + 'd'.repeat(64), name: 'clientCache', nodeType: 'Variable', filePath: '/x.ts', startLine: 1, endLine: 1, isExported: true }, ]) as never, ); @@ -190,7 +190,7 @@ describe('GET /api/search: types filter emptying the page', () => { }); mockedGetGraphClient.mockResolvedValue( fakeGraphClient([ - { name: 'clientCache', nodeType: 'Variable', filePath: '/x.ts', startLine: 1, endLine: 1, isExported: true }, + { id: 'sym:v1:' + 'e'.repeat(64), name: 'clientCache', nodeType: 'Variable', filePath: '/x.ts', startLine: 1, endLine: 1, isExported: true }, ]) as never, ); @@ -201,6 +201,53 @@ describe('GET /api/search: types filter emptying the page', () => { expect(body.total).toBe(1); expect(body.notice).toBeUndefined(); }); + + it('returns persisted ids for every row from the text fallback', async () => { + const symbolId = 'sym:v1:' + 'd'.repeat(64); + mockedSearch.mockResolvedValue({ + hits: [], + meta: { query: 'shared', vectorHits: 0, durationMs: 2 }, + }); + const client = fakeGraphClient([ + { + id: symbolId, + name: 'shared', + nodeType: 'Function', + filePath: '/repo/src/shared.ts', + startLine: 4, + endLine: 6, + isExported: true, + }, + { + id: 'File:/repo/src/shared.ts', + name: 'shared.ts', + nodeType: 'File', + filePath: '/repo/src/shared.ts', + startLine: null, + endLine: null, + isExported: null, + }, + ]); + mockedGetGraphClient.mockResolvedValue(client as never); + + const { status, body } = await searchJson('q=shared&limit=3'); + + expect(status).toBe(200); + expect(body.fallback).toBe(true); + const results = body.results as Array>; + expect(results).toEqual([ + expect.objectContaining({ id: symbolId, nodeType: 'Function' }), + expect.objectContaining({ id: 'File:/repo/src/shared.ts', nodeType: 'File' }), + ]); + for (const row of results) { + expect(row.id).toEqual(expect.stringMatching(/^(?:sym:v1:[a-f0-9]{64}|File:.+)$/)); + } + expect(client.roQuery).toHaveBeenCalledWith( + expect.stringContaining('RETURN n.id AS id'), + expect.objectContaining({ params: { q: 'shared', limit: 3 } }), + ); + expect(client.roQuery.mock.calls[0]?.[0]).toContain('AND n.id IS NOT NULL'); + }); }); describe('GET /api/search: malformed types values (third adversarial-review finding)', () => { diff --git a/packages/api/src/routes/graph.ts b/packages/api/src/routes/graph.ts index b78d1f29..14ee7af4 100644 --- a/packages/api/src/routes/graph.ts +++ b/packages/api/src/routes/graph.ts @@ -8,8 +8,8 @@ export const graphRoutes = new Hono(); const FULL_GRAPH_LIMIT_MAX = 1000; const FILE_RELATIONSHIP_LIMIT_MAX = 500; const REFERENCE_LIMIT_MAX = 1000; -const REFERENCE_START_LINE_MAX = 10_000_000; const DEPENDENCY_DEPTH_MAX = 10; +const SYMBOL_ID_PATTERN = /^sym:v1:[a-f0-9]{64}$/; type BoundedIntegerResult = | { valid: true; value?: number } @@ -108,34 +108,24 @@ graphRoutes.get('/api/graph/file', async (c) => { }); /** - * GET /api/graph/references?name=X&path=Y&startLine=N&limit=M + * GET /api/graph/references?id=X&limit=M * - * Where a symbol is used. Matches every node with this name, not just one, so - * references that land on a type-reference proxy node are included alongside - * ones that land on the declaration itself. `path` and `startLine` are - * optional and disambiguate between distinct declarations that share a name; - * a matched node with no location of its own (a proxy node) is never excluded - * by them. + * Where a symbol is used. The persisted symbol id is the sole declaration + * identity accepted by this endpoint. */ graphRoutes.get('/api/graph/references', async (c) => { try { - const name = c.req.query('name'); - if (!name) return c.json({ error: 'name parameter is required' }, 400); - - const parsedLine = boundedPositiveInteger( - c.req.query('startLine'), - 'startLine', - REFERENCE_START_LINE_MAX, - ); - if (!parsedLine.valid) return c.json({ error: parsedLine.error }, 400); - const parsedLimit = boundedPositiveInteger(c.req.query('limit'), 'limit', REFERENCE_LIMIT_MAX); if (!parsedLimit.valid) return c.json({ error: parsedLimit.error }, 400); + const id = c.req.query('id'); + if (!id) return c.json({ error: 'id parameter is required' }, 400); + if (!SYMBOL_ID_PATTERN.test(id)) { + return c.json({ error: 'id must be a persisted sym:v1 identifier' }, 400); + } + const data = await codeGraphService.getSymbolReferences({ - name, - filePath: c.req.query('path'), - startLine: parsedLine.value, + id, limit: parsedLimit.value, }); return c.json(data); diff --git a/packages/api/src/routes/search.ts b/packages/api/src/routes/search.ts index f70d1f46..1ec4fd11 100644 --- a/packages/api/src/routes/search.ts +++ b/packages/api/src/routes/search.ts @@ -249,6 +249,7 @@ searchRoutes.get('/api/search', async (c) => { : 'n:Function OR n:Class OR n:Interface OR n:Component OR n:Type OR n:Variable OR n:File'; const rows = await client.roQuery<{ + id: string; name: string; nodeType: string; filePath: string | null; @@ -258,8 +259,10 @@ searchRoutes.get('/api/search', async (c) => { }>( `MATCH (n) WHERE (${typeFilter}) + AND n.id IS NOT NULL AND (toLower(n.name) CONTAINS toLower($q) OR toLower(n.filePath) CONTAINS toLower($q)) - RETURN n.name AS name, + RETURN n.id AS id, + n.name AS name, labels(n)[0] AS nodeType, n.filePath AS filePath, n.startLine AS startLine, @@ -278,6 +281,7 @@ searchRoutes.get('/api/search', async (c) => { return c.json({ results: rows.data.map(r => ({ + id: r.id, name: r.name, nodeType: r.nodeType, filePath: r.filePath, diff --git a/packages/core/src/__tests__/dependency-depth.integration.test.ts b/packages/core/src/__tests__/dependency-depth.integration.test.ts index fd45b6f8..39626127 100644 --- a/packages/core/src/__tests__/dependency-depth.integration.test.ts +++ b/packages/core/src/__tests__/dependency-depth.integration.test.ts @@ -51,7 +51,7 @@ describeIfAvailable('dependency depth enrichment', () => { await client.query( `UNWIND range(0, $hub - 1) AS i MATCH (lib:File {filePath: '/x/lib.ts'}) - CREATE (f:Function {name: '_parse', filePath: '/x/lib.ts', startLine: i + 1}) + CREATE (f:Function {id: 'hub-' + toString(i), name: '_parse', filePath: '/x/lib.ts', startLine: i + 1}) CREATE (lib)-[:CONTAINS]->(f)`, { params: { hub: HUB_SIZE } }, ); @@ -65,7 +65,7 @@ describeIfAvailable('dependency depth enrichment', () => { // A genuinely reachable symbol, to pin the values the query returns. await client.query(` MATCH (other:File {filePath: '/x/other.ts'}) - CREATE (r:Function {name: 'reachable', filePath: '/x/other.ts', startLine: 1}) + CREATE (r:Function {id: 'id-reachable', name: 'reachable', filePath: '/x/other.ts', startLine: 1}) CREATE (other)-[:CONTAINS]->(r) `); }, 60_000); @@ -77,9 +77,9 @@ describeIfAvailable('dependency depth enrichment', () => { it('answers for an unreachable hub symbol well inside the budget', async () => { const started = Date.now(); - const result = await client.roQuery<{ symbolName: string; minDepth: number | null }>( + const result = await client.roQuery<{ symbolId: string; minDepth: number | null }>( DEPENDENCY_DEPTH_CYPHER, - { params: { names: ['_parse'] }, timeout: BUDGET_MS }, + { params: { ids: ['hub-0'] }, timeout: BUDGET_MS }, ); expect(Date.now() - started).toBeLessThan(BUDGET_MS); // Unreachable symbols yield no row, which the caller reads as "depth unknown". @@ -87,9 +87,9 @@ describeIfAvailable('dependency depth enrichment', () => { }); it('reports the depth of a reachable symbol', async () => { - const result = await client.roQuery<{ symbolName: string; minDepth: number | null }>( + const result = await client.roQuery<{ symbolId: string; minDepth: number | null }>( DEPENDENCY_DEPTH_CYPHER, - { params: { names: ['reachable'] }, timeout: BUDGET_MS }, + { params: { ids: ['id-reachable'] }, timeout: BUDGET_MS }, ); expect(result.data).toHaveLength(1); expect(result.data[0]?.minDepth).toBe(1); @@ -97,11 +97,11 @@ describeIfAvailable('dependency depth enrichment', () => { it('stays fast when a hub symbol is batched with ordinary ones', async () => { const started = Date.now(); - const result = await client.roQuery<{ symbolName: string; minDepth: number | null }>( + const result = await client.roQuery<{ symbolId: string; minDepth: number | null }>( DEPENDENCY_DEPTH_CYPHER, - { params: { names: ['reachable', '_parse', 'missing'] }, timeout: BUDGET_MS }, + { params: { ids: ['id-reachable', 'hub-0', 'id-missing'] }, timeout: BUDGET_MS }, ); expect(Date.now() - started).toBeLessThan(BUDGET_MS); - expect(result.data.map((r) => r.symbolName)).toEqual(['reachable']); + expect(result.data.map((r) => r.symbolId)).toEqual(['id-reachable']); }); }); diff --git a/packages/core/src/__tests__/embed-pass-node-id.test.ts b/packages/core/src/__tests__/embed-pass-node-id.test.ts new file mode 100644 index 00000000..2415b652 --- /dev/null +++ b/packages/core/src/__tests__/embed-pass-node-id.test.ts @@ -0,0 +1,89 @@ +import { createHash } from 'node:crypto'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { GraphOperations } from '@codegraph/graph'; +import type { ParsedFileEntities } from '@codegraph/types'; +import { buildFunctionEmbeddingText, generateEmbeddings } from '@codegraph/plugin-nlp'; +import { embedAllParsedEntities } from '../embed-pass'; + +vi.mock('@codegraph/plugin-nlp', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isEmbeddingAvailable: () => true, + generateEmbeddings: vi.fn().mockResolvedValue({ embeddings: [[0.1, 0.2]] }), + }; +}); + +const persistedId = `sym:v1:${'a'.repeat(64)}`; +const functionEntity: ParsedFileEntities['functions'][number] = { + id: persistedId, + scopeKey: '', + disambiguator: '', + name: 'stable', + filePath: '/project/stable.ts', + startLine: 80, + endLine: 84, + isExported: true, + isAsync: false, + isArrow: false, + params: [], + returnType: 'number', + bodySnippet: 'return 1;', +}; + +const parsed: ParsedFileEntities = { + file: { + path: '/project/stable.ts', + name: 'stable.ts', + extension: 'ts', + loc: 84, + lastModified: new Date(0).toISOString(), + hash: 'hash', + }, + functions: [functionEntity], + classes: [], + interfaces: [], + variables: [], + types: [], + components: [], + imports: [], + callEdges: [], + importsEdges: [], + extendsEdges: [], + implementsEdges: [], + rendersEdges: [], + hasMethodEdges: [], + hasPropertyEdges: [], + typeRefs: [], + hasParamEdges: [], + returnsEdges: [], + usesTypeEdges: [], + exportsEdges: [], + importsSymbolEdges: [], +}; + +describe('embedding cache identity', () => { + beforeEach(() => { + vi.mocked(generateEmbeddings).mockClear(); + }); + + it('does not re-embed unchanged content when the same persisted id moves lines', async () => { + const text = buildFunctionEmbeddingText(functionEntity); + const hash = createHash('sha256').update(text).digest('hex'); + const ops = { + getEmbeddingHashesForFiles: vi.fn(), + batchUpdateEmbeddings: vi.fn(), + updateEmbedding: vi.fn(), + } as unknown as GraphOperations; + + const result = await embedAllParsedEntities( + [parsed], + ops, + { provider: 'local' }, + new Map([[persistedId, hash]]), + ); + + expect(result).toMatchObject({ embedded: 0, skipped: 1 }); + expect(generateEmbeddings).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/__tests__/enrich-from-graph.integration.test.ts b/packages/core/src/__tests__/enrich-from-graph.integration.test.ts index f4949dd3..33cd71ed 100644 --- a/packages/core/src/__tests__/enrich-from-graph.integration.test.ts +++ b/packages/core/src/__tests__/enrich-from-graph.integration.test.ts @@ -28,7 +28,7 @@ import { enrichFromGraph, type Candidate } from '../enrichedSearchV2'; // The embedded driver ships binaries for darwin-arm64 and linux-x64 only. const describeIfAvailable = resolveEmbeddedBinaryPaths() ? describe : describe.skip; -function candidate(over: Partial & { name: string; filePath: string; startLine: number }): Candidate { +function candidate(over: Partial & { id: string; name: string; filePath: string; startLine: number }): Candidate { return { nodeType: 'Function', properties: {}, @@ -55,8 +55,8 @@ describeIfAvailable('enrichFromGraph', () => { // no inbound one at all, so its caller count is genuinely zero. await client.query(` CREATE (f:File {filePath: '/x/a.ts', name: 'a.ts'}) - CREATE (callsButNotCalled:Function {name: 'callsButNotCalled', filePath: '/x/a.ts', startLine: 5}) - CREATE (callee:Function {name: 'calleeOfIt', filePath: '/x/a.ts', startLine: 9}) + CREATE (callsButNotCalled:Function {id: 'id-caller', name: 'callsButNotCalled', filePath: '/x/a.ts', startLine: 5}) + CREATE (callee:Function {id: 'id-callee', name: 'calleeOfIt', filePath: '/x/a.ts', startLine: 9}) CREATE (f)-[:CONTAINS]->(callsButNotCalled) CREATE (f)-[:CONTAINS]->(callee) CREATE (callsButNotCalled)-[:CALLS]->(callee) @@ -69,10 +69,10 @@ describeIfAvailable('enrichFromGraph', () => { await client.query(` CREATE (fb:File {filePath: '/x/b.ts', name: 'b.ts'}) CREATE (fc:File {filePath: '/x/c.ts', name: 'c.ts'}) - CREATE (ctorB:Function {name: 'constructor', filePath: '/x/b.ts', startLine: 3}) - CREATE (ctorC:Function {name: 'constructor', filePath: '/x/c.ts', startLine: 30}) - CREATE (callerB1:Function {name: 'makeB1', filePath: '/x/b.ts', startLine: 20}) - CREATE (callerB2:Function {name: 'makeB2', filePath: '/x/b.ts', startLine: 25}) + CREATE (ctorB:Function {id: 'id-ctor-b', name: 'constructor', filePath: '/x/b.ts', startLine: 3}) + CREATE (ctorC:Function {id: 'id-ctor-c', name: 'constructor', filePath: '/x/c.ts', startLine: 30}) + CREATE (callerB1:Function {id: 'id-maker-b1', name: 'makeB1', filePath: '/x/b.ts', startLine: 20}) + CREATE (callerB2:Function {id: 'id-maker-b2', name: 'makeB2', filePath: '/x/b.ts', startLine: 25}) CREATE (fb)-[:CONTAINS]->(ctorB) CREATE (fc)-[:CONTAINS]->(ctorC) CREATE (fb)-[:CONTAINS]->(callerB1) @@ -90,7 +90,7 @@ describeIfAvailable('enrichFromGraph', () => { }); it('reports callees for a symbol that has zero callers (defect 1)', async () => { - const hit = candidate({ name: 'callsButNotCalled', filePath: '/x/a.ts', startLine: 5 }); + const hit = candidate({ id: 'id-caller', name: 'callsButNotCalled', filePath: '/x/a.ts', startLine: 5 }); const result = await enrichFromGraph(client, [hit]); const entries = Array.from(result.values()); expect(entries).toHaveLength(1); @@ -103,7 +103,7 @@ describeIfAvailable('enrichFromGraph', () => { }); it('still reports callers correctly for an ordinary symbol', async () => { - const hit = candidate({ name: 'calleeOfIt', filePath: '/x/a.ts', startLine: 9 }); + const hit = candidate({ id: 'id-callee', name: 'calleeOfIt', filePath: '/x/a.ts', startLine: 9 }); const result = await enrichFromGraph(client, [hit]); const entries = Array.from(result.values()); expect(entries[0]?.callerCount).toBe(1); @@ -111,8 +111,8 @@ describeIfAvailable('enrichFromGraph', () => { }); it('does not mix up two declarations that share a name (defect 2)', async () => { - const hitB = candidate({ name: 'constructor', filePath: '/x/b.ts', startLine: 3 }); - const hitC = candidate({ name: 'constructor', filePath: '/x/c.ts', startLine: 30 }); + const hitB = candidate({ id: 'id-ctor-b', name: 'constructor', filePath: '/x/b.ts', startLine: 3 }); + const hitC = candidate({ id: 'id-ctor-c', name: 'constructor', filePath: '/x/c.ts', startLine: 30 }); const result = await enrichFromGraph(client, [hitB, hitC]); // Both hits must be individually retrievable and keep their own numbers. @@ -127,7 +127,7 @@ describeIfAvailable('enrichFromGraph', () => { }); it('produces no row (not a row of zeros) for a name with no matching node', async () => { - const hit = candidate({ name: 'doesNotExistAnywhere', filePath: '/x/nowhere.ts', startLine: 1 }); + const hit = candidate({ id: 'id-missing', name: 'doesNotExistAnywhere', filePath: '/x/nowhere.ts', startLine: 1 }); const result = await enrichFromGraph(client, [hit]); expect(result.size).toBe(0); }); diff --git a/packages/core/src/__tests__/enriched-search-node-id.test.ts b/packages/core/src/__tests__/enriched-search-node-id.test.ts new file mode 100644 index 00000000..02965595 --- /dev/null +++ b/packages/core/src/__tests__/enriched-search-node-id.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest'; +import { enrichFromGraph, enrichmentKey, type Candidate } from '../enrichedSearchV2'; + +function candidate(id: string): Candidate { + return { + id, + name: 'constructor', + nodeType: 'Function', + filePath: '/project/widget.ts', + startLine: 10, + properties: { id }, + vectorScore: 1, + score: 1, + }; +} + +describe('enrichment identity', () => { + it('binds, maps, and returns same-location candidates by persisted id', async () => { + const firstId = `sym:v1:${'1'.repeat(64)}`; + const secondId = `sym:v1:${'2'.repeat(64)}`; + const client = { + roQuery: vi.fn() + .mockResolvedValueOnce({ + data: [ + { symbolId: firstId, callers: 1, calleeNames: [], importers: 0, testRefs: 0 }, + { symbolId: secondId, callers: 3, calleeNames: [], importers: 0, testRefs: 0 }, + ], + }) + .mockResolvedValue({ data: [] }), + }; + + const result = await enrichFromGraph(client as never, [candidate(firstId), candidate(secondId)]); + + expect(client.roQuery).toHaveBeenCalledWith( + expect.stringContaining('MATCH (n {id: item.id})'), + expect.objectContaining({ params: { items: [{ id: firstId }, { id: secondId }] } }), + ); + expect(result.get(enrichmentKey(firstId))?.callerCount).toBe(1); + expect(result.get(enrichmentKey(secondId))?.callerCount).toBe(3); + }); +}); diff --git a/packages/core/src/__tests__/indexer-deleted-file.integration.test.ts b/packages/core/src/__tests__/indexer-deleted-file.integration.test.ts new file mode 100644 index 00000000..deef6a4d --- /dev/null +++ b/packages/core/src/__tests__/indexer-deleted-file.integration.test.ts @@ -0,0 +1,124 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { mkdtemp, rm, unlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { createClient, resolveEmbeddedBinaryPaths, type GraphClient } from '@codegraph/graph'; +import { indexProject } from '../indexer'; + +const describeIfAvailable = resolveEmbeddedBinaryPaths() ? describe : describe.skip; + +describeIfAvailable('incremental indexing of deleted files', () => { + let client: GraphClient; + let dataDir: string; + let projectDir: string; + let callerPath: string; + let doomedPath: string; + let previousEmbeddingProvider: string | undefined; + + beforeAll(async () => { + previousEmbeddingProvider = process.env['CODEGRAPH_EMBEDDING_PROVIDER']; + process.env['CODEGRAPH_EMBEDDING_PROVIDER'] = 'none'; + dataDir = await mkdtemp(join(tmpdir(), 'cg-deleted-file-db-')); + projectDir = await mkdtemp(join(tmpdir(), 'cg-deleted-file-project-')); + callerPath = resolve(projectDir, 'caller.ts'); + doomedPath = resolve(projectDir, 'doomed.ts'); + + client = await createClient({ + driver: 'falkordblite', + databasePath: dataDir, + graphName: 'deleted_file_incremental', + } as never); + + await writeFile(doomedPath, 'export function doomed(): number {\n return 42;\n}\n'); + await writeFile( + callerPath, + [ + "import { doomed } from './doomed';", + '', + 'export function caller(): number {', + ' return doomed();', + '}', + '', + ].join('\n'), + ); + }, 60_000); + + afterAll(async () => { + await client?.close(); + if (dataDir) await rm(dataDir, { recursive: true, force: true }); + if (projectDir) await rm(projectDir, { recursive: true, force: true }); + if (previousEmbeddingProvider === undefined) { + delete process.env['CODEGRAPH_EMBEDDING_PROVIDER']; + } else { + process.env['CODEGRAPH_EMBEDDING_PROVIDER'] = previousEmbeddingProvider; + } + }); + + it('removes a vanished File, its symbol id, and both inbound edge kinds in the same pass', async () => { + const initial = await indexProject(projectDir, { + client, + includePatterns: ['*.ts'], + embeddings: false, + gitSync: false, + force: true, + }); + expect(initial).toMatchObject({ success: true, errorMessages: [] }); + + const targetBefore = await client.roQuery<{ id: string }>( + `MATCH (target:Function {name: 'doomed', filePath: $doomedPath}) + RETURN target.id AS id`, + { params: { doomedPath } }, + ); + expect(targetBefore.data).toEqual([{ id: expect.stringMatching(/^sym:v1:[a-f0-9]{64}$/) }]); + const doomedId = targetBefore.data[0]!.id; + + const inboundBefore = await client.roQuery<{ calls: number; imports: number }>( + `MATCH (target:Function {id: $doomedId}) + OPTIONAL MATCH (:Function {filePath: $callerPath})-[call:CALLS]->(target) + OPTIONAL MATCH (:File {filePath: $callerPath})-[imported:IMPORTS_SYMBOL]->(target) + RETURN count(DISTINCT call) AS calls, count(DISTINCT imported) AS imports`, + { params: { callerPath, doomedId } }, + ); + expect(inboundBefore.data).toEqual([{ calls: 1, imports: 1 }]); + + await unlink(doomedPath); + + const incremental = await indexProject(projectDir, { + client, + includePatterns: ['*.ts'], + embeddings: false, + gitSync: false, + force: false, + }); + expect(incremental.success).toBe(true); + + const survivors = await client.roQuery<{ files: number; symbols: number; inbound: number }>( + `OPTIONAL MATCH (file:File {filePath: $doomedPath}) + WITH count(file) AS files + OPTIONAL MATCH (symbol {id: $doomedId}) + WITH files, count(symbol) AS symbols + OPTIONAL MATCH ()-[edge:CALLS|IMPORTS_SYMBOL]->(target {id: $doomedId}) + RETURN files, symbols, count(edge) AS inbound`, + { params: { doomedId, doomedPath } }, + ); + expect(survivors.data).toEqual([{ files: 0, symbols: 0, inbound: 0 }]); + + await unlink(callerPath); + const emptyIncremental = await indexProject(projectDir, { + client, + includePatterns: ['*.ts'], + embeddings: false, + gitSync: false, + force: false, + }); + expect(emptyIncremental).toMatchObject({ success: true, errorMessages: [] }); + + const remainingSourceNodes = await client.roQuery<{ count: number }>( + `MATCH (node) + WHERE node.filePath STARTS WITH $projectDir + RETURN count(node) AS count`, + { params: { projectDir } }, + ); + expect(remainingSourceNodes.data).toEqual([{ count: 0 }]); + }, 120_000); +}); diff --git a/packages/core/src/__tests__/indexer-overload-call-target.integration.test.ts b/packages/core/src/__tests__/indexer-overload-call-target.integration.test.ts new file mode 100644 index 00000000..a7906af4 --- /dev/null +++ b/packages/core/src/__tests__/indexer-overload-call-target.integration.test.ts @@ -0,0 +1,102 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { createClient, resolveEmbeddedBinaryPaths, type GraphClient } from '@codegraph/graph'; +import { indexProject } from '../indexer'; + +const describeIfAvailable = resolveEmbeddedBinaryPaths() ? describe : describe.skip; + +describeIfAvailable('indexProject overload call target resolution', () => { + let client: GraphClient; + let dataDir: string; + let projectDir: string; + let overPath: string; + let callerPath: string; + let previousEmbeddingProvider: string | undefined; + + beforeAll(async () => { + previousEmbeddingProvider = process.env['CODEGRAPH_EMBEDDING_PROVIDER']; + process.env['CODEGRAPH_EMBEDDING_PROVIDER'] = 'none'; + + dataDir = await mkdtemp('/private/tmp/cgot-'); + client = await createClient({ + driver: 'falkordblite', + databasePath: dataDir, + graphName: 'overload_call_target', + } as never); + + projectDir = mkdtempSync(join(tmpdir(), 'cg-overload-call-project-')); + overPath = resolve(projectDir, 'over.ts'); + callerPath = resolve(projectDir, 'caller.ts'); + writeFileSync(overPath, [ + 'export class Over {', + ' work(value: string): string;', + ' work(value: number): number;', + ' work(value: string | number): string | number { return value; }', + '}', + 'export class Sibling {', + ' work(value: string): string;', + ' work(value: number): number;', + ' work(value: string | number): string | number { return value; }', + '}', + '', + ].join('\n')); + writeFileSync(callerPath, [ + "import { Over } from './over';", + 'export function callOver(over: Over): string {', + " return over.work('value');", + '}', + '', + ].join('\n')); + }, 60_000); + + afterAll(async () => { + await client?.close(); + if (dataDir) await rm(dataDir, { recursive: true, force: true }); + if (projectDir) rmSync(projectDir, { recursive: true, force: true }); + if (previousEmbeddingProvider === undefined) { + delete process.env['CODEGRAPH_EMBEDDING_PROVIDER']; + } else { + process.env['CODEGRAPH_EMBEDDING_PROVIDER'] = previousEmbeddingProvider; + } + }); + + it('persists exactly one cross-file receiver CALLS edge to the implementation', async () => { + const result = await indexProject(projectDir, { + client, + includePatterns: ['*.ts'], + embeddings: false, + gitSync: false, + force: true, + }); + expect(result.success).toBe(true); + expect(result.errorMessages).toEqual([]); + + const targets = await client.roQuery<{ + id: string; + scopeKey: string; + startLine: number; + }>( + `MATCH (:Function {name: 'callOver', filePath: $callerPath})-[:CALLS]->(target:Function {name: 'work'}) + RETURN target.id AS id, target.scopeKey AS scopeKey, target.startLine AS startLine + ORDER BY target.startLine`, + { params: { callerPath } }, + ); + + expect(targets.data).toEqual([{ + id: expect.stringMatching(/^sym:v1:[a-f0-9]{64}$/), + scopeKey: 'Class:Over', + startLine: 4, + }]); + + const overloadNodes = await client.roQuery<{ scopeKey: string; startLine: number }>( + `MATCH (method:Function {name: 'work', filePath: $overPath}) + RETURN method.scopeKey AS scopeKey, method.startLine AS startLine + ORDER BY method.scopeKey, method.startLine`, + { params: { overPath } }, + ); + expect(overloadNodes.data).toHaveLength(6); + }, 60_000); +}); diff --git a/packages/core/src/__tests__/indexer-single-file-preserves-file-node.test.ts b/packages/core/src/__tests__/indexer-single-file-preserves-file-node.test.ts index 4307b373..4f710647 100644 --- a/packages/core/src/__tests__/indexer-single-file-preserves-file-node.test.ts +++ b/packages/core/src/__tests__/indexer-single-file-preserves-file-node.test.ts @@ -32,6 +32,8 @@ import type { ExtractedEntities, FileEntity, ParsedFileEntities } from '@codegra // --------------------------------------------------------------------------- const opsMocks = vi.hoisted(() => ({ + getProjectByRoot: vi.fn().mockResolvedValue(null), + sweepStaleFileSymbols: vi.fn().mockResolvedValue(undefined), removeFileAndCleanup: vi.fn().mockResolvedValue(undefined), removeFileContents: vi.fn().mockResolvedValue(undefined), removeDocumentContents: vi.fn().mockResolvedValue(undefined), @@ -118,6 +120,7 @@ beforeEach(() => { writeFileSync(filePath, 'export const x = 1;\n'); opsMocks.removeFileAndCleanup.mockClear(); opsMocks.removeFileContents.mockClear(); + opsMocks.sweepStaleFileSymbols.mockClear(); }); afterEach(() => { @@ -130,6 +133,7 @@ describe('indexSingleFile: reindexing a changed file must not destroy the File n expect(result.success).toBe(true); expect(opsMocks.removeFileContents).toHaveBeenCalledWith(filePath); + expect(opsMocks.sweepStaleFileSymbols).toHaveBeenCalledWith(filePath, []); expect(opsMocks.removeFileAndCleanup).not.toHaveBeenCalled(); }); }); diff --git a/packages/core/src/__tests__/linked-knowledge.test.ts b/packages/core/src/__tests__/linked-knowledge.test.ts index 082bc16d..20ca3204 100644 --- a/packages/core/src/__tests__/linked-knowledge.test.ts +++ b/packages/core/src/__tests__/linked-knowledge.test.ts @@ -22,7 +22,7 @@ const MOCK_VECTOR_HIT = { filePath: 'src/auth.ts', startLine: 10, distance: 0.1, - properties: {}, + properties: { id: 'sym:v1:parse-token' }, }; // --------------------------------------------------------------------------- @@ -39,7 +39,7 @@ vi.mock('@codegraph/graph', () => ({ filePath: 'src/auth.ts', startLine: 10, distance: 0.1, - properties: {}, + properties: { id: 'sym:v1:parse-token' }, }, ]), }), @@ -91,7 +91,7 @@ const { enrichedSearchV2, clearEmbeddedLabelCache } = await import('../enrichedS type RoQueryResponse = { data: unknown[]; metadata: null }; type LinkedKnowledgeRow = { - targetName: string; + targetId: string; entityText: string; entityType: string; confidence: number; @@ -148,7 +148,7 @@ describe('enrichedSearchV2 — linkedKnowledge enrichment via ABOUT edges', () = it('attaches linkedKnowledge to hits that have matching ABOUT edges', async () => { const client = makeMockClient([ { - targetName: 'parseToken', + targetId: 'sym:v1:parse-token', entityText: 'JWT authentication decision', entityType: 'Decision', confidence: 0.95, @@ -182,14 +182,14 @@ describe('enrichedSearchV2 — linkedKnowledge enrichment via ABOUT edges', () = it('attaches multiple knowledge entries when several ABOUT edges exist', async () => { const client = makeMockClient([ { - targetName: 'parseToken', + targetId: 'sym:v1:parse-token', entityText: 'JWT decision', entityType: 'Decision', confidence: 0.9, fact: null, }, { - targetName: 'parseToken', + targetId: 'sym:v1:parse-token', entityText: 'Token expiry policy', entityType: 'Policy', confidence: 0.8, @@ -208,7 +208,7 @@ describe('enrichedSearchV2 — linkedKnowledge enrichment via ABOUT edges', () = it('does not include fact field when fact is null', async () => { const client = makeMockClient([ { - targetName: 'parseToken', + targetId: 'sym:v1:parse-token', entityText: 'Auth system', entityType: 'System', confidence: 0.7, diff --git a/packages/core/src/__tests__/neighbor-expansion.test.ts b/packages/core/src/__tests__/neighbor-expansion.test.ts index bc007a6e..b225334e 100644 --- a/packages/core/src/__tests__/neighbor-expansion.test.ts +++ b/packages/core/src/__tests__/neighbor-expansion.test.ts @@ -6,13 +6,13 @@ describe('fetchSiblingSymbols', () => { const mockClient = { roQuery: vi.fn().mockResolvedValue({ data: [ - { name: 'fnA', startLine: 10, endLine: 20, signature: 'fnA sig', nodeType: 'Function' }, - { name: 'fnB', startLine: 30, endLine: 40, signature: 'fnB sig', nodeType: 'Function' }, - { name: 'fnC', startLine: 50, endLine: 60, signature: 'fnC sig', nodeType: 'Function' }, + { id: 'id-a', name: 'fnA', startLine: 10, endLine: 20, signature: 'fnA sig', nodeType: 'Function' }, + { id: 'id-b', name: 'fnB', startLine: 30, endLine: 40, signature: 'fnB sig', nodeType: 'Function' }, + { id: 'id-c', name: 'fnC', startLine: 50, endLine: 60, signature: 'fnC sig', nodeType: 'Function' }, ], }), }; - const siblings = await fetchSiblingSymbols(mockClient as never, '/file.ts', 'fnB', 30); + const siblings = await fetchSiblingSymbols(mockClient as never, '/file.ts', 'id-b'); expect(siblings).toHaveLength(2); expect(siblings.map(s => s.name)).toEqual(['fnA', 'fnC']); }); @@ -21,12 +21,12 @@ describe('fetchSiblingSymbols', () => { const mockClient = { roQuery: vi.fn().mockResolvedValue({ data: [ - { name: 'fnA', startLine: 10, endLine: 20, nodeType: 'Function' }, - { name: 'fnB', startLine: 30, endLine: 40, nodeType: 'Function' }, + { id: 'id-a', name: 'fnA', startLine: 10, endLine: 20, nodeType: 'Function' }, + { id: 'id-b', name: 'fnB', startLine: 30, endLine: 40, nodeType: 'Function' }, ], }), }; - const siblings = await fetchSiblingSymbols(mockClient as never, '/file.ts', 'fnA', 10); + const siblings = await fetchSiblingSymbols(mockClient as never, '/file.ts', 'id-a'); expect(siblings).toHaveLength(1); expect(siblings[0]?.name).toBe('fnB'); }); @@ -35,12 +35,12 @@ describe('fetchSiblingSymbols', () => { const mockClient = { roQuery: vi.fn().mockResolvedValue({ data: [ - { name: 'fnA', startLine: 10, endLine: 20, nodeType: 'Function' }, - { name: 'fnB', startLine: 30, endLine: 40, nodeType: 'Function' }, + { id: 'id-a', name: 'fnA', startLine: 10, endLine: 20, nodeType: 'Function' }, + { id: 'id-b', name: 'fnB', startLine: 30, endLine: 40, nodeType: 'Function' }, ], }), }; - const siblings = await fetchSiblingSymbols(mockClient as never, '/file.ts', 'fnB', 30); + const siblings = await fetchSiblingSymbols(mockClient as never, '/file.ts', 'id-b'); expect(siblings).toHaveLength(1); expect(siblings[0]?.name).toBe('fnA'); }); @@ -48,10 +48,10 @@ describe('fetchSiblingSymbols', () => { it('returns empty array for files with only one symbol', async () => { const mockClient = { roQuery: vi.fn().mockResolvedValue({ - data: [{ name: 'lonelyFn', startLine: 10, endLine: 20, nodeType: 'Function' }], + data: [{ id: 'id-lonely', name: 'lonelyFn', startLine: 10, endLine: 20, nodeType: 'Function' }], }), }; - const siblings = await fetchSiblingSymbols(mockClient as never, '/file.ts', 'lonelyFn', 10); + const siblings = await fetchSiblingSymbols(mockClient as never, '/file.ts', 'id-lonely'); expect(siblings).toEqual([]); }); }); diff --git a/packages/core/src/__tests__/node-identity-catalog.test.ts b/packages/core/src/__tests__/node-identity-catalog.test.ts new file mode 100644 index 00000000..2fcda7c6 --- /dev/null +++ b/packages/core/src/__tests__/node-identity-catalog.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { + buildParsedFileEntities, + createFileEntityFromContent, + extractEntitiesForFile, + parseCode, + registerPlugins, +} from '../pipeline'; +import { buildProjectSymbolCatalog, resolveProjectSymbolEdges } from '../pipeline/pipeline'; + +describe('project symbol catalog', () => { + it('resolves a cross-file call to both persisted symbol ids before graph writes', () => { + registerPlugins(); + const targetPath = '/project/target.ts'; + const callerPath = '/project/caller.ts'; + const targetContent = 'export function target(): number {\n return 1;\n}\n'; + const callerContent = [ + "import { target } from './target';", + 'export function caller(): number {', + ' return target();', + '}', + '', + ].join('\n'); + + const parse = (filePath: string, content: string) => { + const tree = parseCode(content, 'typescript', '.ts'); + const extracted = extractEntitiesForFile(tree.rootNode, filePath); + if (filePath === callerPath && extracted.imports[0]) { + extracted.imports[0].resolvedPath = targetPath; + } + const file = createFileEntityFromContent(filePath, content, new Date(0)); + return { + extracted, + parsed: buildParsedFileEntities(file, extracted, tree.rootNode, { deepAnalysis: true }), + }; + }; + + const target = parse(targetPath, targetContent); + const caller = parse(callerPath, callerContent); + const catalog = buildProjectSymbolCatalog([target.parsed, caller.parsed]); + + resolveProjectSymbolEdges([target.parsed, caller.parsed], catalog); + + const edge = caller.parsed.callEdges[0]; + expect(edge?.callerId).toBe(caller.extracted.functions.find(fn => fn.name === 'caller')?.id); + expect(edge?.calleeId).toBe(target.extracted.functions.find(fn => fn.name === 'target')?.id); + expect(edge?.callerId).toMatch(/^sym:v1:[a-f0-9]{64}$/); + expect(edge?.calleeId).toMatch(/^sym:v1:[a-f0-9]{64}$/); + }); +}); diff --git a/packages/core/src/__tests__/node-identity-final-gate.integration.test.ts b/packages/core/src/__tests__/node-identity-final-gate.integration.test.ts new file mode 100644 index 00000000..6736e683 --- /dev/null +++ b/packages/core/src/__tests__/node-identity-final-gate.integration.test.ts @@ -0,0 +1,206 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { createClient, resolveEmbeddedBinaryPaths, type GraphClient } from '@codegraph/graph'; +import { indexProject } from '../indexer'; + +const describeIfAvailable = resolveEmbeddedBinaryPaths() ? describe : describe.skip; + +describeIfAvailable('node identity final integration gate', () => { + let client: GraphClient; + let dataDir: string; + let projectDir: string; + let fileAPath: string; + let fileBPath: string; + let previousEmbeddingProvider: string | undefined; + + beforeAll(async () => { + previousEmbeddingProvider = process.env['CODEGRAPH_EMBEDDING_PROVIDER']; + process.env['CODEGRAPH_EMBEDDING_PROVIDER'] = 'none'; + + dataDir = await mkdtemp(join(tmpdir(), 'cg-node-identity-final-')); + client = await createClient({ + driver: 'falkordblite', + databasePath: dataDir, + graphName: 'node_identity_final_gate', + } as never); + + projectDir = mkdtempSync(join(tmpdir(), 'cg-node-identity-project-')); + fileAPath = resolve(projectDir, 'fileA.ts'); + fileBPath = resolve(projectDir, 'fileB.ts'); + writeFileSync(fileAPath, 'export function target(): number {\n return 42;\n}\n'); + writeFileSync( + fileBPath, + [ + "import { target } from './fileA';", + '', + 'export function caller(): number {', + ' return target();', + '}', + '', + ].join('\n'), + ); + }, 60_000); + + afterAll(async () => { + await client?.close(); + if (dataDir) await rm(dataDir, { recursive: true, force: true }); + if (projectDir) rmSync(projectDir, { recursive: true, force: true }); + if (previousEmbeddingProvider === undefined) { + delete process.env['CODEGRAPH_EMBEDDING_PROVIDER']; + } else { + process.env['CODEGRAPH_EMBEDDING_PROVIDER'] = previousEmbeddingProvider; + } + }); + + it('preserves target identity and inbound edges across line shift, then removes the prior generation on force reindex', async () => { + const initial = await indexProject(projectDir, { + client, + includePatterns: ['*.ts'], + embeddings: false, + gitSync: false, + force: true, + }); + expect(initial.success).toBe(true); + + const before = await client.roQuery<{ id: string; startLine: number }>( + `MATCH (target:Function {name: 'target', filePath: $fileAPath}) + RETURN target.id AS id, target.startLine AS startLine`, + { params: { fileAPath } }, + ); + expect(before.data).toEqual([{ id: expect.stringMatching(/^sym:v1:/), startLine: 1 }]); + const targetId = before.data[0]!.id; + + const inboundBefore = await client.roQuery<{ calls: number; imports: number }>( + `MATCH (target:Function {id: $targetId}) + OPTIONAL MATCH (:Function {filePath: $fileBPath})-[call:CALLS]->(target) + OPTIONAL MATCH (:File {filePath: $fileBPath})-[imported:IMPORTS_SYMBOL]->(target) + RETURN count(DISTINCT call) AS calls, count(DISTINCT imported) AS imports`, + { params: { targetId, fileBPath } }, + ); + expect(inboundBefore.data).toEqual([{ calls: 1, imports: 1 }]); + + writeFileSync( + fileAPath, + [ + '// inserted one', + '// inserted two', + '', + 'export function target(): number {', + ' return 42;', + '}', + '', + ].join('\n'), + ); + + const querySpy = vi.spyOn(client, 'query'); + const incremental = await indexProject(projectDir, { + client, + includePatterns: ['*.ts'], + embeddings: false, + gitSync: false, + force: false, + }); + expect(incremental.success).toBe(true); + + const sweepCall = querySpy.mock.calls.find(([cypher, options]) => + cypher.includes('$currentIds') && + Array.isArray(options?.params?.['currentIds']) && + options.params['currentIds'].includes(targetId), + ); + expect(sweepCall).toBeDefined(); + querySpy.mockRestore(); + + const after = await client.roQuery<{ id: string; startLine: number }>( + `MATCH (target:Function {name: 'target', filePath: $fileAPath}) + RETURN target.id AS id, target.startLine AS startLine`, + { params: { fileAPath } }, + ); + expect(after.data).toEqual([{ id: targetId, startLine: 4 }]); + + const inboundAfter = await client.roQuery<{ calls: number; imports: number }>( + `MATCH (target:Function {id: $targetId}) + OPTIONAL MATCH (:Function {filePath: $fileBPath})-[call:CALLS]->(target) + OPTIONAL MATCH (:File {filePath: $fileBPath})-[imported:IMPORTS_SYMBOL]->(target) + RETURN count(DISTINCT call) AS calls, count(DISTINCT imported) AS imports`, + { params: { targetId, fileBPath } }, + ); + expect(inboundAfter.data).toEqual([{ calls: 1, imports: 1 }]); + + const idIndexes = await client.roQuery<{ label: string; properties: string[] }>( + `CALL db.indexes() + YIELD label, properties + WHERE label IN ['Function', 'Class', 'Interface', 'Variable', 'Type', 'Component', 'Method'] + AND 'id' IN properties + RETURN label, properties`, + ); + expect(new Set(idIndexes.data.map((row) => row.label))).toEqual( + new Set(['Function', 'Class', 'Interface', 'Variable', 'Type', 'Component']), + ); + + const oldGeneration = 'node-identity-final-gate-prior'; + const staleId = 'sym:v1:stale-final-gate'; + await client.query( + `MATCH (n {projectId: $projectId}) + SET n.indexGeneration = $oldGeneration + WITH count(n) AS stamped + CREATE (:Function { + id: $staleId, + name: 'removedTarget', + filePath: $fileAPath, + startLine: 999, + projectId: $projectId, + indexGeneration: $oldGeneration + }) + RETURN stamped`, + { params: { projectId: initial.projectId, oldGeneration, staleId, fileAPath } }, + ); + + const forced = await indexProject(projectDir, { + client, + includePatterns: ['*.ts'], + embeddings: false, + gitSync: false, + force: true, + }); + expect(forced.success).toBe(true); + expect(forced.projectId).toBe(initial.projectId); + + const detached = await client.roQuery<{ detachedSymbols: number }>( + `MATCH (n) + WHERE n.projectId = $projectId + AND (n:Function OR n:Class OR n:Interface OR n:Variable OR n:Type OR n:Component) + OPTIONAL MATCH (f:File)-[:CONTAINS]->(n) + WITH n, f + WHERE f IS NULL + RETURN count(n) AS detachedSymbols`, + { params: { projectId: initial.projectId } }, + ); + const duplicates = await client.roQuery<{ label: string; id: string; copies: number }>( + `MATCH (n) + WHERE n.projectId = $projectId + AND (n:Function OR n:Class OR n:Interface OR n:Variable OR n:Type OR n:Component) + WITH labels(n)[0] AS label, n.id AS id, count(*) AS copies + WHERE copies > 1 + RETURN label, id, copies`, + { params: { projectId: initial.projectId } }, + ); + const priorGeneration = await client.roQuery<{ survivors: number }>( + `MATCH (n {projectId: $projectId, indexGeneration: $oldGeneration}) + RETURN count(n) AS survivors`, + { params: { projectId: initial.projectId, oldGeneration } }, + ); + const staleSymbols = await client.roQuery<{ survivors: number }>( + `MATCH (n {projectId: $projectId, id: $staleId}) + RETURN count(n) AS survivors`, + { params: { projectId: initial.projectId, staleId } }, + ); + + expect(detached.data).toEqual([{ detachedSymbols: 0 }]); + expect(duplicates.data).toEqual([]); + expect(priorGeneration.data).toEqual([{ survivors: 0 }]); + expect(staleSymbols.data).toEqual([{ survivors: 0 }]); + }, 120_000); +}); diff --git a/packages/core/src/__tests__/pipeline-barrel-resolution.test.ts b/packages/core/src/__tests__/pipeline-barrel-resolution.test.ts index cd3f3986..88605e57 100644 --- a/packages/core/src/__tests__/pipeline-barrel-resolution.test.ts +++ b/packages/core/src/__tests__/pipeline-barrel-resolution.test.ts @@ -49,6 +49,10 @@ import { resolveReExportChain, } from '../pipeline'; import type { ImportEntity } from '@codegraph/types'; +import { + buildProjectSymbolCatalog, + resolveProjectSymbolEdges, +} from '../pipeline/pipeline'; describe('resolveReExportChain', () => { it('resolves a two-level named re-export chain to the origin file and name', () => { @@ -413,15 +417,10 @@ describe('buildParsedFileEntities: barrel-aware CALL edges (end-to-end)', () => main.rootNode, { deepAnalysis: true, includeExternals: false }, ); + resolveProjectSymbolEdges([built], buildProjectSymbolCatalog([built])); - const methodCall = built.callEdges.find((e) => e.calleeId.includes(':method')); - // Bug 1's receiver binding resolves `s.method()` to *some* file for - // `Service` (the barrel, since that's what the import's resolvedPath - // says) but without barrel-chain resolution, that's the barrel file, - // which has no `method` Function node, so this callee id names a file - // that doesn't define `method`. - expect(methodCall).toBeDefined(); - expect(methodCall!.calleeId).toBe(`Function:${join(dir, 'barrel.ts')}:method`); + const callerId = built.functions.find((fn) => fn.name === 'run')?.id; + expect(built.callEdges.find((edge) => edge.callerId === callerId)).toBeUndefined(); }); it('with a barrelIndex, the receiver call resolves through the two-level barrel chain to service.ts', () => { @@ -442,10 +441,19 @@ describe('buildParsedFileEntities: barrel-aware CALL edges (end-to-end)', () => main.rootNode, { deepAnalysis: true, includeExternals: false, barrelIndex }, ); + const serviceBuilt = buildParsedFileEntities( + service.fileEntity, + service.extracted, + service.rootNode, + { deepAnalysis: true, includeExternals: false, barrelIndex }, + ); + const files = [built, serviceBuilt]; + resolveProjectSymbolEdges(files, buildProjectSymbolCatalog(files)); - const methodCall = built.callEdges.find((e) => e.calleeId.includes(':method')); + const callerId = built.functions.find((fn) => fn.name === 'run')?.id; + const methodCall = built.callEdges.find((edge) => edge.callerId === callerId); expect(methodCall).toBeDefined(); - expect(methodCall!.calleeId).toBe(`Function:${join(dir, 'service.ts')}:method`); + expect(methodCall!.calleeId).toBe(serviceBuilt.functions.find((fn) => fn.name === 'method')?.id); // The File-to-File IMPORTS edge stays pointing at the barrel: that edge // describes what main.ts actually imports from, which is genuinely the @@ -470,13 +478,22 @@ describe('buildParsedFileEntities: barrel-aware CALL edges (end-to-end)', () => consumer.rootNode, { deepAnalysis: true, includeExternals: false, barrelIndex }, ); + const originBuilt = buildParsedFileEntities( + origin.fileEntity, + origin.extracted, + origin.rootNode, + { deepAnalysis: true, includeExternals: false, barrelIndex }, + ); + const files = [built, originBuilt]; + resolveProjectSymbolEdges(files, buildProjectSymbolCatalog(files)); - const call = built.callEdges.find((e) => e.callerId.includes(':useAlias')); + const callerId = built.functions.find((fn) => fn.name === 'useAlias')?.id; + const call = built.callEdges.find((edge) => edge.callerId === callerId); expect(call).toBeDefined(); // The real Function node at origin.ts is named aliasedFn, not renamedFn // (the call site's local alias). The callee id must search for the // origin-declared name, or this edge silently drops at graph-write time. - expect(call!.calleeId).toBe(`Function:${join(dir, 'origin.ts')}:aliasedFn`); + expect(call!.calleeId).toBe(originBuilt.functions.find((fn) => fn.name === 'aliasedFn')?.id); }); it('reviewer blocker 5 (end-to-end): a mixed barrel resolves its own local export to itself, not the unrelated star target', () => { @@ -498,10 +515,19 @@ describe('buildParsedFileEntities: barrel-aware CALL edges (end-to-end)', () => consumer.rootNode, { deepAnalysis: true, includeExternals: false, barrelIndex, localExportsIndex }, ); + const targetBuilt = buildParsedFileEntities( + mixedBarrel.fileEntity, + mixedBarrel.extracted, + mixedBarrel.rootNode, + { deepAnalysis: true, includeExternals: false, barrelIndex, localExportsIndex }, + ); + const files = [built, targetBuilt]; + resolveProjectSymbolEdges(files, buildProjectSymbolCatalog(files)); - const call = built.callEdges.find((e) => e.callerId.includes(':useMixed')); + const callerId = built.functions.find((fn) => fn.name === 'useMixed')?.id; + const call = built.callEdges.find((edge) => edge.callerId === callerId); expect(call).toBeDefined(); - expect(call!.calleeId).toBe(`Function:${join(dir, 'mixedBarrel.ts')}:localOnly`); + expect(call!.calleeId).toBe(targetBuilt.functions.find((fn) => fn.name === 'localOnly')?.id); }); it('without localExportsIndex, the mixed-barrel local export can be mis-resolved through the unrelated star hop (documents the degraded case)', () => { @@ -521,13 +547,11 @@ describe('buildParsedFileEntities: barrel-aware CALL edges (end-to-end)', () => consumer.rootNode, { deepAnalysis: true, includeExternals: false, barrelIndex }, ); + const files = [built]; + resolveProjectSymbolEdges(files, buildProjectSymbolCatalog(files)); - const call = built.callEdges.find((e) => e.callerId.includes(':useMixed')); - expect(call).toBeDefined(); - // Without the local-exports base case, the chain follows mixedBarrel's - // star re-export and searches for `localOnly` at otherThing.ts, where it - // doesn't exist, so this callee id names a file that doesn't define it. - expect(call!.calleeId).toBe(`Function:${join(dir, 'otherThing.ts')}:localOnly`); + const callerId = built.functions.find((fn) => fn.name === 'useMixed')?.id; + expect(built.callEdges.find((edge) => edge.callerId === callerId)).toBeUndefined(); }); }); @@ -734,15 +758,30 @@ describe('buildParsedFileEntities: multi-star barrel end-to-end (reviewer follow consumer.rootNode, { deepAnalysis: true, includeExternals: false, barrelIndex, localExportsIndex }, ); + const moduleABuilt = buildParsedFileEntities( + moduleA.fileEntity, + moduleA.extracted, + moduleA.rootNode, + { deepAnalysis: true, includeExternals: false, barrelIndex, localExportsIndex }, + ); + const moduleBBuilt = buildParsedFileEntities( + moduleB.fileEntity, + moduleB.extracted, + moduleB.rootNode, + { deepAnalysis: true, includeExternals: false, barrelIndex, localExportsIndex }, + ); + const files = [built, moduleABuilt, moduleBBuilt]; + resolveProjectSymbolEdges(files, buildProjectSymbolCatalog(files)); + const callerId = built.functions.find((fn) => fn.name === 'useMultiStar')?.id; const callees = built.callEdges - .filter((e) => e.callerId.includes(':useMultiStar')) + .filter((edge) => edge.callerId === callerId) .map((e) => e.calleeId) .sort(); expect(callees).toEqual([ - `Function:${join(dir, 'moduleA.ts')}:fnA`, - `Function:${join(dir, 'moduleB.ts')}:fnB`, - ]); + moduleABuilt.functions.find((fn) => fn.name === 'fnA')?.id, + moduleBBuilt.functions.find((fn) => fn.name === 'fnB')?.id, + ].sort()); }); it('(b) useMultiStarTypes() TypeRefs: TypeA keys on typeA.ts and TypeB keys on typeB.ts', () => { diff --git a/packages/core/src/__tests__/pipeline-exports-and-imports-symbol-edges.test.ts b/packages/core/src/__tests__/pipeline-exports-and-imports-symbol-edges.test.ts index 2c580613..25366cae 100644 --- a/packages/core/src/__tests__/pipeline-exports-and-imports-symbol-edges.test.ts +++ b/packages/core/src/__tests__/pipeline-exports-and-imports-symbol-edges.test.ts @@ -103,11 +103,14 @@ describe('buildParsedFileEntities: exportsEdges (end-to-end)', () => { const byName = new Map(built.exportsEdges.map((e) => [e.symbolName, e])); - expect(byName.get('exportedFn')).toEqual({ filePath, symbolName: 'exportedFn', symbolKind: 'Function' }); - expect(byName.get('ExportedClass')).toEqual({ filePath, symbolName: 'ExportedClass', symbolKind: 'Class' }); - expect(byName.get('ExportedInterface')).toEqual({ filePath, symbolName: 'ExportedInterface', symbolKind: 'Interface' }); - expect(byName.get('exportedVar')).toEqual({ filePath, symbolName: 'exportedVar', symbolKind: 'Variable' }); - expect(byName.get('ExportedType')).toEqual({ filePath, symbolName: 'ExportedType', symbolKind: 'Type' }); + expect(byName.get('exportedFn')).toMatchObject({ filePath, symbolName: 'exportedFn', symbolKind: 'Function' }); + expect(byName.get('ExportedClass')).toMatchObject({ filePath, symbolName: 'ExportedClass', symbolKind: 'Class' }); + expect(byName.get('ExportedInterface')).toMatchObject({ filePath, symbolName: 'ExportedInterface', symbolKind: 'Interface' }); + expect(byName.get('exportedVar')).toMatchObject({ filePath, symbolName: 'exportedVar', symbolKind: 'Variable' }); + expect(byName.get('ExportedType')).toMatchObject({ filePath, symbolName: 'ExportedType', symbolKind: 'Type' }); + expect(byName.get('exportedFn')?.toId).toBe( + built.functions.find((fn) => fn.name === 'exportedFn')?.id, + ); // Unexported siblings must not produce an EXPORTS edge. expect(byName.has('localFn')).toBe(false); @@ -201,7 +204,7 @@ describe('buildParsedFileEntities: importsSymbolEdges (end-to-end)', () => { const edge = built.importsSymbolEdges.find((e) => e.symbolName === 'targetFn' && !e.alias); expect(edge).toBeDefined(); - expect(edge).toEqual({ + expect(edge).toMatchObject({ fromFilePath: join(dir, 'consumer.ts'), toFilePath: join(dir, 'target.ts'), symbolName: 'targetFn', @@ -219,7 +222,7 @@ describe('buildParsedFileEntities: importsSymbolEdges (end-to-end)', () => { // named renamedFn at target.ts, which doesn't exist there). const edge = built.importsSymbolEdges.find((e) => e.alias === 'renamedFn'); expect(edge).toBeDefined(); - expect(edge).toEqual({ + expect(edge).toMatchObject({ fromFilePath: join(dir, 'consumer.ts'), toFilePath: join(dir, 'target.ts'), symbolName: 'otherFn', diff --git a/packages/core/src/__tests__/pipeline-python-cross-file-calls.test.ts b/packages/core/src/__tests__/pipeline-python-cross-file-calls.test.ts index ffc29b5a..f249909a 100644 --- a/packages/core/src/__tests__/pipeline-python-cross-file-calls.test.ts +++ b/packages/core/src/__tests__/pipeline-python-cross-file-calls.test.ts @@ -32,6 +32,10 @@ import { createFileEntityFromContent, buildParsedFileEntities, } from '../pipeline'; +import { + buildProjectSymbolCatalog, + resolveProjectSymbolEdges, +} from '../pipeline/pipeline'; describe('buildParsedFileEntities: Python cross-file call resolution (end-to-end)', () => { let dir: string; @@ -62,6 +66,17 @@ describe('buildParsedFileEntities: Python cross-file call resolution (end-to-end return { rootNode: syntaxTree.rootNode, extracted, fileEntity }; } + function build(filePath: string) { + const parsed = parseAndExtract(filePath); + return buildParsedFileEntities( + parsed.fileEntity, + parsed.extracted, + parsed.rootNode, + { deepAnalysis: true, includeExternals: false }, + dir, + ); + } + it('(e) resolves the IMPORTS edge to the real file on disk (bug 1: no phantom edge for an existing module)', () => { const caller = parseAndExtract(join(dir, 'caller.py')); @@ -95,37 +110,27 @@ describe('buildParsedFileEntities: Python cross-file call resolution (end-to-end }); it('(e) buildCallEdgesFromRefs keys the CALLS edge on the resolved callee file, not the caller file (bugs 2 + 3 together)', () => { - const caller = parseAndExtract(join(dir, 'caller.py')); - - const built = buildParsedFileEntities( - caller.fileEntity, - caller.extracted, - caller.rootNode, - { deepAnalysis: true, includeExternals: false }, - dir, - ); + const built = build(join(dir, 'caller.py')); + const target = build(join(dir, 'mod.py')); + resolveProjectSymbolEdges([built, target], buildProjectSymbolCatalog([built, target])); - const call = built.callEdges.find((e) => e.callerId.includes(':caller')); + const callerId = built.functions.find((fn) => fn.name === 'caller')?.id; + const targetId = target.functions.find((fn) => fn.name === 'fn')?.id; + const call = built.callEdges.find((e) => e.callerId === callerId); expect(call).toBeDefined(); // Before the fix: this edge either didn't exist (bug 2 dropped it // entirely) or, if it had, would have pointed at // Function:/caller.py:fn (bug 3), a Function node that doesn't // exist there since fn is defined in mod.py. - expect(call!.calleeId).toBe(`Function:${join(dir, 'mod.py')}:fn`); + expect(call!.calleeId).toBe(targetId); }); it('(e) a call to an unresolvable cross-file name produces no edge at all, not a wrong same-file one', () => { - const phantomCaller = parseAndExtract(join(dir, 'phantom_caller.py')); - - const built = buildParsedFileEntities( - phantomCaller.fileEntity, - phantomCaller.extracted, - phantomCaller.rootNode, - { deepAnalysis: true, includeExternals: false }, - dir, - ); + const built = build(join(dir, 'phantom_caller.py')); + resolveProjectSymbolEdges([built], buildProjectSymbolCatalog([built])); - const call = built.callEdges.find((e) => e.callerId.includes(':caller')); + const callerId = built.functions.find((fn) => fn.name === 'caller')?.id; + const call = built.callEdges.find((e) => e.callerId === callerId); expect(call).toBeUndefined(); }); }); diff --git a/packages/core/src/__tests__/pipeline-python-review-wave-b.test.ts b/packages/core/src/__tests__/pipeline-python-review-wave-b.test.ts index 8247baca..e9a9b96e 100644 --- a/packages/core/src/__tests__/pipeline-python-review-wave-b.test.ts +++ b/packages/core/src/__tests__/pipeline-python-review-wave-b.test.ts @@ -40,6 +40,10 @@ import { createFileEntityFromContent, buildParsedFileEntities, } from '../pipeline'; +import { + buildProjectSymbolCatalog, + resolveProjectSymbolEdges, +} from '../pipeline/pipeline'; function parseAndExtract(filePath: string) { const content = readFileSync(filePath, 'utf-8'); @@ -49,6 +53,23 @@ function parseAndExtract(filePath: string) { return { rootNode: syntaxTree.rootNode, extracted, fileEntity }; } +function buildFile(filePath: string, projectRoot: string) { + const parsed = parseAndExtract(filePath); + return buildParsedFileEntities( + parsed.fileEntity, + parsed.extracted, + parsed.rootNode, + { deepAnalysis: true, includeExternals: false }, + projectRoot, + ); +} + +function resolveFiles(filePaths: string[], projectRoot: string) { + const built = filePaths.map((filePath) => buildFile(filePath, projectRoot)); + resolveProjectSymbolEdges(built, buildProjectSymbolCatalog(built)); + return built; +} + describe('BLOCKER: aliased cross-file import resolves to the DECLARED name, not the local alias', () => { let dir: string; @@ -75,23 +96,16 @@ describe('BLOCKER: aliased cross-file import resolves to the DECLARED name, not expect(realFn).toBeDefined(); expect(realFn!.name).toBe('fn'); // NOT 'f' -- the target has no idea it was aliased on import - const consumer = parseAndExtract(join(dir, 'pkg', 'consumer_alias.py')); - const built = buildParsedFileEntities( - consumer.fileEntity, - consumer.extracted, - consumer.rootNode, - { deepAnalysis: true, includeExternals: false }, - dir, - ); + const [built, target] = resolveFiles([ + join(dir, 'pkg', 'consumer_alias.py'), + join(dir, 'pkg', 'mod.py'), + ], dir); - const call = built.callEdges.find((e) => e.callerId.includes(':caller_alias')); + const callerId = built?.functions.find((fn) => fn.name === 'caller_alias')?.id; + const call = built?.callEdges.find((e) => e.callerId === callerId); expect(call).toBeDefined(); - // packages/graph/src/operations.ts's CREATE_CALLS_EDGE MATCHes the callee - // by {name, filePath} parsed out of this exact string (parseEntityId - // splits on ':'). It must name mod.py's REAL function ('fn'), or the - // MATCH finds nothing and the edge silently drops at write time. - expect(call!.calleeId).toBe(`Function:${join(dir, 'pkg', 'mod.py')}:${realFn!.name}`); - expect(call!.calleeId).not.toBe(`Function:${join(dir, 'pkg', 'mod.py')}:f`); + const targetId = target?.functions.find((fn) => fn.name === realFn!.name)?.id; + expect(call!.calleeId).toBe(targetId); }); it('a plain (non-aliased) cross-file import still resolves to the same declared name (regression check)', () => { @@ -99,18 +113,15 @@ describe('BLOCKER: aliased cross-file import resolves to the DECLARED name, not join(dir, 'pkg', 'consumer_rel.py'), ['from .mod import fn', '', 'def caller_rel():', ' return fn()', ''].join('\n'), ); - const consumer = parseAndExtract(join(dir, 'pkg', 'consumer_rel.py')); - const built = buildParsedFileEntities( - consumer.fileEntity, - consumer.extracted, - consumer.rootNode, - { deepAnalysis: true, includeExternals: false }, - dir, - ); + const [built, target] = resolveFiles([ + join(dir, 'pkg', 'consumer_rel.py'), + join(dir, 'pkg', 'mod.py'), + ], dir); - const call = built.callEdges.find((e) => e.callerId.includes(':caller_rel')); + const callerId = built?.functions.find((fn) => fn.name === 'caller_rel')?.id; + const call = built?.callEdges.find((e) => e.callerId === callerId); expect(call).toBeDefined(); - expect(call!.calleeId).toBe(`Function:${join(dir, 'pkg', 'mod.py')}:fn`); + expect(call!.calleeId).toBe(target?.functions.find((fn) => fn.name === 'fn')?.id); }); }); @@ -143,18 +154,15 @@ describe('ADJACENT BUG 1: `import pkgns as p` resolves an attribute call through }); it('p.init_fn() resolves to a CALLS edge into pkgns/__init__.py (was zero edges before this fix)', () => { - const consumer = parseAndExtract(join(dir, 'consumer.py')); - const built = buildParsedFileEntities( - consumer.fileEntity, - consumer.extracted, - consumer.rootNode, - { deepAnalysis: true, includeExternals: false }, - dir, - ); + const [built, target] = resolveFiles([ + join(dir, 'consumer.py'), + join(dir, 'pkgns', '__init__.py'), + ], dir); - const call = built.callEdges.find((e) => e.callerId.includes(':caller')); + const callerId = built?.functions.find((fn) => fn.name === 'caller')?.id; + const call = built?.callEdges.find((e) => e.callerId === callerId); expect(call).toBeDefined(); - expect(call!.calleeId).toBe(`Function:${join(dir, 'pkgns', '__init__.py')}:init_fn`); + expect(call!.calleeId).toBe(target?.functions.find((fn) => fn.name === 'init_fn')?.id); }); }); diff --git a/packages/core/src/__tests__/pipeline-tier2-ruby-calls-regression.test.ts b/packages/core/src/__tests__/pipeline-tier2-ruby-calls-regression.test.ts index 96711371..59b1078c 100644 --- a/packages/core/src/__tests__/pipeline-tier2-ruby-calls-regression.test.ts +++ b/packages/core/src/__tests__/pipeline-tier2-ruby-calls-regression.test.ts @@ -25,6 +25,10 @@ import { buildParsedFileEntities, languageRegistry, } from '../pipeline'; +import { + buildProjectSymbolCatalog, + resolveProjectSymbolEdges, +} from '../pipeline/pipeline'; describe('buildParsedFileEntities: tier-2 (Ruby) same-file calls unaffected by the context change', () => { let dir: string; @@ -65,9 +69,12 @@ describe('buildParsedFileEntities: tier-2 (Ruby) same-file calls unaffected by t { deepAnalysis: true, includeExternals: false }, dir, ); + resolveProjectSymbolEdges([built], buildProjectSymbolCatalog([built])); - const call = built.callEdges.find((e) => e.callerId.includes(':caller')); + const callerId = built.functions.find((fn) => fn.name === 'caller')?.id; + const calleeId = built.functions.find((fn) => fn.name === 'helper')?.id; + const call = built.callEdges.find((e) => e.callerId === callerId); expect(call).toBeDefined(); - expect(call!.calleeId).toBe(`Function:${filePath}:helper`); + expect(call!.calleeId).toBe(calleeId); }); }); diff --git a/packages/core/src/__tests__/service.test.ts b/packages/core/src/__tests__/service.test.ts index 0341d6f7..ce907ddf 100644 --- a/packages/core/src/__tests__/service.test.ts +++ b/packages/core/src/__tests__/service.test.ts @@ -254,7 +254,7 @@ describe('CodeGraphService', () => { // Should NOT contain elementId (removed) expect(cypher).not.toContain('elementId'); // Should use property-based matching - expect(cypher).toContain('n.filePath = $id'); + expect(cypher).toContain('n.id = $id'); }); }); @@ -290,7 +290,7 @@ describe('CodeGraphService', () => { .mockResolvedValueOnce({ data: [ { n: { filePath: '/src/app.ts' }, labels: ['File'] }, - { n: { name: 'render', filePath: '/src/app.ts', startLine: 5 }, labels: ['Function'] }, + { n: { id: 'sym:v1:render', name: 'render', filePath: '/src/app.ts', startLine: 5 }, labels: ['Function'] }, ], metadata: null, }); @@ -298,7 +298,7 @@ describe('CodeGraphService', () => { const result = await codeGraphService.getNodesPaginated(); expect(result.nodes[0]!.id).toBe('File:/src/app.ts'); - expect(result.nodes[1]!.id).toBe('Function:/src/app.ts:render:5'); + expect(result.nodes[1]!.id).toBe('sym:v1:render'); }); it('applies type filtering with dialect labelCheckExpr', async () => { @@ -374,36 +374,34 @@ describe('CodeGraphService', () => { expect(result.direction).toBe('both'); }); - it('parses File: IDs correctly using path match', async () => { + it('matches opaque IDs without parsing them', async () => { mockClient.roQuery.mockResolvedValueOnce({ data: [], metadata: null }); - await codeGraphService.getNeighbors('File:/src/index.ts'); + await codeGraphService.getNeighbors('sym:v1:opaque'); const cypher: string = mockClient.roQuery.mock.calls[0][0]; - expect(cypher).toContain('center.filePath = $actualPath'); + expect(cypher).toContain('center.id = $id'); const params = mockClient.roQuery.mock.calls[0][1]; - expect(params.params.actualPath).toBe('/src/index.ts'); + expect(params.params.id).toBe('sym:v1:opaque'); }); - it('parses composite IDs (Label:filePath:name:line)', async () => { + it('does not derive source coordinates from opaque IDs', async () => { mockClient.roQuery.mockResolvedValueOnce({ data: [], metadata: null }); - await codeGraphService.getNeighbors('Function:/src/app.ts:render:15'); + await codeGraphService.getNeighbors('sym:v1:opaque'); const params = mockClient.roQuery.mock.calls[0][1]; - expect(params.params.filePath).toBe('/src/app.ts'); - expect(params.params.name).toBe('render'); - expect(params.params.line).toBe(15); + expect(params.params).toEqual({ id: 'sym:v1:opaque', limit: 50 }); }); - it('falls back to simple name/path match for simple IDs', async () => { + it('does not fall back to symbol name or path matching', async () => { mockClient.roQuery.mockResolvedValueOnce({ data: [], metadata: null }); await codeGraphService.getNeighbors('myFunction'); const cypher: string = mockClient.roQuery.mock.calls[0][0]; - expect(cypher).toContain('center.name = $simpleId'); - expect(cypher).toContain('center.filePath = $simpleId'); + expect(cypher).not.toContain('center.name ='); + expect(cypher).not.toContain('center.filePath = $id'); }); it('uses correct match pattern for direction=in', async () => { @@ -450,7 +448,7 @@ describe('CodeGraphService', () => { mockClient.roQuery.mockResolvedValueOnce({ data: [ { - neighbor: { name: 'helper', filePath: '/src/util.ts', startLine: 5 }, + neighbor: { id: 'sym:v1:helper', name: 'helper', filePath: '/src/util.ts', startLine: 5 }, neighborLabels: ['Function'], r: { weight: 1 }, rType: 'CALLS', @@ -462,7 +460,7 @@ describe('CodeGraphService', () => { const result = await codeGraphService.getNeighbors('File:/src/index.ts', 'out'); expect(result.nodes).toHaveLength(1); - expect(result.nodes[0]!.id).toBe('Function:/src/util.ts:helper:5'); + expect(result.nodes[0]!.id).toBe('sym:v1:helper'); expect(result.nodes[0]!.label).toBe('Function'); expect(result.nodes[0]!.displayName).toBe('helper'); diff --git a/packages/core/src/__tests__/services-helpers.test.ts b/packages/core/src/__tests__/services-helpers.test.ts index 684e6d20..de063f9d 100644 --- a/packages/core/src/__tests__/services-helpers.test.ts +++ b/packages/core/src/__tests__/services-helpers.test.ts @@ -140,10 +140,21 @@ describe('generateNodeId: Entity uses text + type (its real MERGE key), reachabl }); }); -describe('generateNodeId: symbol labels keep the existing name/filePath/startLine-or-line scheme', () => { - it('still builds ids from name, filePath and startLine for Function/Class/etc.', () => { +describe('generateNodeId: symbol labels expose persisted opaque ids', () => { + it('returns the persisted id without rebuilding it from mutable location fields', () => { + const persistedId = `sym:v1:${'a'.repeat(64)}`; + const id = generateNodeId('Function', { + id: persistedId, + name: 'doThing', + filePath: '/src/a.ts', + startLine: 10, + }); + expect(id).toBe(persistedId); + }); + + it('does not synthesize a location-based id when persisted identity is absent', () => { const id = generateNodeId('Function', { name: 'doThing', filePath: '/src/a.ts', startLine: 10 }); - expect(id).toBe('Function:/src/a.ts:doThing:10'); + expect(id).toBe('Function:unknown'); }); it('still builds the single-key File id', () => { diff --git a/packages/core/src/embed-nodes.ts b/packages/core/src/embed-nodes.ts index 41456631..b42d7a2a 100644 --- a/packages/core/src/embed-nodes.ts +++ b/packages/core/src/embed-nodes.ts @@ -85,14 +85,14 @@ const QUERIES = { FUNCTIONS_NEEDING_EMBEDDING: ` MATCH (fn:Function) WHERE fn.embedding IS NULL - RETURN fn.name as name, fn.filePath as filePath, fn.startLine as startLine, + RETURN fn.id as id, fn.name as name, fn.filePath as filePath, fn.startLine as startLine, fn.endLine as endLine, fn.isExported as isExported, fn.isAsync as isAsync, fn.isArrow as isArrow, fn.params as params, fn.returnType as returnType, fn.docstring as docstring `, ALL_FUNCTIONS: ` MATCH (fn:Function) - RETURN fn.name as name, fn.filePath as filePath, fn.startLine as startLine, + RETURN fn.id as id, fn.name as name, fn.filePath as filePath, fn.startLine as startLine, fn.endLine as endLine, fn.isExported as isExported, fn.isAsync as isAsync, fn.isArrow as isArrow, fn.params as params, fn.returnType as returnType, fn.docstring as docstring @@ -102,13 +102,13 @@ const QUERIES = { CLASSES_NEEDING_EMBEDDING: ` MATCH (c:Class) WHERE c.embedding IS NULL - RETURN c.name as name, c.filePath as filePath, c.startLine as startLine, + RETURN c.id as id, c.name as name, c.filePath as filePath, c.startLine as startLine, c.endLine as endLine, c.isExported as isExported, c.isAbstract as isAbstract, c.extends as extends_, c.implements as implements_, c.docstring as docstring `, ALL_CLASSES: ` MATCH (c:Class) - RETURN c.name as name, c.filePath as filePath, c.startLine as startLine, + RETURN c.id as id, c.name as name, c.filePath as filePath, c.startLine as startLine, c.endLine as endLine, c.isExported as isExported, c.isAbstract as isAbstract, c.extends as extends_, c.implements as implements_, c.docstring as docstring `, @@ -117,13 +117,13 @@ const QUERIES = { INTERFACES_NEEDING_EMBEDDING: ` MATCH (i:Interface) WHERE i.embedding IS NULL - RETURN i.name as name, i.filePath as filePath, i.startLine as startLine, + RETURN i.id as id, i.name as name, i.filePath as filePath, i.startLine as startLine, i.endLine as endLine, i.isExported as isExported, i.extends as extends_, i.docstring as docstring `, ALL_INTERFACES: ` MATCH (i:Interface) - RETURN i.name as name, i.filePath as filePath, i.startLine as startLine, + RETURN i.id as id, i.name as name, i.filePath as filePath, i.startLine as startLine, i.endLine as endLine, i.isExported as isExported, i.extends as extends_, i.docstring as docstring `, @@ -132,12 +132,12 @@ const QUERIES = { VARIABLES_NEEDING_EMBEDDING: ` MATCH (v:Variable) WHERE v.embedding IS NULL - RETURN v.name as name, v.filePath as filePath, v.line as line, + RETURN v.id as id, v.name as name, v.filePath as filePath, v.line as line, v.kind as kind, v.type as type_, v.isExported as isExported `, ALL_VARIABLES: ` MATCH (v:Variable) - RETURN v.name as name, v.filePath as filePath, v.line as line, + RETURN v.id as id, v.name as name, v.filePath as filePath, v.line as line, v.kind as kind, v.type as type_, v.isExported as isExported `, @@ -145,13 +145,13 @@ const QUERIES = { TYPES_NEEDING_EMBEDDING: ` MATCH (t:Type) WHERE t.embedding IS NULL - RETURN t.name as name, t.filePath as filePath, t.startLine as startLine, + RETURN t.id as id, t.name as name, t.filePath as filePath, t.startLine as startLine, t.endLine as endLine, t.kind as kind, t.isExported as isExported, t.docstring as docstring `, ALL_TYPES: ` MATCH (t:Type) - RETURN t.name as name, t.filePath as filePath, t.startLine as startLine, + RETURN t.id as id, t.name as name, t.filePath as filePath, t.startLine as startLine, t.endLine as endLine, t.kind as kind, t.isExported as isExported, t.docstring as docstring `, @@ -160,13 +160,13 @@ const QUERIES = { COMPONENTS_NEEDING_EMBEDDING: ` MATCH (comp:Component) WHERE comp.embedding IS NULL - RETURN comp.name as name, comp.filePath as filePath, comp.startLine as startLine, + RETURN comp.id as id, comp.name as name, comp.filePath as filePath, comp.startLine as startLine, comp.endLine as endLine, comp.isExported as isExported, comp.propsType as propsType, comp.props as props `, ALL_COMPONENTS: ` MATCH (comp:Component) - RETURN comp.name as name, comp.filePath as filePath, comp.startLine as startLine, + RETURN comp.id as id, comp.name as name, comp.filePath as filePath, comp.startLine as startLine, comp.endLine as endLine, comp.isExported as isExported, comp.propsType as propsType, comp.props as props `, @@ -222,6 +222,7 @@ const rowMappers = { loc: r.loc as number, }), Function: (r: Record) => ({ + id: r.id as string, name: r.name as string, filePath: r.filePath as string, startLine: r.startLine as number, @@ -235,6 +236,7 @@ const rowMappers = { docstring: (r.docstring as string) ?? null, }), Class: (r: Record) => ({ + id: r.id as string, name: r.name as string, filePath: r.filePath as string, startLine: r.startLine as number, @@ -246,6 +248,7 @@ const rowMappers = { docstring: (r.docstring as string) ?? null, }), Interface: (r: Record) => ({ + id: r.id as string, name: r.name as string, filePath: r.filePath as string, startLine: r.startLine as number, @@ -255,6 +258,7 @@ const rowMappers = { docstring: (r.docstring as string) ?? null, }), Variable: (r: Record) => ({ + id: r.id as string, name: r.name as string, filePath: r.filePath as string, line: r.line as number, @@ -263,6 +267,7 @@ const rowMappers = { isExported: r.isExported as boolean, }), Type: (r: Record) => ({ + id: r.id as string, name: r.name as string, filePath: r.filePath as string, startLine: r.startLine as number, @@ -272,6 +277,7 @@ const rowMappers = { docstring: (r.docstring as string) ?? null, }), Component: (r: Record) => ({ + id: r.id as string, name: r.name as string, filePath: r.filePath as string, startLine: r.startLine as number, @@ -285,12 +291,12 @@ const rowMappers = { /** Identifier extractors for each node type */ const identifierExtractors: Record) => Record> = { File: (e) => ({ filePath: e.path }), - Function: (e) => ({ name: e.name, filePath: e.filePath, startLine: e.startLine }), - Class: (e) => ({ name: e.name, filePath: e.filePath, startLine: e.startLine }), - Interface: (e) => ({ name: e.name, filePath: e.filePath, startLine: e.startLine }), - Variable: (e) => ({ name: e.name, filePath: e.filePath, line: e.line }), - Type: (e) => ({ name: e.name, filePath: e.filePath, startLine: e.startLine }), - Component: (e) => ({ name: e.name, filePath: e.filePath, startLine: e.startLine }), + Function: (e) => ({ id: e.id }), + Class: (e) => ({ id: e.id }), + Interface: (e) => ({ id: e.id }), + Variable: (e) => ({ id: e.id }), + Type: (e) => ({ id: e.id }), + Component: (e) => ({ id: e.id }), }; /** Embedding text builders for each node type — row mappers produce matching shapes */ diff --git a/packages/core/src/embed-pass.ts b/packages/core/src/embed-pass.ts index 9e164274..5634779b 100644 --- a/packages/core/src/embed-pass.ts +++ b/packages/core/src/embed-pass.ts @@ -89,7 +89,7 @@ function collectEmbeddableItems(parsed: ParsedFileEntities): EmbeddableItem[] { nodeType: 'Function', text, textHash: hashText(text), - identifier: { name: fn.name, filePath: fn.filePath, startLine: fn.startLine }, + identifier: { id: fn.id }, }); } @@ -100,7 +100,7 @@ function collectEmbeddableItems(parsed: ParsedFileEntities): EmbeddableItem[] { nodeType: 'Class', text, textHash: hashText(text), - identifier: { name: cls.name, filePath: cls.filePath, startLine: cls.startLine }, + identifier: { id: cls.id }, }); } @@ -111,7 +111,7 @@ function collectEmbeddableItems(parsed: ParsedFileEntities): EmbeddableItem[] { nodeType: 'Interface', text, textHash: hashText(text), - identifier: { name: iface.name, filePath: iface.filePath, startLine: iface.startLine }, + identifier: { id: iface.id }, }); } @@ -129,7 +129,7 @@ function collectEmbeddableItems(parsed: ParsedFileEntities): EmbeddableItem[] { nodeType: 'Type', text, textHash: hashText(text), - identifier: { name: t.name, filePath: t.filePath, startLine: t.startLine }, + identifier: { id: t.id }, }); } @@ -140,7 +140,7 @@ function collectEmbeddableItems(parsed: ParsedFileEntities): EmbeddableItem[] { nodeType: 'Component', text, textHash: hashText(text), - identifier: { name: comp.name, filePath: comp.filePath, startLine: comp.startLine }, + identifier: { id: comp.id }, }); } @@ -239,8 +239,11 @@ export async function embedParsedEntities( * Build a cache key for an embeddable item (matches the format used by getEmbeddingHashesForFiles). */ function itemCacheKey(item: EmbeddableItem): string { - const startLine = item.identifier['startLine']; - return `${item.nodeType}:${item.identifier['name']}:${item.identifier['filePath']}:${startLine}`; + const id = item.identifier['id']; + if (typeof id !== 'string' || id.length === 0) { + throw new Error(`Missing persisted id for ${item.nodeType} embedding`); + } + return id; } /** diff --git a/packages/core/src/enrichedSearchV2.ts b/packages/core/src/enrichedSearchV2.ts index 0763a5ed..b4441b12 100644 --- a/packages/core/src/enrichedSearchV2.ts +++ b/packages/core/src/enrichedSearchV2.ts @@ -46,6 +46,7 @@ export interface LinkedKnowledgeEntry { } export interface SiblingSymbol { + id: string; name: string; startLine: number; endLine: number; @@ -54,6 +55,7 @@ export interface SiblingSymbol { } export interface EnrichedV2Hit { + id: string; name: string; nodeType: string; filePath?: string; @@ -220,10 +222,10 @@ const ENRICHMENT_TIMEOUT_MS = 5_000; * and the rewrite dropped the batch from 240s to 0.4s. */ export const DEPENDENCY_DEPTH_CYPHER = ` - UNWIND $names AS symbolName - MATCH path = (entry:File)-[:CONTAINS|CALLS*1..6]->(n {name: symbolName}) + UNWIND $ids AS symbolId + MATCH path = (entry:File)-[:CONTAINS|CALLS*1..6]->(n {id: symbolId}) WHERE NOT ()-[:IMPORTS]->(entry) - RETURN symbolName, min(length(path)) AS minDepth + RETURN symbolId, min(length(path)) AS minDepth `; interface GraphEnrichment { @@ -236,23 +238,9 @@ interface GraphEnrichment { commitCount: number; } -/** - * Composite key for the map `enrichFromGraph` returns, and for looking a hit - * up in it. Symbol names collide constantly in real codebases (many classes - * each declare a "constructor", many modules each export a "GraphClient"), - * so a map keyed on name alone lets one declaration's numbers silently - * overwrite another's. filePath narrows to one file, startLine narrows - * further to one declaration for files that declare more than one symbol - * under the same name (overloads). File nodes carry no startLine, and - * `Candidate.startLine` is `undefined` for them; the query side normalizes - * the same way, so both sides still agree on the key. - */ -export function enrichmentKey( - filePath: string | undefined, - name: string, - startLine: number | undefined | null, -): string { - return `${filePath ?? ''}\x00${name}\x00${startLine ?? ''}`; +/** Use the persisted opaque identity for enrichment lookups. */ +export function enrichmentKey(id: string): string { + return id; } export async function enrichFromGraph( @@ -261,13 +249,13 @@ export async function enrichFromGraph( ): Promise> { if (hits.length === 0) return new Map(); - const names = hits.map(h => h.name); - const items = hits.map(h => ({ name: h.name, filePath: h.filePath ?? '' })); + const ids = hits.map(hit => hit.id); + const items = hits.map(hit => ({ id: hit.id })); // Single batch query: for each hit, count callers, callees, importers, // test references (files with test/spec in path), and dependency depth. // - // `n` is bound by its own MATCH, on (name, filePath), before any OPTIONAL + // `n` is bound by its own persisted ID before any OPTIONAL // expansion runs. It used to be bound inside the first OPTIONAL MATCH, // alongside the caller edge: `OPTIONAL MATCH (n {name: symbolName})<-[:CALLS]-(caller)`. // When a symbol had no callers that whole pattern failed to match, so `n` @@ -278,7 +266,7 @@ export async function enrichFromGraph( // A plain MATCH can't hang the way the one in DEPENDENCY_DEPTH_CYPHER // could: this is a single-hop exact-property lookup, not a bounded path // search over a densely connected hub, so there's no enumeration to blow - // up. The one behavior change is that a name with no matching node now + // up. The one behavior change is that an ID with no matching node now // produces no row at all, instead of a row of zeros. Every call site below // that reads this map already treats a missing entry as "no enrichment for // this hit" (see the `.get(...)` calls in enrichedSearchV2Impl), so that's @@ -286,7 +274,7 @@ export async function enrichFromGraph( // unreachable symbol. const cypher = ` UNWIND $items AS item - MATCH (n {name: item.name, filePath: item.filePath}) + MATCH (n {id: item.id}) OPTIONAL MATCH (n)<-[:CALLS]-(caller) WITH item, n, count(DISTINCT caller) AS callers OPTIONAL MATCH (n)-[:CALLS]->(callee) @@ -297,9 +285,9 @@ export async function enrichFromGraph( WHERE (testFile.filePath CONTAINS '.test.' OR testFile.filePath CONTAINS '.spec.' OR testFile.filePath CONTAINS '__tests__') AND ((testFile)-[:CONTAINS]->()-[:CALLS]->(n) OR (testFile)-[:IMPORTS]->()-[:CONTAINS]->(n)) - WITH item.name AS symbolName, n.filePath AS filePath, coalesce(n.startLine, n.line) AS startLine, + WITH item.id AS symbolId, callers, calleeNames, importers, count(DISTINCT testFile) AS testRefs - RETURN symbolName, filePath, startLine, callers, calleeNames, importers, testRefs + RETURN symbolId, callers, calleeNames, importers, testRefs `; try { @@ -310,11 +298,7 @@ export async function enrichFromGraph( const map = new Map(); for (const row of result.data) { - const key = enrichmentKey( - row['filePath'] as string | undefined, - row['symbolName'] as string, - row['startLine'] as number | null | undefined, - ); + const key = enrichmentKey(row['symbolId'] as string); map.set(key, { callerCount: (row['callers'] as number) ?? 0, callees: (row['calleeNames'] as string[]) ?? [], @@ -326,25 +310,22 @@ export async function enrichFromGraph( }); } - // Dependency depth: see DEPENDENCY_DEPTH_CYPHER. That query answers by - // name alone (it has no filePath or startLine to key on), so a depth - // found for a name applies to every hit sharing that name in this batch, - // and gets folded into each hit's own composite-keyed entry below. + // Dependency depth is keyed by the same persisted ID as the hit. try { const depthResult = await client.roQuery>(DEPENDENCY_DEPTH_CYPHER, { - params: { names }, + params: { ids }, timeout: ENRICHMENT_TIMEOUT_MS, }); - const depthByName = new Map(); + const depthById = new Map(); for (const row of depthResult.data) { if (row['minDepth'] != null) { - depthByName.set(row['symbolName'] as string, row['minDepth'] as number); + depthById.set(row['symbolId'] as string, row['minDepth'] as number); } } for (const hit of hits) { - const depth = depthByName.get(hit.name); + const depth = depthById.get(hit.id); if (depth == null) continue; - const enrichment = map.get(enrichmentKey(hit.filePath, hit.name, hit.startLine)); + const enrichment = map.get(enrichmentKey(hit.id)); if (enrichment) enrichment.dependencyDepth = depth; } } catch (err) { @@ -377,7 +358,7 @@ export async function enrichFromGraph( for (const hit of hits) { if (hit.filePath) { const gitData = gitByFile.get(hit.filePath); - const enrichment = map.get(enrichmentKey(hit.filePath, hit.name, hit.startLine)); + const enrichment = map.get(enrichmentKey(hit.id)); if (gitData && enrichment) { enrichment.lastModified = gitData.lastModified; enrichment.commitCount = gitData.commitCount; @@ -405,6 +386,7 @@ function distanceToScore(distance: number): number { } export interface Candidate { + id: string; name: string; nodeType: string; filePath?: string | undefined; @@ -416,7 +398,7 @@ export interface Candidate { /** Key function for rrfFuse when combining retrieval sources */ export function candidateKey(c: Candidate): string { - return `${c.nodeType}:${c.filePath}:${c.name}`; + return c.id; } /** @@ -510,14 +492,17 @@ async function retrieveCandidates( for (const r of results) { if (!matchesScope(r.filePath, scope, scopePaths)) continue; - const key = `${r.nodeType}:${r.filePath}:${r.name}`; - if (seen.has(key)) continue; - seen.add(key); + const persistedId = typeof r.properties?.['id'] === 'string' + ? r.properties['id'] + : r.nodeType === 'File' ? `File:${r.filePath}` : undefined; + if (!persistedId || seen.has(persistedId)) continue; + seen.add(persistedId); const vScore = distanceToScore(r.distance); // r.properties contains the full row from searchByVector (all node fields) const props = r.properties ?? {}; candidates.push({ + id: persistedId, name: r.name, nodeType: r.nodeType, filePath: r.filePath, @@ -542,43 +527,43 @@ async function retrieveCandidates( */ async function getLinkedKnowledge( client: GraphClient, - names: string[], + ids: string[], ): Promise> { const result = new Map(); - if (names.length === 0) return result; + if (ids.length === 0) return result; try { // Reverse ABOUT: (Entity)-[ABOUT]->(CodeNode) — find entities pointing at these code nodes const rows = await client.roQuery<{ - targetName: string; + targetId: string; entityText: string; entityType: string; confidence: number; fact: string | null; }>( - `UNWIND $names AS targetName + `UNWIND $ids AS targetId MATCH (e:Entity)-[r:ABOUT]->(t) - WHERE t.name = targetName + WHERE t.id = targetId OPTIONAL MATCH (e)-[rel:RELATES_TO]-() WHERE rel.invalid_at IS NULL - RETURN targetName, e.text AS entityText, e.type AS entityType, + RETURN targetId, e.text AS entityText, e.type AS entityType, r.confidence AS confidence, rel.fact AS fact LIMIT 100`, - { params: { names }, timeout: ENRICHMENT_TIMEOUT_MS }, + { params: { ids }, timeout: ENRICHMENT_TIMEOUT_MS }, ); for (const row of rows.data) { - const existing = result.get(row.targetName) ?? []; + const existing = result.get(row.targetId) ?? []; existing.push({ entityText: row.entityText, entityType: row.entityType, confidence: row.confidence, ...(row.fact != null ? { fact: row.fact } : {}), }); - result.set(row.targetName, existing); + result.set(row.targetId, existing); } - } catch { - // ABOUT edges may not exist — non-fatal + } catch (error) { + logger.debug(`Linked knowledge query failed (non-fatal): ${error}`); } return result; @@ -596,20 +581,19 @@ const SIBLING_AGGREGATE_CAP = 10_000; // bytes aggregate siblings JSON per hit * within the same file, using CONTAINS edges from the File node. * * Returns an array of 0–2 siblings. Empty when the file has only one symbol - * or the target isn't found by name+startLine. + * or the target ID is not found. * * NOTE: File nodes use `filePath` as the property key (not `path`). */ export async function fetchSiblingSymbols( client: GraphClient, filePath: string, - symbolName: string, - symbolStartLine: number, + symbolId: string, ): Promise { const cypher = ` MATCH (f:File)-[:CONTAINS]->(s) WHERE f.filePath = $filePath AND s.startLine IS NOT NULL - RETURN s.name AS name, s.startLine AS startLine, s.endLine AS endLine, + RETURN s.id AS id, s.name AS name, s.startLine AS startLine, s.endLine AS endLine, s.signature AS signature, labels(s)[0] AS nodeType ORDER BY s.startLine `; @@ -626,7 +610,7 @@ export async function fetchSiblingSymbols( } const idx = data.findIndex( - s => s['name'] === symbolName && s['startLine'] === symbolStartLine, + symbol => symbol['id'] === symbolId, ); if (idx === -1) return []; @@ -641,6 +625,7 @@ export async function fetchSiblingSymbols( sig = sig.slice(0, SIBLING_SIGNATURE_CAP); } out.push({ + id: row['id'] as string, name: row['name'] as string, startLine: row['startLine'] as number, endLine: row['endLine'] as number, @@ -654,25 +639,24 @@ export async function fetchSiblingSymbols( /** * Batch-fetch siblings for multiple hits, grouping by unique filePath to - * minimize graph round-trips. Returns a map of `filePath:name:startLine` → siblings. + * minimize graph round-trips. Returns a map of persisted symbol ID to siblings. */ async function fetchSiblingsForHits( client: GraphClient, - hits: Array<{ name: string; filePath?: string | undefined; startLine?: number | undefined }>, + hits: Array<{ id: string; filePath?: string | undefined }>, ): Promise> { const result = new Map(); if (hits.length === 0) return result; // Group hits by unique filePath - const byFile = new Map>(); + const byFile = new Map>(); for (const hit of hits) { - if (!hit.filePath || hit.startLine == null) continue; - const key = `${hit.filePath}:${hit.name}:${hit.startLine}`; + if (!hit.filePath) continue; const existing = byFile.get(hit.filePath); if (existing) { - existing.push({ name: hit.name, startLine: hit.startLine, key }); + existing.push({ id: hit.id }); } else { - byFile.set(hit.filePath, [{ name: hit.name, startLine: hit.startLine, key }]); + byFile.set(hit.filePath, [{ id: hit.id }]); } } @@ -684,7 +668,7 @@ async function fetchSiblingsForHits( const cypher = ` MATCH (f:File)-[:CONTAINS]->(s) WHERE f.filePath = $filePath AND s.startLine IS NOT NULL - RETURN s.name AS name, s.startLine AS startLine, s.endLine AS endLine, + RETURN s.id AS id, s.name AS name, s.startLine AS startLine, s.endLine AS endLine, s.signature AS signature, labels(s)[0] AS nodeType ORDER BY s.startLine `; @@ -700,10 +684,10 @@ async function fetchSiblingsForHits( return; } - for (const { name, startLine, key } of hitsInFile) { - const idx = rows.findIndex(r => r['name'] === name && r['startLine'] === startLine); + for (const { id } of hitsInFile) { + const idx = rows.findIndex(row => row['id'] === id); if (idx === -1) { - result.set(key, []); + result.set(id, []); continue; } @@ -713,6 +697,7 @@ async function fetchSiblingsForHits( let sig = row['signature'] as string | undefined; if (sig && sig.length > SIBLING_SIGNATURE_CAP) sig = sig.slice(0, SIBLING_SIGNATURE_CAP); siblings.push({ + id: row['id'] as string, name: row['name'] as string, startLine: row['startLine'] as number, endLine: row['endLine'] as number, @@ -724,9 +709,9 @@ async function fetchSiblingsForHits( // Cap aggregate size if (JSON.stringify(siblings).length > SIBLING_AGGREGATE_CAP) { // Keep only the first sibling if both together exceed the cap - result.set(key, siblings.slice(0, 1)); + result.set(id, siblings.slice(0, 1)); } else { - result.set(key, siblings); + result.set(id, siblings); } } }), @@ -857,7 +842,7 @@ async function enrichedSearchV2Impl( if (c.properties.signature) parts.push(`Signature: ${String(c.properties.signature).slice(0, 200)}`); if (c.properties.docstring) parts.push(String(c.properties.docstring).slice(0, 300)); // Graph signals help the reranker distinguish core code from leaf/UI code - const ge = prerankEnrichments.get(enrichmentKey(c.filePath, c.name, c.startLine)); + const ge = prerankEnrichments.get(enrichmentKey(c.id)); if (ge) { const signals: string[] = []; if (ge.callerCount > 0) signals.push(`called by ${ge.callerCount} functions`); @@ -905,14 +890,14 @@ async function enrichedSearchV2Impl( // Reuse pre-rank enrichments; fetch any missing (e.g., if reranker was skipped) let enrichments = prerankEnrichments; - const missingHits = topHits.filter(h => !enrichments.has(enrichmentKey(h.filePath, h.name, h.startLine))); + const missingHits = topHits.filter(hit => !enrichments.has(enrichmentKey(hit.id))); if (missingHits.length > 0) { const extra = await enrichFromGraph(client, missingHits); for (const [k, v] of extra) enrichments.set(k, v); } // Enrich with linked knowledge (ABOUT edges: knowledge → code) - const knowledgeLinks = await getLinkedKnowledge(client, topHits.map(h => h.name)); + const knowledgeLinks = await getLinkedKnowledge(client, topHits.map(hit => hit.id)); // Drawer-grep: fetch ±1 sibling symbols per hit, batched by unique filePath const siblingMap = await fetchSiblingsForHits(client, topHits); @@ -929,9 +914,10 @@ async function enrichedSearchV2Impl( const result: EnrichedV2Result = { hits: topHits.map(c => { - const graphData = enrichments.get(enrichmentKey(c.filePath, c.name, c.startLine)); + const graphData = enrichments.get(enrichmentKey(c.id)); const props = c.properties; return { + id: c.id, name: c.name, nodeType: c.nodeType, ...(c.filePath && { filePath: c.filePath }), @@ -958,11 +944,10 @@ async function enrichedSearchV2Impl( ...(graphData.commitCount > 0 && { commitCount: graphData.commitCount }), }), // Knowledge graph enrichment (ABOUT edges) - ...(knowledgeLinks.has(c.name) ? { linkedKnowledge: knowledgeLinks.get(c.name)! } : {}), + ...(knowledgeLinks.has(c.id) ? { linkedKnowledge: knowledgeLinks.get(c.id)! } : {}), // Drawer-grep: ±1 sibling symbols in the same file ...((): { siblings?: SiblingSymbol[] } => { - const key = `${c.filePath}:${c.name}:${c.startLine}`; - const sibs = siblingMap.get(key); + const sibs = siblingMap.get(c.id); return sibs && sibs.length > 0 ? { siblings: sibs } : {}; })(), }; diff --git a/packages/core/src/indexer.ts b/packages/core/src/indexer.ts index fc6e7a79..34cdd123 100644 --- a/packages/core/src/indexer.ts +++ b/packages/core/src/indexer.ts @@ -28,6 +28,12 @@ import { getSupportedExtensions, DEFAULT_IGNORE_PATTERNS, } from './pipeline'; +import { + buildProjectSymbolCatalog, + currentSymbolIds, + resolveProjectSymbolEdges, + type PersistedCatalogSymbol, +} from './pipeline/pipeline'; import { extractReExports, extractLocalExportedNames, type ReExportEntity } from '@codegraph/plugin-typescript'; import { parseMarkdownContent } from '@codegraph/plugin-markdown'; import { createOperations, type GraphClient } from '@codegraph/graph'; @@ -50,6 +56,81 @@ import { glob } from 'glob'; const execFileAsync = promisify(execFile); const logger = createLogger({ namespace: 'Core:Indexer' }); +const SOURCE_SYMBOL_LABELS = new Set([ + 'Function', + 'Class', + 'Interface', + 'Variable', + 'Type', + 'Component', +]); + +async function loadPersistedCatalogSymbols( + client: GraphClient, + projectId: string, +): Promise { + const labelExpression = client.dialect.labelsExpr('symbol'); + try { + const result = await client.roQuery>( + `MATCH (:Project {id: $projectId})-[:HAS_FILE]->(:File)-[:CONTAINS]->(symbol) + WHERE symbol.id IS NOT NULL + RETURN symbol.id AS id, ${labelExpression} AS labels, + symbol.filePath AS filePath, symbol.name AS name, + symbol.scopeKey AS scopeKey, symbol.disambiguator AS disambiguator, + symbol.isExported AS isExported, + coalesce(symbol.startLine, symbol.line, 0) AS startLine`, + { params: { projectId } }, + ); + const symbols: PersistedCatalogSymbol[] = []; + for (const row of result.data ?? []) { + const rawLabels = row['labels']; + const labels = Array.isArray(rawLabels) + ? rawLabels.filter((label): label is string => typeof label === 'string') + : typeof rawLabels === 'string' ? [rawLabels] : []; + const label = labels.find((candidate): candidate is PersistedCatalogSymbol['label'] => + SOURCE_SYMBOL_LABELS.has(candidate as PersistedCatalogSymbol['label']), + ); + const id = row['id']; + const filePath = row['filePath']; + const name = row['name']; + if (!label || typeof id !== 'string' || typeof filePath !== 'string' || typeof name !== 'string') { + continue; + } + symbols.push({ + id, + label, + filePath, + name, + scopeKey: typeof row['scopeKey'] === 'string' ? row['scopeKey'] : '', + disambiguator: typeof row['disambiguator'] === 'string' ? row['disambiguator'] : '', + isExported: row['isExported'] === true, + startLine: typeof row['startLine'] === 'number' ? row['startLine'] : 0, + }); + } + return symbols; + } catch (error) { + logger.warn(`Could not preload persisted symbol catalog: ${error instanceof Error ? error.message : String(error)}`); + return []; + } +} + +interface SymbolSweepOperations { + sweepStaleFileSymbols(filePath: string, currentIds: readonly string[]): Promise; +} + +function supportsSymbolSweep(ops: object): ops is SymbolSweepOperations { + return 'sweepStaleFileSymbols' in ops && typeof ops.sweepStaleFileSymbols === 'function'; +} + +async function sweepStaleSymbols( + ops: object, + parsed: ReturnType, +): Promise { + if (supportsSymbolSweep(ops)) { + await ops.sweepStaleFileSymbols(parsed.file.path, currentSymbolIds(parsed)); + } +} + /** Skip files larger than 512 KB — they stall the parser and are usually generated/bundled */ const MAX_FILE_SIZE_BYTES = 512 * 1024; @@ -443,16 +524,6 @@ export async function indexProject( logger.info(`Indexing ${rootPath}: found ${files.length} source files`); - if (files.length === 0) { - return { - success: true, - projectId: '', - projectName: basename(rootPath), - stats: { files: 0, entities: 0, edges: 0, errors: 0, durationMs: Date.now() - startTime }, - errorMessages: [], - }; - } - // Get graph operations const graphClient = options.client ?? await getGraphClient(); // Ensure schema/indexes exist before any writes (also pre-creates labels @@ -478,7 +549,6 @@ export async function indexProject( // created. The final upsertProject() call further down still runs, to // persist fileCount/lastParsed once those are known. await ops.upsertProject(project); - // ---------------------------------------------------------------- // Incremental: build hash map of previously indexed files // ---------------------------------------------------------------- @@ -493,6 +563,17 @@ export async function indexProject( } } + const discoveredPaths = new Set(files); + const vanishedPaths = [...storedHashes.keys()].filter(path => !discoveredPaths.has(path)); + if (vanishedPaths.length > 0) { + await Promise.all(vanishedPaths.map(path => ops.removeFileAndCleanup(path))); + logger.info(`Incremental: removed ${vanishedPaths.length} vanished file${vanishedPaths.length === 1 ? '' : 's'}`); + } + + const persistedCatalogSymbols = !force && existingProject + ? await loadPersistedCatalogSymbols(graphClient, project.id) + : []; + // ---------------------------------------------------------------- // Compute content hashes for all files, determine which need processing // Read file content once — reuse for both hashing and parsing @@ -636,6 +717,7 @@ export async function indexProject( // (MERGE), instead of a fresh node replacing a deleted one. await Promise.all(chunk.map(r => ops.removeFileContents(r.file))); await ops.batchUpsertBulk(chunk.map(r => r.built)); + await Promise.all(chunk.map(r => sweepStaleSymbols(ops, r.built))); } await ops.linkProjectFiles(project.id, chunk.map(r => r.file)); @@ -650,6 +732,7 @@ export async function indexProject( for (const { file, built, extracted } of chunk) { try { await ops.batchUpsert(built); + if (!useCreatePath) await sweepStaleSymbols(ops, built); await ops.linkProjectFile(project.id, file); totalFiles++; totalEntities += 1 + countEntities(extracted); @@ -664,9 +747,6 @@ export async function indexProject( } }; - // Pipeline: parse batch N while upserting batch N-1 - let pendingUpsert: Promise | null = null; - let pendingBatch: ParsedResult[] = []; const UPSERT_CHUNK_SIZE = 50; for (let i = 0; i < codeFiles.length; i += concurrency) { @@ -708,15 +788,6 @@ export async function indexProject( continue; } allParsed.push(result.value); - pendingBatch.push(result.value); - } - - // When pending batch reaches upsert chunk size, fire upsert (with backpressure) - if (pendingBatch.length >= UPSERT_CHUNK_SIZE) { - if (pendingUpsert) await pendingUpsert; // Backpressure: wait for previous upsert - const chunk = pendingBatch; - pendingBatch = []; - pendingUpsert = upsertChunk(chunk); } // Progress logging @@ -727,11 +798,14 @@ export async function indexProject( } } - // Flush remaining parsed results - if (pendingUpsert) await pendingUpsert; - if (pendingBatch.length > 0) { - await upsertChunk(pendingBatch); - pendingBatch = []; + const symbolCatalog = buildProjectSymbolCatalog( + allParsed.map(result => result.built), + persistedCatalogSymbols, + ); + resolveProjectSymbolEdges(allParsed.map(result => result.built), symbolCatalog); + + for (let i = 0; i < allParsed.length; i += UPSERT_CHUNK_SIZE) { + await upsertChunk(allParsed.slice(i, i + UPSERT_CHUNK_SIZE)); } // Parse markdown files (typically few, no pipeline needed) @@ -949,6 +1023,13 @@ export async function indexSingleFile( // Skip non-exported variables parsed.variables = parsed.variables.filter(v => v.isExported); + const existingProject = projectRoot ? await ops.getProjectByRoot(projectRoot) : null; + const persistedCatalogSymbols = existingProject + ? await loadPersistedCatalogSymbols(graphClient, existingProject.id) + : []; + const symbolCatalog = buildProjectSymbolCatalog([parsed], persistedCatalogSymbols); + resolveProjectSymbolEdges([parsed], symbolCatalog); + // Clean up old entities before re-upserting (prevents stale nodes when // code moves/deletes). This path re-indexes a file whose content // changed (see onFileChanged in the watcher integrations) -- it was not @@ -958,6 +1039,7 @@ export async function indexSingleFile( // go through removeFileAndCleanup() directly, from onFileRemoved. await ops.removeFileContents(filePath); await ops.batchUpsert(parsed); + await sweepStaleSymbols(ops, parsed); // Embedding pass — deferred (background) or blocking let embedded = 0; diff --git a/packages/core/src/pipeline/pipeline.ts b/packages/core/src/pipeline/pipeline.ts index b14bd139..f475689c 100644 --- a/packages/core/src/pipeline/pipeline.ts +++ b/packages/core/src/pipeline/pipeline.ts @@ -265,6 +265,212 @@ export interface PipelineOptions { localExportsIndex?: ReadonlyMap; } +type SourceSymbolLabel = 'Function' | 'Class' | 'Interface' | 'Variable' | 'Type' | 'Component'; + +interface CatalogSymbol { + id: string; + label: SourceSymbolLabel; + filePath: string; + name: string; + scopeKey: string; + disambiguator: string; + isOverloadSignature?: boolean; + isExported: boolean; + startLine: number; +} + +export interface PersistedCatalogSymbol extends CatalogSymbol {} + +export interface ProjectSymbolCatalog { + readonly byId: ReadonlyMap; + readonly byDeclaration: ReadonlyMap; + readonly byExport: ReadonlyMap; +} + +interface SymbolLookup { + labels: readonly SourceSymbolLabel[]; + filePath: string; + name: string; + startLine?: number; + ownerName?: string; + exportedOnly?: boolean; +} + +interface EdgeResolutionHint { + from?: SymbolLookup; + to?: SymbolLookup; +} + +const edgeResolutionHints = new WeakMap(); + +const SYMBOL_COLLECTIONS = [ + ['Function', 'functions'], + ['Class', 'classes'], + ['Interface', 'interfaces'], + ['Variable', 'variables'], + ['Type', 'types'], + ['Component', 'components'], +] as const; + +function declarationKey(label: SourceSymbolLabel, filePath: string, name: string): string { + return `${label}\u0000${filePath}\u0000${name}`; +} + +function exportKey(filePath: string, name: string): string { + return `${filePath}\u0000${name}`; +} + +function symbolStartLine(symbol: { startLine?: number; line?: number }): number { + return symbol.startLine ?? symbol.line ?? 0; +} + +function symbolsFromParsed(parsed: ParsedFileEntities): CatalogSymbol[] { + const symbols: CatalogSymbol[] = []; + for (const [label, collection] of SYMBOL_COLLECTIONS) { + for (const symbol of parsed[collection]) { + symbols.push({ + id: symbol.id, + label, + filePath: symbol.filePath, + name: symbol.name, + scopeKey: symbol.scopeKey, + disambiguator: symbol.disambiguator, + isOverloadSignature: label === 'Function' + && 'isOverloadSignature' in symbol + && symbol.isOverloadSignature === true, + isExported: symbol.isExported, + startLine: symbolStartLine(symbol), + }); + } + } + return symbols; +} + +export function currentSymbolIds(parsed: ParsedFileEntities): string[] { + return symbolsFromParsed(parsed).map(symbol => symbol.id); +} + +export function buildProjectSymbolCatalog( + parsedList: readonly ParsedFileEntities[], + persistedSymbols: readonly PersistedCatalogSymbol[] = [], +): ProjectSymbolCatalog { + const changedFiles = new Set(parsedList.map(parsed => parsed.file.path)); + const symbols = [ + ...persistedSymbols.filter(symbol => !changedFiles.has(symbol.filePath)), + ...parsedList.flatMap(symbolsFromParsed), + ]; + const byId = new Map(); + const byDeclaration = new Map(); + const byExport = new Map(); + + for (const symbol of symbols) { + byId.set(symbol.id, symbol); + if (symbol.isOverloadSignature === true) continue; + const declared = declarationKey(symbol.label, symbol.filePath, symbol.name); + const declarationMatches = byDeclaration.get(declared) ?? []; + declarationMatches.push(symbol); + byDeclaration.set(declared, declarationMatches); + if (symbol.isExported && symbol.scopeKey.length === 0) { + const exported = exportKey(symbol.filePath, symbol.name); + const exportMatches = byExport.get(exported) ?? []; + exportMatches.push(symbol); + byExport.set(exported, exportMatches); + } + } + + return { byId, byDeclaration, byExport }; +} + +function resolveLookup(catalog: ProjectSymbolCatalog, lookup: SymbolLookup): CatalogSymbol[] { + let matches = lookup.exportedOnly + ? [...(catalog.byExport.get(exportKey(lookup.filePath, lookup.name)) ?? [])] + : lookup.labels.flatMap(label => catalog.byDeclaration.get( + declarationKey(label, lookup.filePath, lookup.name), + ) ?? []); + matches = matches.filter(symbol => lookup.labels.includes(symbol.label)); + if (lookup.ownerName) { + const owner = `Class:${lookup.ownerName}`; + matches = matches.filter(symbol => + symbol.scopeKey === owner || symbol.scopeKey.endsWith(`/${owner}`), + ); + } + if (lookup.startLine !== undefined) { + const atLine = matches.filter(symbol => symbol.startLine === lookup.startLine); + if (atLine.length > 0) matches = atLine; + } + return matches; +} + +function resolveSingleEndpoint( + currentId: string, + lookup: SymbolLookup | undefined, + catalog: ProjectSymbolCatalog, +): string | undefined { + if (currentId && catalog.byId.has(currentId)) return currentId; + if (!lookup) return undefined; + const matches = resolveLookup(catalog, lookup); + return matches.length === 1 ? matches[0]?.id : undefined; +} + +export function resolveProjectSymbolEdges( + parsedList: readonly ParsedFileEntities[], + catalog: ProjectSymbolCatalog, +): void { + for (const parsed of parsedList) { + parsed.callEdges = parsed.callEdges.flatMap(edge => { + const hint = edgeResolutionHints.get(edge); + const callerId = resolveSingleEndpoint(edge.callerId, hint?.from, catalog); + const calleeMatches = edge.calleeId && catalog.byId.has(edge.calleeId) + ? [catalog.byId.get(edge.calleeId)!] + : hint?.to ? resolveLookup(catalog, hint.to) : []; + const implementationMatches = calleeMatches.filter( + target => target.isOverloadSignature !== true, + ); + if (!callerId || implementationMatches.length === 0) return []; + return implementationMatches.map(target => ({ ...edge, callerId, calleeId: target.id })); + }); + + parsed.importsSymbolEdges = parsed.importsSymbolEdges.flatMap(edge => { + const hint = edgeResolutionHints.get(edge); + const targets = edge.toId && catalog.byId.has(edge.toId) + ? [catalog.byId.get(edge.toId)!] + : hint?.to ? resolveLookup(catalog, hint.to) : []; + return targets.map(target => ({ + ...edge, + fromId: edge.fromId ?? `File:${edge.fromFilePath}`, + toId: target.id, + })); + }); + + parsed.extendsEdges = parsed.extendsEdges.flatMap(edge => { + const hint = edgeResolutionHints.get(edge); + const childId = resolveSingleEndpoint(edge.childId, hint?.from, catalog); + const parentId = resolveSingleEndpoint(edge.parentId, hint?.to, catalog); + return childId && parentId ? [{ childId, parentId }] : []; + }); + + parsed.implementsEdges = parsed.implementsEdges.flatMap(edge => { + const hint = edgeResolutionHints.get(edge); + const classId = resolveSingleEndpoint(edge.classId, hint?.from, catalog); + const interfaceId = resolveSingleEndpoint(edge.interfaceId, hint?.to, catalog); + return classId && interfaceId ? [{ classId, interfaceId }] : []; + }); + + parsed.rendersEdges = parsed.rendersEdges.flatMap(edge => { + const hint = edgeResolutionHints.get(edge); + const parentId = resolveSingleEndpoint(edge.parentId, hint?.from, catalog); + const childId = resolveSingleEndpoint(edge.childId, hint?.to, catalog); + return parentId && childId ? [{ ...edge, parentId, childId }] : []; + }); + + parsed.exportsEdges = parsed.exportsEdges.flatMap(edge => { + const hint = edgeResolutionHints.get(edge); + const toId = resolveSingleEndpoint(edge.toId ?? '', hint?.to, catalog); + return toId ? [{ ...edge, fromId: edge.fromId ?? `File:${edge.filePath}`, toId }] : []; + }); + } +} + // ============================================================================ // Barrel re-export chain resolution (batch/full-index only) // ============================================================================ @@ -529,21 +735,27 @@ function buildInheritanceEdgesFromRefs( if (ref.type === 'extends') { if (cls) { - extendsEdges.push({ - childId: `Class:${filePath}:${cls.name}:${cls.startLine}`, - parentId: `Class:external:${ref.parentName}`, + const edge = { childId: ref.fromId ?? cls.id, parentId: ref.toId ?? '' }; + edgeResolutionHints.set(edge, { + from: { labels: ['Class'], filePath, name: cls.name, startLine: cls.startLine }, + to: { labels: ['Class'], filePath, name: ref.parentName }, }); + extendsEdges.push(edge); } else if (iface) { - extendsEdges.push({ - childId: `Interface:${filePath}:${iface.name}:${iface.startLine}`, - parentId: `Interface:external:${ref.parentName}`, + const edge = { childId: ref.fromId ?? iface.id, parentId: ref.toId ?? '' }; + edgeResolutionHints.set(edge, { + from: { labels: ['Interface'], filePath, name: iface.name, startLine: iface.startLine }, + to: { labels: ['Interface'], filePath, name: ref.parentName }, }); + extendsEdges.push(edge); } } else if (ref.type === 'implements' && cls) { - implementsEdges.push({ - classId: `Class:${filePath}:${cls.name}:${cls.startLine}`, - interfaceId: `Interface:external:${ref.parentName}`, + const edge = { classId: ref.fromId ?? cls.id, interfaceId: ref.toId ?? '' }; + edgeResolutionHints.set(edge, { + from: { labels: ['Class'], filePath, name: cls.name, startLine: cls.startLine }, + to: { labels: ['Interface'], filePath, name: ref.parentName }, }); + implementsEdges.push(edge); } } @@ -563,13 +775,23 @@ function collectExportsEdges(file: FileEntity, extracted: ExtractedEntities): Pa ...(extracted.hasPropertyEdges ?? []).map((edge) => edge.toId), ]); const push = ( - id: string | undefined, + id: string, name: string, isExported: boolean, symbolKind: ParsedFileEntities['exportsEdges'][number]['symbolKind'], ): void => { - if (isExported && (!id || !memberIds.has(id))) { - edges.push({ filePath: file.path, symbolName: name, symbolKind }); + if (isExported && !memberIds.has(id)) { + const edge = { + fromId: `File:${file.path}`, + toId: id, + filePath: file.path, + symbolName: name, + symbolKind, + }; + edgeResolutionHints.set(edge, { + to: { labels: [symbolKind], filePath: file.path, name, exportedOnly: true }, + }); + edges.push(edge); } }; for (const fn of extracted.functions) push(fn.id, fn.name, fn.isExported, 'Function'); @@ -607,12 +829,21 @@ function collectImportsSymbolEdges( const localName = spec.alias ?? spec.name; const resolved = resolvedImportMap?.get(localName); const edge: ParsedFileEntities['importsSymbolEdges'][number] = { + fromId: `File:${file.path}`, fromFilePath: file.path, toFilePath: resolved?.filePath ?? resolvedPath, symbolName: resolved?.exportedName ?? spec.name, isDefault: false, }; if (spec.alias) edge.alias = spec.alias; + edgeResolutionHints.set(edge, { + to: { + labels: ['Function', 'Class', 'Interface', 'Variable', 'Type', 'Component'], + filePath: edge.toFilePath, + name: edge.symbolName, + exportedOnly: true, + }, + }); edges.push(edge); } } @@ -622,22 +853,34 @@ function collectImportsSymbolEdges( /** * Build call edges from CallReference[] (non-TS languages). */ -function buildCallEdgesFromRefs(refs: CallReference[]): ParsedFileEntities['callEdges'] { +function buildCallEdgesFromRefs( + refs: CallReference[], + extracted: ExtractedEntities, +): ParsedFileEntities['callEdges'] { // Non-TS plugins haven't been migrated to attribution-aware extraction yet. // They produce CallReferences with `callerName` only (no kind), defaulting // to Function caller and via='direct': the same defaults the old // TypeScript path used, so behavior is unchanged for those plugins. - return refs.map((call) => ({ - callerId: `Function:${call.filePath}:${call.callerName}`, - // When the extractor resolved the callee to another file (via import - // analysis, see CallExtractionContext), key the edge on that file - // instead of unconditionally assuming the callee lives in the same file - // as the caller. Absent calleeFilePath still means same-file. - calleeId: `Function:${call.calleeFilePath ?? call.filePath}:${call.calleeName}`, - line: call.line, - callerKind: 'Function' as const, - via: 'direct' as const, - })); + return refs.map((call) => { + const caller = extracted.functions.find(fn => fn.name === call.callerName); + const edge = { + callerId: call.fromId ?? caller?.id ?? '', + calleeId: call.toId ?? '', + line: call.line, + callerKind: 'Function' as const, + via: 'direct' as const, + }; + edgeResolutionHints.set(edge, { + from: { labels: ['Function'], filePath: call.filePath, name: call.callerName }, + to: { + labels: ['Function'], + filePath: call.calleeFilePath ?? call.filePath, + name: call.calleeName, + exportedOnly: call.calleeFilePath !== undefined && call.calleeFilePath !== call.filePath, + }, + }); + return edge; + }); } /** @@ -736,19 +979,43 @@ export function buildParsedFileEntities( includeExternals, ); - extendsEdges = inheritance.extends.map((ext) => ({ - childId: `Class:${ext.childFilePath}:${ext.childName}:${ext.childStartLine}`, - parentId: ext.parentFilePath - ? `Class:${ext.parentFilePath}:${ext.parentName}` - : `Class:external:${ext.parentName}`, - })); - - implementsEdges = inheritance.implements.map((impl) => ({ - classId: `Class:${impl.classFilePath}:${impl.className}:${impl.classStartLine}`, - interfaceId: impl.interfaceFilePath - ? `Interface:${impl.interfaceFilePath}:${impl.interfaceName}` - : `Interface:external:${impl.interfaceName}`, - })); + extendsEdges = inheritance.extends.map((ext) => { + const child = extracted.classes.find(cls => + cls.name === ext.childName && cls.startLine === ext.childStartLine, + ); + const edge = { childId: ext.fromId ?? child?.id ?? '', parentId: ext.toId ?? '' }; + if (ext.parentFilePath) { + edgeResolutionHints.set(edge, { + from: { + labels: ['Class'], + filePath: ext.childFilePath, + name: ext.childName, + startLine: ext.childStartLine, + }, + to: { labels: ['Class'], filePath: ext.parentFilePath, name: ext.parentName }, + }); + } + return edge; + }); + + implementsEdges = inheritance.implements.map((impl) => { + const child = extracted.classes.find(cls => + cls.name === impl.className && cls.startLine === impl.classStartLine, + ); + const edge = { classId: impl.fromId ?? child?.id ?? '', interfaceId: impl.toId ?? '' }; + if (impl.interfaceFilePath) { + edgeResolutionHints.set(edge, { + from: { + labels: ['Class'], + filePath: impl.classFilePath, + name: impl.className, + startLine: impl.classStartLine, + }, + to: { labels: ['Interface'], filePath: impl.interfaceFilePath, name: impl.interfaceName }, + }); + } + return edge; + }); } else if (plugin?.extractors.extractInheritance && rootNode) { // All other languages: use registry-dispatched extractInheritance const refs = plugin.extractors.extractInheritance(rootNode as unknown as GenericSyntaxNode, file.path); @@ -793,18 +1060,41 @@ export function buildParsedFileEntities( resolvedImportMap, ); callEdges = calls.map((call) => { + const callerCollection = call.callerKind === 'Function' + ? extracted.functions + : call.callerKind === 'Variable' + ? extracted.variables + : call.callerKind === 'Class' + ? extracted.classes + : extracted.interfaces; + const caller = callerCollection.find(candidate => + candidate.name === call.callerName && symbolStartLine(candidate) === call.callerStartLine, + ); const edge: ParsedFileEntities['callEdges'][number] = { - // Caller id encodes its kind so cross-label disambiguation works - // downstream, see graph operations.ts CALLS upsert. - callerId: `${call.callerKind}:${call.callerFilePath}:${call.callerName}`, - calleeId: call.calleeFilePath - ? `Function:${call.calleeFilePath}:${call.calleeName}` - : `Function:external:${call.calleeName}`, + callerId: call.fromId ?? caller?.id ?? '', + calleeId: call.toId ?? '', line: call.line, callerKind: call.callerKind, via: call.via, }; if (call.calleeClassName) edge.calleeClassName = call.calleeClassName; + if (call.calleeFilePath) { + edgeResolutionHints.set(edge, { + from: { + labels: [call.callerKind], + filePath: call.callerFilePath, + name: call.callerName, + startLine: call.callerStartLine, + }, + to: { + labels: ['Function'], + filePath: call.calleeFilePath, + name: call.calleeName, + ...(call.calleeClassName ? { ownerName: call.calleeClassName } : {}), + exportedOnly: call.calleeFilePath !== call.callerFilePath && !call.calleeClassName, + }, + }); + } return edge; }); } else if (plugin?.extractors.extractCalls) { @@ -820,7 +1110,7 @@ export function buildParsedFileEntities( const refs = plugin.extractors.extractCalls(rootNode as unknown as GenericSyntaxNode, file.path, { imports: extracted.imports, }); - callEdges = buildCallEdgesFromRefs(refs); + callEdges = buildCallEdgesFromRefs(refs, extracted); } } @@ -834,13 +1124,26 @@ export function buildParsedFileEntities( extracted.imports, includeExternals, ); - rendersEdges = renders.map((render) => ({ - parentId: `Component:${render.parentFilePath}:${render.parentName}`, - childId: render.childFilePath - ? `Component:${render.childFilePath}:${render.childName}` - : `Component:external:${render.childName}`, - line: render.line, - })); + rendersEdges = renders.map((render) => { + const parent = extracted.components.find(component => component.name === render.parentName); + const edge = { + parentId: render.fromId ?? parent?.id ?? '', + childId: render.toId ?? '', + line: render.line, + }; + if (render.childFilePath) { + edgeResolutionHints.set(edge, { + from: { labels: ['Component'], filePath: render.parentFilePath, name: render.parentName }, + to: { + labels: ['Component'], + filePath: render.childFilePath, + name: render.childName, + exportedOnly: render.childFilePath !== render.parentFilePath, + }, + }); + } + return edge; + }); } // --- TypeRefs / HAS_PARAM / RETURNS / USES_TYPE, re-resolved through the barrel chain --- diff --git a/packages/core/src/services/graph-data-service.ts b/packages/core/src/services/graph-data-service.ts index 0d0dc32e..223cfeaa 100644 --- a/packages/core/src/services/graph-data-service.ts +++ b/packages/core/src/services/graph-data-service.ts @@ -212,7 +212,13 @@ export async function getEntityWithConnectionsImpl( outLabels: string[] | null; }>(` MATCH (n) - WHERE n.filePath = $id OR (n.name + ':' + n.filePath) = $id + WHERE n.id = $id + OR ('Commit:' + n.hash) = $id + OR ('MarkdownDocument:' + n.path) = $id + OR ('Section:' + n.filePath + ':' + toString(n.startLine)) = $id + OR ('CodeBlock:' + n.filePath + ':' + toString(n.startLine)) = $id + OR ('Link:' + n.filePath + ':' + toString(n.line) + ':' + n.target) = $id + OR ('Entity:' + n.type + ':' + n.text) = $id OPTIONAL MATCH (inNode)-[inEdge]->(n) OPTIONAL MATCH (n)-[outEdge]->(outNode) RETURN n, ${dialect.labelsExpr('n')} as labels, @@ -411,29 +417,19 @@ export async function getNeighborsImpl( cypherMatch = '(center)-[r]-(neighbor)'; } - // Parse node ID to build center match - const parts = id.split(':'); - const isFileId = parts[0] === 'File'; - const actualPath = isFileId ? parts.slice(1).join(':') : undefined; - - let centerMatch: string; + const centerMatch = `center.id = $id + OR ('Commit:' + center.hash) = $id + OR ('MarkdownDocument:' + center.path) = $id + OR ('Section:' + center.filePath + ':' + toString(center.startLine)) = $id + OR ('CodeBlock:' + center.filePath + ':' + toString(center.startLine)) = $id + OR ('Link:' + center.filePath + ':' + toString(center.line) + ':' + center.target) = $id + OR ('Entity:' + center.type + ':' + center.text) = $id`; const queryParams: Record> = { limit: depth * 50 }; if (safeEdgeTypes && safeEdgeTypes.length > 0) { queryParams.edgeTypes = safeEdgeTypes; } - if (isFileId && actualPath) { - centerMatch = 'center.filePath = $actualPath'; - queryParams.actualPath = actualPath; - } else if (parts.length >= 4) { - centerMatch = '(center.filePath = $filePath AND center.name = $name AND (center.startLine = $line OR center.line = $line))'; - queryParams.filePath = parts[1] ?? ''; - queryParams.name = parts[2] ?? ''; - queryParams.line = parseInt(parts[3] ?? '0', 10) || 0; - } else { - centerMatch = '(center.name = $simpleId OR center.filePath = $simpleId)'; - queryParams.simpleId = id; - } + queryParams.id = id; const result = await client.roQuery<{ neighbor: Record; diff --git a/packages/core/src/services/helpers.ts b/packages/core/src/services/helpers.ts index e4347382..dd5b9f66 100644 --- a/packages/core/src/services/helpers.ts +++ b/packages/core/src/services/helpers.ts @@ -103,10 +103,7 @@ export function generateNodeId(label: NodeLabel | 'Entity', props: Record 0 + ? result.filePath + : result.id.slice('File:'.length) + return { + id: result.id, + label: result.name, + type: 'File', + properties: { ...result, filePath }, + } + } + if (typeof result.id !== 'string' || !/^sym:v1:[a-f0-9]{64}$/.test(result.id)) { + throw new Error('Search result is missing a persisted id') + } return { - id: canonicalSymbolNodeId(result.nodeType, { - name: result.name, - filePath: result.filePath, - startLine, - }), + id: result.id, label: result.name, type: result.nodeType, properties: result, @@ -164,7 +166,7 @@ export function ExplorerNavigation({ export function AppShell({ projectId, projectName }: { projectId?: string | null; projectName?: string }) { const [selectionHistory, setSelectionHistory] = useState(EMPTY_SELECTION_HISTORY) - const [highlightedNames, setHighlightedNames] = useState>(new Set()) + const [highlightedNodeIds, setHighlightedNodeIds] = useState>(new Set()) const [hiddenEdgeTypes, setHiddenEdgeTypes] = useState>(new Set()) const [hiddenNodeTypes, setHiddenNodeTypes] = useState>(new Set()) const [showQuery, setShowQuery] = useState(false) @@ -216,9 +218,7 @@ export function AppShell({ projectId, projectName }: { projectId?: string | null const controller = new AbortController() setReferencesLoading(true) fetchReferences( - name, - selectedNode.properties?.filePath as string | undefined, - selectedNode.properties?.startLine as number | undefined, + selectedNode.id, controller.signal, ) .then((result) => { @@ -261,12 +261,12 @@ export function AppShell({ projectId, projectName }: { projectId?: string | null return () => controller.abort() }, [selectedNode]) - const referenceKeys = new Set( - (references?.references ?? []).map((r) => referenceKey(r.filePath, r.name, r.startLine)), + const referenceNodeIds = new Set( + (references?.references ?? []).map((reference) => reference.id), ) - const handleSearchHighlight = useCallback((names: string[]) => { - setHighlightedNames(new Set(names)) + const handleSearchHighlight = useCallback((nodeIds: string[]) => { + setHighlightedNodeIds(new Set(nodeIds.filter((id) => id.startsWith('sym:v1:')))) }, []) const handleToggleEdgeType = useCallback((edgeType: string) => { @@ -295,10 +295,11 @@ export function AppShell({ projectId, projectName }: { projectId?: string | null apiUrl={API_URL} onHighlight={handleSearchHighlight} onSelectResult={(result) => { - setHighlightedNames(new Set([result.name])) + const node = searchResultToGraphNode(result) + setHighlightedNodeIds(new Set([node.id])) // Open the detail panel too. The search payload already carries // filePath and line numbers, which is everything the panel needs. - handleNodeSelect(searchResultToGraphNode(result)) + handleNodeSelect(node) }} /> @@ -314,11 +315,11 @@ export function AppShell({ projectId, projectName }: { projectId?: string | null apiUrl={API_URL} onNodeSelect={handleNodeSelect} selectedNode={selectedNode} - highlightedNames={highlightedNames} + highlightedNodeIds={highlightedNodeIds} hiddenEdgeTypes={hiddenEdgeTypes} hiddenNodeTypes={hiddenNodeTypes} projectId={projectId} - referenceKeys={referenceKeys} + referenceNodeIds={referenceNodeIds} /> {/* Toolbar: Query toggle + Legend */}
diff --git a/packages/dashboard/src/components/dashboard/entity-detail.tsx b/packages/dashboard/src/components/dashboard/entity-detail.tsx index dee407d8..4f4a3474 100644 --- a/packages/dashboard/src/components/dashboard/entity-detail.tsx +++ b/packages/dashboard/src/components/dashboard/entity-detail.tsx @@ -5,7 +5,6 @@ import { Button } from '@/components/ui/button' import { Separator } from '@/components/ui/separator' import { NODE_COLORS } from '@/lib/cytoscape-config' import { API_URL } from '@/lib/api' -import { canonicalSymbolNodeId } from '@/lib/references' import type { FileRelationshipNode, FileRelationships, @@ -613,8 +612,8 @@ function ReferenceGroup({ label, items, declaringFile, onSelect }: { {label}

    - {items.map((ref, index) => ( -
  • + {items.map((ref) => ( +
- {results.map((r, i) => ( + {results.map((r) => (