From a87cd7be8e130f1a4630b1149711402d1f5c8671 Mon Sep 17 00:00:00 2001 From: GrokCode Date: Fri, 22 May 2026 14:52:27 +0000 Subject: [PATCH 1/4] turn 0 From 73b3f8f3e5f46fe7ceb033198be0e49c7c23a606 Mon Sep 17 00:00:00 2001 From: GrokCode Date: Fri, 22 May 2026 15:58:20 +0000 Subject: [PATCH 2/4] turn 1 --- __tests__/overlay.test.ts | 1092 +++++++++++++++++++++++++++++++++ package-lock.json | 1 - src/index.ts | 123 +++- src/overlay/branch-diff.ts | 144 +++++ src/overlay/index.ts | 12 + src/overlay/overlay-engine.ts | 543 ++++++++++++++++ src/overlay/remote-client.ts | 198 ++++++ src/overlay/types.ts | 72 +++ 8 files changed, 2183 insertions(+), 2 deletions(-) create mode 100644 __tests__/overlay.test.ts create mode 100644 src/overlay/branch-diff.ts create mode 100644 src/overlay/index.ts create mode 100644 src/overlay/overlay-engine.ts create mode 100644 src/overlay/remote-client.ts create mode 100644 src/overlay/types.ts diff --git a/__tests__/overlay.test.ts b/__tests__/overlay.test.ts new file mode 100644 index 00000000..cfba7337 --- /dev/null +++ b/__tests__/overlay.test.ts @@ -0,0 +1,1092 @@ +/** + * Overlay System Tests + * + * Comprehensive test suite for the remote graph overlay system: + * - RemoteGraphClient: fetch, cache, open/close lifecycle + * - BranchDiffIndexer: git diff detection + * - OverlayQueryEngine: merged query semantics + * - CodeGraph.openOverlay: end-to-end integration + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { execSync } from 'child_process'; +import { DatabaseConnection, getDatabasePath } from '../src/db'; +import { QueryBuilder } from '../src/db/queries'; +import { RemoteGraphClient } from '../src/overlay/remote-client'; +import { BranchDiffIndexer } from '../src/overlay/branch-diff'; +import { OverlayQueryEngine } from '../src/overlay/overlay-engine'; +import { Node, Edge } from '../src/types'; +import { GraphTraverser } from '../src/graph/traversal'; + +// =========================================================================== +// Helpers +// =========================================================================== + +/** Create a temp directory that is cleaned up after each test. */ +function makeTempDir(prefix: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), `codegraph-${prefix}-`)); +} + +/** Initialize a fresh SQLite database with the codegraph schema. */ +function initDb(dbPath: string, opts?: { disableForeignKeys?: boolean }): { db: DatabaseConnection; queries: QueryBuilder } { + const dir = path.dirname(dbPath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const db = DatabaseConnection.initialize(dbPath); + if (opts?.disableForeignKeys) { + // Overlay databases legitimately have edges referencing nodes in another DB + db.getDb().pragma('foreign_keys = OFF'); + } + const queries = new QueryBuilder(db.getDb()); + return { db, queries }; +} + +/** Create a minimal Node object for testing. */ +function makeNode(overrides: Partial & { id: string; name: string; filePath: string }): Node { + return { + kind: 'function', + qualifiedName: `${overrides.filePath}::${overrides.name}`, + language: 'typescript', + startLine: 1, + endLine: 10, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + ...overrides, + }; +} + +/** Create a minimal Edge object for testing. */ +function makeEdge(source: string, target: string, kind: Edge['kind'] = 'calls'): Edge { + return { source, target, kind }; +} + +/** Run a git command in a directory. */ +function git(dir: string, cmd: string): string { + return execSync(`git ${cmd}`, { cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); +} + +// =========================================================================== +// RemoteGraphClient +// =========================================================================== + +describe('RemoteGraphClient', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = makeTempDir('remote-client'); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should fetch a local file and open the database', async () => { + // Create a source database + const srcDbPath = path.join(tempDir, 'source.db'); + const { db: srcDb, queries: srcQueries } = initDb(srcDbPath); + const node = makeNode({ id: 'base-1', name: 'baseFunc', filePath: 'src/base.ts' }); + srcQueries.insertNode(node); + srcDb.close(); + + // Create client pointing to the source file + const cacheDir = path.join(tempDir, 'cache'); + const client = new RemoteGraphClient({ + url: srcDbPath, + baseBranch: 'main', + cacheDir, + }); + + await client.fetch(); + const baseQueries = client.open(); + + // Verify the node is accessible + const result = baseQueries.getNodeById('base-1'); + expect(result).not.toBeNull(); + expect(result!.name).toBe('baseFunc'); + + client.close(); + }); + + it('should fetch using file:// prefix', async () => { + const srcDbPath = path.join(tempDir, 'source.db'); + const { db: srcDb, queries: srcQueries } = initDb(srcDbPath); + srcQueries.insertNode(makeNode({ id: 'n1', name: 'fn1', filePath: 'a.ts' })); + srcDb.close(); + + const cacheDir = path.join(tempDir, 'cache'); + const client = new RemoteGraphClient({ + url: `file://${srcDbPath}`, + baseBranch: 'main', + cacheDir, + }); + + await client.fetch(); + const q = client.open(); + expect(q.getNodeById('n1')).not.toBeNull(); + client.close(); + }); + + it('should use cache within TTL', async () => { + const srcDbPath = path.join(tempDir, 'source.db'); + const { db: srcDb } = initDb(srcDbPath); + srcDb.close(); + + const cacheDir = path.join(tempDir, 'cache'); + const client = new RemoteGraphClient({ + url: srcDbPath, + baseBranch: 'main', + cacheDir, + cacheTTL: 60_000, // 1 minute + }); + + await client.fetch(); + const cachePath = client.getCachePath(); + expect(fs.existsSync(cachePath)).toBe(true); + + // Modify the source by adding a node (reopen, don't re-initialize) + const srcDb2 = DatabaseConnection.open(srcDbPath); + const srcQ2 = new QueryBuilder(srcDb2.getDb()); + srcQ2.insertNode(makeNode({ id: 'new-node', name: 'newFunc', filePath: 'new.ts' })); + srcDb2.close(); + + await client.fetch(); // Should be a no-op (cache is fresh) + const q = client.open(); + // The cached version should NOT have the new node + expect(q.getNodeById('new-node')).toBeNull(); + client.close(); + }); + + it('should re-fetch when cache expires', async () => { + const srcDbPath = path.join(tempDir, 'source.db'); + const { db: srcDb } = initDb(srcDbPath); + srcDb.close(); + + const cacheDir = path.join(tempDir, 'cache'); + const client = new RemoteGraphClient({ + url: srcDbPath, + baseBranch: 'main', + cacheDir, + cacheTTL: 1, // 1ms — essentially always stale + }); + + await client.fetch(); + + // Add a node to the source (reopen, don't re-initialize) + const srcDb2 = DatabaseConnection.open(srcDbPath); + const srcQ2 = new QueryBuilder(srcDb2.getDb()); + srcQ2.insertNode(makeNode({ id: 'fresh-node', name: 'freshFunc', filePath: 'fresh.ts' })); + srcDb2.close(); + + // Wait a bit so the cache is expired + await new Promise((r) => setTimeout(r, 10)); + // Need to close the old connection before re-fetch overwrites the file + client.close(); + await client.fetch(); + const q = client.open(); + expect(q.getNodeById('fresh-node')).not.toBeNull(); + client.close(); + }); + + it('should throw when fetching a non-existent file', async () => { + const client = new RemoteGraphClient({ + url: '/non/existent/path.db', + baseBranch: 'main', + cacheDir: path.join(tempDir, 'cache'), + }); + + await expect(client.fetch()).rejects.toThrow('Remote base graph not found'); + }); + + it('should throw when opening without fetching first', () => { + const client = new RemoteGraphClient({ + url: '/dummy', + baseBranch: 'main', + cacheDir: path.join(tempDir, 'cache'), + }); + + expect(() => client.open()).toThrow('not cached'); + }); + + it('should be idempotent on close()', async () => { + const srcDbPath = path.join(tempDir, 'source.db'); + const { db: srcDb } = initDb(srcDbPath); + srcDb.close(); + + const client = new RemoteGraphClient({ + url: srcDbPath, + baseBranch: 'main', + cacheDir: path.join(tempDir, 'cache'), + }); + await client.fetch(); + client.open(); + client.close(); + client.close(); // Should not throw + }); + + it('should return config and cache path', () => { + const config = { url: '/some/path.db', baseBranch: 'main', cacheDir: tempDir }; + const client = new RemoteGraphClient(config); + expect(client.getConfig()).toEqual(config); + expect(client.getCachePath()).toContain('base-graph.db'); + }); + + it('isCacheValid returns false when no cache exists', () => { + const client = new RemoteGraphClient({ + url: '/dummy', + baseBranch: 'main', + cacheDir: path.join(tempDir, 'nonexistent'), + }); + expect(client.isCacheValid()).toBe(false); + }); +}); + +// =========================================================================== +// BranchDiffIndexer +// =========================================================================== + +describe('BranchDiffIndexer', () => { + let repoDir: string; + + beforeEach(() => { + repoDir = makeTempDir('branch-diff'); + + // Initialize a git repo with an initial commit + git(repoDir, 'init'); + git(repoDir, 'config user.email "test@test.com"'); + git(repoDir, 'config user.name "Test"'); + + // Create initial files on main + fs.writeFileSync(path.join(repoDir, 'base.ts'), 'export const x = 1;'); + fs.writeFileSync(path.join(repoDir, 'utils.ts'), 'export function add(a: number, b: number) { return a + b; }'); + fs.mkdirSync(path.join(repoDir, 'lib'), { recursive: true }); + fs.writeFileSync(path.join(repoDir, 'lib', 'helper.ts'), 'export const helper = true;'); + git(repoDir, 'add -A'); + git(repoDir, 'commit -m "initial"'); + git(repoDir, 'branch -M main'); + }); + + afterEach(() => { + fs.rmSync(repoDir, { recursive: true, force: true }); + }); + + it('should detect added, modified, and deleted files', () => { + // Create a feature branch + git(repoDir, 'checkout -b feature/test'); + + // Add a new file + fs.writeFileSync(path.join(repoDir, 'new-file.ts'), 'export const y = 2;'); + // Modify an existing file + fs.writeFileSync(path.join(repoDir, 'utils.ts'), 'export function add(a: number, b: number) { return a + b + 1; }'); + // Delete a file + fs.unlinkSync(path.join(repoDir, 'lib', 'helper.ts')); + + git(repoDir, 'add -A'); + git(repoDir, 'commit -m "feature changes"'); + + const indexer = new BranchDiffIndexer(repoDir); + const diff = indexer.getChangedFiles('main'); + + expect(diff.added).toContain('new-file.ts'); + expect(diff.modified).toContain('utils.ts'); + expect(diff.deleted).toContain('lib/helper.ts'); + expect(diff.currentBranch).toBe('feature/test'); + expect(diff.baseBranch).toBe('main'); + }); + + it('should return empty diff when no changes exist', () => { + git(repoDir, 'checkout -b feature/no-changes'); + // No changes — branch is identical to main + + const indexer = new BranchDiffIndexer(repoDir); + const diff = indexer.getChangedFiles('main'); + + expect(diff.added).toEqual([]); + expect(diff.modified).toEqual([]); + expect(diff.deleted).toEqual([]); + }); + + it('getFilesToIndex should return added + modified files', () => { + git(repoDir, 'checkout -b feature/partial'); + + fs.writeFileSync(path.join(repoDir, 'added.ts'), 'new file'); + fs.writeFileSync(path.join(repoDir, 'base.ts'), 'modified'); + fs.unlinkSync(path.join(repoDir, 'lib', 'helper.ts')); + git(repoDir, 'add -A'); + git(repoDir, 'commit -m "partial changes"'); + + const indexer = new BranchDiffIndexer(repoDir); + const toIndex = indexer.getFilesToIndex('main'); + + expect(toIndex).toContain('added.ts'); + expect(toIndex).toContain('base.ts'); + // Deleted files should NOT be in the index list + expect(toIndex).not.toContain('lib/helper.ts'); + }); + + it('getCurrentBranch should return the branch name', () => { + git(repoDir, 'checkout -b feature/named'); + const indexer = new BranchDiffIndexer(repoDir); + expect(indexer.getCurrentBranch()).toBe('feature/named'); + }); + + it('getMergeBase should return a valid commit hash', () => { + git(repoDir, 'checkout -b feature/merge-base-test'); + const indexer = new BranchDiffIndexer(repoDir); + const mergeBase = indexer.getMergeBase('main'); + expect(mergeBase).toMatch(/^[0-9a-f]{40}$/); + }); + + it('should throw for non-existent base branch', () => { + git(repoDir, 'checkout -b feature/bad-base'); + const indexer = new BranchDiffIndexer(repoDir); + expect(() => indexer.getMergeBase('nonexistent-branch')).toThrow( + /Cannot find merge-base/ + ); + }); + + it('should handle renamed files', () => { + git(repoDir, 'checkout -b feature/rename'); + + // Rename a file (git detects this as a rename if content is similar) + fs.renameSync( + path.join(repoDir, 'lib', 'helper.ts'), + path.join(repoDir, 'lib', 'helper-renamed.ts') + ); + git(repoDir, 'add -A'); + git(repoDir, 'commit -m "rename file"'); + + const indexer = new BranchDiffIndexer(repoDir); + const diff = indexer.getChangedFiles('main'); + + // Renamed files appear as modified (with the new name) + // or as deleted (old) + added (new) depending on similarity + const allChanged = [...diff.added, ...diff.modified, ...diff.deleted]; + expect(allChanged.length).toBeGreaterThan(0); + }); +}); + +// =========================================================================== +// OverlayQueryEngine +// =========================================================================== + +describe('OverlayQueryEngine', () => { + let tempDir: string; + let baseDb: DatabaseConnection; + let baseQueries: QueryBuilder; + let overlayDb: DatabaseConnection; + let engine: OverlayQueryEngine; + + // The overlay set: files changed on the feature branch + const overlayFiles = new Set(['src/changed.ts', 'src/added.ts']); + const deletedFiles = new Set(['src/deleted.ts']); + + beforeEach(() => { + tempDir = makeTempDir('overlay-engine'); + + // Initialize base database with known data + const basePath = path.join(tempDir, 'base.db'); + ({ db: baseDb, queries: baseQueries } = initDb(basePath)); + + // Base nodes: src/base.ts (unchanged), src/changed.ts (will be re-indexed), src/deleted.ts + baseQueries.insertNode(makeNode({ id: 'base-fn1', name: 'baseFn', filePath: 'src/base.ts' })); + baseQueries.insertNode(makeNode({ id: 'changed-fn1', name: 'changedFn', filePath: 'src/changed.ts' })); + baseQueries.insertNode(makeNode({ id: 'deleted-fn1', name: 'deletedFn', filePath: 'src/deleted.ts' })); + baseQueries.insertNode(makeNode({ id: 'base-class1', name: 'BaseClass', filePath: 'src/base.ts', kind: 'class' })); + + // Base edges + baseQueries.insertEdge(makeEdge('base-fn1', 'changed-fn1', 'calls')); + baseQueries.insertEdge(makeEdge('changed-fn1', 'deleted-fn1', 'calls')); + baseQueries.insertEdge(makeEdge('base-class1', 'base-fn1', 'contains')); + + // Base file records + baseQueries.upsertFile({ + path: 'src/base.ts', contentHash: 'h1', language: 'typescript', + size: 100, modifiedAt: 1000, indexedAt: 1000, nodeCount: 2, + }); + baseQueries.upsertFile({ + path: 'src/changed.ts', contentHash: 'h2', language: 'typescript', + size: 50, modifiedAt: 1000, indexedAt: 1000, nodeCount: 1, + }); + baseQueries.upsertFile({ + path: 'src/deleted.ts', contentHash: 'h3', language: 'typescript', + size: 30, modifiedAt: 1000, indexedAt: 1000, nodeCount: 1, + }); + + // Initialize overlay database (FKs off — edges reference base nodes) + const overlayPath = path.join(tempDir, 'overlay.db'); + const { db: overlayConn, queries: overlayQB } = initDb(overlayPath, { disableForeignKeys: true }); + overlayDb = overlayConn; + + // Overlay nodes: re-indexed version of src/changed.ts + new src/added.ts + overlayQB.insertNode(makeNode({ + id: 'changed-fn1-v2', name: 'changedFnV2', filePath: 'src/changed.ts', + })); + overlayQB.insertNode(makeNode({ + id: 'added-fn1', name: 'addedFn', filePath: 'src/added.ts', + })); + + // Overlay edges (cross-boundary: overlay → base) + overlayQB.insertEdge(makeEdge('changed-fn1-v2', 'base-fn1', 'calls')); + overlayQB.insertEdge(makeEdge('added-fn1', 'changed-fn1-v2', 'calls')); + + // Overlay file records + overlayQB.upsertFile({ + path: 'src/changed.ts', contentHash: 'h2-new', language: 'typescript', + size: 60, modifiedAt: 2000, indexedAt: 2000, nodeCount: 1, + }); + overlayQB.upsertFile({ + path: 'src/added.ts', contentHash: 'h4', language: 'typescript', + size: 40, modifiedAt: 2000, indexedAt: 2000, nodeCount: 1, + }); + + // Create the overlay engine + engine = new OverlayQueryEngine( + overlayConn.getDb(), + baseQueries, + overlayFiles, + deletedFiles + ); + }); + + afterEach(() => { + baseDb.close(); + overlayDb.close(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + // ---- Accessors ---- + + describe('accessors', () => { + it('should return overlay file paths', () => { + expect(engine.getOverlayFilePaths()).toEqual(overlayFiles); + }); + + it('should return deleted file paths', () => { + expect(engine.getDeletedFilePaths()).toEqual(deletedFiles); + }); + + it('should return base queries', () => { + expect(engine.getBaseQueries()).toBe(baseQueries); + }); + }); + + // ---- getNodeById ---- + + describe('getNodeById', () => { + it('should return overlay nodes for overlay files', () => { + const node = engine.getNodeById('changed-fn1-v2'); + expect(node).not.toBeNull(); + expect(node!.name).toBe('changedFnV2'); + }); + + it('should return base nodes for unchanged files', () => { + const node = engine.getNodeById('base-fn1'); + expect(node).not.toBeNull(); + expect(node!.name).toBe('baseFn'); + }); + + it('should NOT return stale base nodes for overlay files', () => { + // The old version of changed-fn1 should not be visible + const node = engine.getNodeById('changed-fn1'); + expect(node).toBeNull(); + }); + + it('should NOT return nodes from deleted files', () => { + const node = engine.getNodeById('deleted-fn1'); + expect(node).toBeNull(); + }); + + it('should return null for non-existent nodes', () => { + expect(engine.getNodeById('non-existent')).toBeNull(); + }); + + it('should return newly added overlay nodes', () => { + const node = engine.getNodeById('added-fn1'); + expect(node).not.toBeNull(); + expect(node!.name).toBe('addedFn'); + }); + }); + + // ---- getNodesByFile ---- + + describe('getNodesByFile', () => { + it('should return overlay nodes for overlay files', () => { + const nodes = engine.getNodesByFile('src/changed.ts'); + expect(nodes).toHaveLength(1); + expect(nodes[0]!.name).toBe('changedFnV2'); + }); + + it('should return base nodes for unchanged files', () => { + const nodes = engine.getNodesByFile('src/base.ts'); + expect(nodes.length).toBeGreaterThanOrEqual(1); + expect(nodes.some(n => n.name === 'baseFn')).toBe(true); + }); + + it('should return empty for deleted files', () => { + const nodes = engine.getNodesByFile('src/deleted.ts'); + expect(nodes).toEqual([]); + }); + + it('should return nodes for newly added files', () => { + const nodes = engine.getNodesByFile('src/added.ts'); + expect(nodes).toHaveLength(1); + expect(nodes[0]!.name).toBe('addedFn'); + }); + }); + + // ---- getNodesByKind ---- + + describe('getNodesByKind', () => { + it('should merge functions from both databases', () => { + const functions = engine.getNodesByKind('function'); + const names = functions.map(n => n.name); + + // Should include: baseFn (base), changedFnV2 (overlay), addedFn (overlay) + expect(names).toContain('baseFn'); + expect(names).toContain('changedFnV2'); + expect(names).toContain('addedFn'); + + // Should NOT include: changedFn (stale base), deletedFn (deleted file) + expect(names).not.toContain('changedFn'); + expect(names).not.toContain('deletedFn'); + }); + + it('should return base-only kinds correctly', () => { + const classes = engine.getNodesByKind('class'); + expect(classes.some(n => n.name === 'BaseClass')).toBe(true); + }); + }); + + // ---- getAllNodes ---- + + describe('getAllNodes', () => { + it('should return merged node set', () => { + const all = engine.getAllNodes(); + const names = all.map(n => n.name); + + expect(names).toContain('baseFn'); + expect(names).toContain('BaseClass'); + expect(names).toContain('changedFnV2'); + expect(names).toContain('addedFn'); + expect(names).not.toContain('changedFn'); + expect(names).not.toContain('deletedFn'); + }); + }); + + // ---- getNodesByName / getNodesByQualifiedNameExact / getNodesByLowerName ---- + + describe('name-based lookups', () => { + it('getNodesByName should find overlay nodes', () => { + const nodes = engine.getNodesByName('changedFnV2'); + expect(nodes).toHaveLength(1); + }); + + it('getNodesByName should find base nodes', () => { + const nodes = engine.getNodesByName('baseFn'); + expect(nodes).toHaveLength(1); + }); + + it('getNodesByName should not find stale base nodes', () => { + const nodes = engine.getNodesByName('changedFn'); + expect(nodes).toHaveLength(0); + }); + + it('getNodesByName should not find deleted file nodes', () => { + const nodes = engine.getNodesByName('deletedFn'); + expect(nodes).toHaveLength(0); + }); + + it('getNodesByQualifiedNameExact should merge results', () => { + const qn = 'src/base.ts::baseFn'; + const nodes = engine.getNodesByQualifiedNameExact(qn); + expect(nodes).toHaveLength(1); + expect(nodes[0]!.name).toBe('baseFn'); + }); + + it('getNodesByLowerName should merge results', () => { + const nodes = engine.getNodesByLowerName('basefn'); + expect(nodes).toHaveLength(1); + }); + }); + + // ---- searchNodes ---- + + describe('searchNodes', () => { + it('should return results from both databases', () => { + const results = engine.searchNodes('Fn'); + const names = results.map(r => r.node.name); + + // Should find functions from both DBs + expect(names).toContain('baseFn'); + // Overlay functions should be present + expect(names.some(n => n === 'changedFnV2' || n === 'addedFn')).toBe(true); + }); + + it('should not return nodes from deleted files', () => { + const results = engine.searchNodes('deletedFn'); + const names = results.map(r => r.node.name); + expect(names).not.toContain('deletedFn'); + }); + + it('should not return stale base nodes for overlay files', () => { + const results = engine.searchNodes('changedFn'); + // The old 'changedFn' from base should not appear + const stale = results.find(r => r.node.id === 'changed-fn1'); + expect(stale).toBeUndefined(); + }); + }); + + // ---- Edge operations ---- + + describe('getOutgoingEdges', () => { + it('should return overlay edges for overlay nodes', () => { + const edges = engine.getOutgoingEdges('changed-fn1-v2'); + expect(edges).toHaveLength(1); + expect(edges[0]!.target).toBe('base-fn1'); + }); + + it('should return base edges for base nodes', () => { + const edges = engine.getOutgoingEdges('base-class1'); + expect(edges).toHaveLength(1); + expect(edges[0]!.target).toBe('base-fn1'); + expect(edges[0]!.kind).toBe('contains'); + }); + + it('should return empty for deleted file nodes', () => { + const edges = engine.getOutgoingEdges('deleted-fn1'); + expect(edges).toEqual([]); + }); + + it('should return overlay edges for added file nodes', () => { + const edges = engine.getOutgoingEdges('added-fn1'); + expect(edges).toHaveLength(1); + expect(edges[0]!.target).toBe('changed-fn1-v2'); + }); + }); + + describe('getIncomingEdges', () => { + it('should merge incoming edges from both databases', () => { + // base-fn1 has: + // - incoming 'contains' from base-class1 (base DB) + // - incoming 'calls' from changed-fn1-v2 (overlay DB) + // - incoming 'calls' from changed-fn1 (base DB — STALE, should be filtered) + const edges = engine.getIncomingEdges('base-fn1'); + + // Should have the base 'contains' edge and the overlay 'calls' edge + const containsEdge = edges.find(e => e.kind === 'contains'); + expect(containsEdge).toBeDefined(); + expect(containsEdge!.source).toBe('base-class1'); + + const callsEdge = edges.find(e => e.kind === 'calls'); + expect(callsEdge).toBeDefined(); + expect(callsEdge!.source).toBe('changed-fn1-v2'); + + // The stale base edge (changed-fn1 → base-fn1) should be filtered + const staleEdge = edges.find(e => e.source === 'changed-fn1'); + expect(staleEdge).toBeUndefined(); + }); + + it('should filter out edges from deleted file nodes', () => { + // deleted-fn1 was a target of changed-fn1 in the base DB + // But changed-fn1 is in an overlay file, so that edge is stale + const edges = engine.getIncomingEdges('deleted-fn1'); + // deleted-fn1 itself is in a deleted file, but we're asking for + // edges targeting it. Base edges from changed-fn1 are stale. + const staleEdge = edges.find(e => e.source === 'changed-fn1'); + expect(staleEdge).toBeUndefined(); + }); + + it('should support kind filtering', () => { + const edges = engine.getIncomingEdges('base-fn1', ['calls']); + expect(edges.every(e => e.kind === 'calls')).toBe(true); + }); + }); + + describe('findEdgesBetweenNodes', () => { + it('should find edges across both databases', () => { + const nodeIds = ['base-fn1', 'changed-fn1-v2', 'base-class1', 'added-fn1']; + const edges = engine.findEdgesBetweenNodes(nodeIds); + + // Should find: + // - base-class1 → base-fn1 (contains, base) + // - changed-fn1-v2 → base-fn1 (calls, overlay) + // - added-fn1 → changed-fn1-v2 (calls, overlay) + expect(edges.length).toBeGreaterThanOrEqual(2); + }); + + it('should filter stale base edges in merged results', () => { + // Include the stale node ID to verify it's filtered + const nodeIds = ['changed-fn1', 'base-fn1', 'changed-fn1-v2']; + const edges = engine.findEdgesBetweenNodes(nodeIds); + + // The base edge changed-fn1 → base-fn1 should NOT appear + // (changed-fn1 is in an overlay file) + const staleEdge = edges.find( + e => e.source === 'changed-fn1' && e.target === 'base-fn1' + ); + // Note: the base DB has this edge, but it's filtered because + // the source is in an overlay file + // However, findEdgesBetweenNodes queries both DBs and checks sources + // The stale edge's source 'changed-fn1' is in src/changed.ts (overlay file) + // so it should be filtered + expect(staleEdge).toBeUndefined(); + }); + }); + + // ---- File operations ---- + + describe('file operations', () => { + it('getFileByPath should route to overlay for overlay files', () => { + const file = engine.getFileByPath('src/changed.ts'); + expect(file).not.toBeNull(); + expect(file!.contentHash).toBe('h2-new'); // Overlay version + }); + + it('getFileByPath should route to base for unchanged files', () => { + const file = engine.getFileByPath('src/base.ts'); + expect(file).not.toBeNull(); + expect(file!.contentHash).toBe('h1'); + }); + + it('getFileByPath should return null for deleted files', () => { + const file = engine.getFileByPath('src/deleted.ts'); + expect(file).toBeNull(); + }); + + it('getFileByPath should return overlay for added files', () => { + const file = engine.getFileByPath('src/added.ts'); + expect(file).not.toBeNull(); + expect(file!.contentHash).toBe('h4'); + }); + + it('getAllFiles should merge and deduplicate', () => { + const files = engine.getAllFiles(); + const paths = files.map(f => f.path); + + expect(paths).toContain('src/base.ts'); + expect(paths).toContain('src/changed.ts'); + expect(paths).toContain('src/added.ts'); + expect(paths).not.toContain('src/deleted.ts'); + + // Verify overlay version of changed.ts is used + const changedFile = files.find(f => f.path === 'src/changed.ts'); + expect(changedFile!.contentHash).toBe('h2-new'); + }); + + it('getAllFilePaths should merge and sort', () => { + const paths = engine.getAllFilePaths(); + + expect(paths).toContain('src/base.ts'); + expect(paths).toContain('src/changed.ts'); + expect(paths).toContain('src/added.ts'); + expect(paths).not.toContain('src/deleted.ts'); + + // Should be sorted + for (let i = 1; i < paths.length; i++) { + expect(paths[i]! >= paths[i - 1]!).toBe(true); + } + }); + }); + + // ---- getAllNodeNames ---- + + describe('getAllNodeNames', () => { + it('should merge names from both databases', () => { + const names = engine.getAllNodeNames(); + + expect(names).toContain('baseFn'); + expect(names).toContain('changedFnV2'); + expect(names).toContain('addedFn'); + expect(names).toContain('BaseClass'); + // Note: base names like 'changedFn' and 'deletedFn' might still appear + // in getAllNodeNames since it's just distinct names without file filtering. + // This is acceptable — it's a hint set, not a precise query. + }); + }); + + // ---- getStats ---- + + describe('getStats', () => { + it('should return merged statistics', () => { + const stats = engine.getStats(); + + expect(stats.nodeCount).toBeGreaterThan(0); + expect(stats.edgeCount).toBeGreaterThan(0); + expect(stats.fileCount).toBeGreaterThan(0); + // maskedFileCount = overlayFilePaths.size(2) + deletedFilePaths.size(1) = 3 + // adjustedBaseFileCount = max(0, 3 - 3) = 0 + // overlay file count = 2 (src/changed.ts + src/added.ts) + // Total = 0 + 2 = 2 + expect(stats.fileCount).toBe(2); + }); + + it('should have non-zero lastUpdated', () => { + const stats = engine.getStats(); + expect(stats.lastUpdated).toBeGreaterThan(0); + }); + }); + + // ---- clearCache ---- + + describe('clearCache', () => { + it('should clear caches in both databases', () => { + // Warm the caches + engine.getNodeById('base-fn1'); + engine.getNodeById('changed-fn1-v2'); + + // Should not throw + engine.clearCache(); + + // Lookups should still work after cache clear + expect(engine.getNodeById('base-fn1')).not.toBeNull(); + expect(engine.getNodeById('changed-fn1-v2')).not.toBeNull(); + }); + }); + + // ---- Write operations ---- + + describe('write operations go to overlay only', () => { + it('insertNode should write to overlay DB', () => { + const newNode = makeNode({ id: 'write-test-1', name: 'writeTest', filePath: 'src/write.ts' }); + engine.insertNode(newNode); + + // Should be in overlay (via super) + const found = engine.getNodeById('write-test-1'); + expect(found).not.toBeNull(); + expect(found!.name).toBe('writeTest'); + + // Should NOT be in base + const baseFound = baseQueries.getNodeById('write-test-1'); + expect(baseFound).toBeNull(); + }); + + it('insertEdge should write to overlay DB', () => { + engine.insertEdge(makeEdge('base-fn1', 'added-fn1', 'references')); + + // The edge should be visible through the engine + const edges = engine.getOutgoingEdges('base-fn1'); + // Note: base-fn1 is a base node, so getOutgoingEdges queries the base DB + // But we just wrote to overlay. This edge will be visible via getIncomingEdges on added-fn1 + const incoming = engine.getIncomingEdges('added-fn1'); + const refEdge = incoming.find(e => e.source === 'base-fn1' && e.kind === 'references'); + expect(refEdge).toBeDefined(); + }); + }); + + // ---- Edge case: overlay file with no overlay nodes ---- + + describe('edge cases', () => { + it('should handle overlay file with all nodes removed', () => { + // src/changed.ts is in the overlay set, so base nodes for it are hidden. + // If the overlay version has different node IDs, the old IDs are gone. + const oldNode = engine.getNodeById('changed-fn1'); + expect(oldNode).toBeNull(); + + // The new node should be there + const newNode = engine.getNodeById('changed-fn1-v2'); + expect(newNode).not.toBeNull(); + }); + + it('should handle constructor with no deleted files', () => { + const engineNoDeletes = new OverlayQueryEngine( + overlayDb.getDb(), + baseQueries, + overlayFiles + ); + // Deleted file nodes should still be visible since no deletes specified + const node = engineNoDeletes.getNodeById('deleted-fn1'); + expect(node).not.toBeNull(); + }); + + it('should handle empty overlay', () => { + const emptyEngine = new OverlayQueryEngine( + overlayDb.getDb(), + baseQueries, + new Set(), // No overlay files + new Set(), // No deleted files + ); + // All base nodes should be visible + expect(emptyEngine.getNodeById('base-fn1')).not.toBeNull(); + expect(emptyEngine.getNodeById('changed-fn1')).not.toBeNull(); + expect(emptyEngine.getNodeById('deleted-fn1')).not.toBeNull(); + }); + }); +}); + +// =========================================================================== +// Integration: OverlayQueryEngine with GraphTraverser +// =========================================================================== + +describe('OverlayQueryEngine + GraphTraverser integration', () => { + let tempDir: string; + let baseDb: DatabaseConnection; + let overlayDb: DatabaseConnection; + let engine: OverlayQueryEngine; + + beforeEach(() => { + tempDir = makeTempDir('overlay-traversal'); + + // Base database + const basePath = path.join(tempDir, 'base.db'); + const { db: bd, queries: bq } = initDb(basePath); + baseDb = bd; + + // Build a small graph: + // fileA.ts: classA → methodA1, methodA2 + // fileB.ts: funcB (calls methodA1) + bq.insertNode(makeNode({ id: 'fA', name: 'fileA.ts', filePath: 'fileA.ts', kind: 'file' })); + bq.insertNode(makeNode({ id: 'cA', name: 'ClassA', filePath: 'fileA.ts', kind: 'class' })); + bq.insertNode(makeNode({ id: 'mA1', name: 'methodA1', filePath: 'fileA.ts', kind: 'method' })); + bq.insertNode(makeNode({ id: 'mA2', name: 'methodA2', filePath: 'fileA.ts', kind: 'method' })); + bq.insertNode(makeNode({ id: 'fB', name: 'fileB.ts', filePath: 'fileB.ts', kind: 'file' })); + bq.insertNode(makeNode({ id: 'fnB', name: 'funcB', filePath: 'fileB.ts', kind: 'function' })); + + bq.insertEdge(makeEdge('fA', 'cA', 'contains')); + bq.insertEdge(makeEdge('cA', 'mA1', 'contains')); + bq.insertEdge(makeEdge('cA', 'mA2', 'contains')); + bq.insertEdge(makeEdge('fB', 'fnB', 'contains')); + bq.insertEdge(makeEdge('fnB', 'mA1', 'calls')); + + // Overlay: fileB.ts is modified — funcB now calls methodA2 instead of methodA1 + const overlayPath = path.join(tempDir, 'overlay.db'); + const { db: od, queries: oq } = initDb(overlayPath, { disableForeignKeys: true }); + overlayDb = od; + + oq.insertNode(makeNode({ id: 'fB-v2', name: 'fileB.ts', filePath: 'fileB.ts', kind: 'file' })); + oq.insertNode(makeNode({ id: 'fnB-v2', name: 'funcBv2', filePath: 'fileB.ts', kind: 'function' })); + oq.insertEdge(makeEdge('fB-v2', 'fnB-v2', 'contains')); + oq.insertEdge(makeEdge('fnB-v2', 'mA2', 'calls')); // Now calls methodA2 + + engine = new OverlayQueryEngine( + od.getDb(), + bq, + new Set(['fileB.ts']), + new Set() + ); + }); + + afterEach(() => { + baseDb.close(); + overlayDb.close(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should traverse from overlay node into base graph', () => { + // Import GraphTraverser dynamically to use with our engine + const traverser = new GraphTraverser(engine); + + // Traverse from funcBv2 (overlay) — should find mA2 (base) + const subgraph = traverser.traverseBFS('fnB-v2', { + maxDepth: 2, + direction: 'outgoing', + }); + + expect(subgraph.nodes.has('fnB-v2')).toBe(true); + expect(subgraph.nodes.has('mA2')).toBe(true); + }); + + it('should get callers crossing overlay/base boundary', () => { + const traverser = new GraphTraverser(engine); + + // mA2 is in the base, but funcBv2 (overlay) now calls it + const callers = traverser.getCallers('mA2', 1); + const callerNames = callers.map(c => c.node.name); + expect(callerNames).toContain('funcBv2'); + }); + + it('should hide stale callers from overlay files', () => { + const traverser = new GraphTraverser(engine); + + // mA1 was called by funcB in the base, but fileB.ts is now in overlay + // The base edge funcB → mA1 should be filtered (funcB is stale) + const callers = traverser.getCallers('mA1', 1); + const callerNames = callers.map(c => c.node.name); + expect(callerNames).not.toContain('funcB'); + }); + + it('should show containment hierarchy from base', () => { + const traverser = new GraphTraverser(engine); + + // mA1's ancestors should still work (all in base, unmodified) + const ancestors = traverser.getAncestors('mA1'); + const ancestorNames = ancestors.map(a => a.name); + expect(ancestorNames).toContain('ClassA'); + }); +}); + +// =========================================================================== +// OverlayQueryEngine: findNodesByExactName & findNodesByNameSubstring +// =========================================================================== + +describe('OverlayQueryEngine search methods', () => { + let tempDir: string; + let baseDb: DatabaseConnection; + let overlayDb: DatabaseConnection; + let engine: OverlayQueryEngine; + + beforeEach(() => { + tempDir = makeTempDir('overlay-search'); + + const basePath = path.join(tempDir, 'base.db'); + const { db: bd, queries: bq } = initDb(basePath); + baseDb = bd; + + bq.insertNode(makeNode({ id: 'auth-1', name: 'AuthService', filePath: 'auth.ts', kind: 'class' })); + bq.insertNode(makeNode({ id: 'auth-2', name: 'authenticate', filePath: 'auth.ts', kind: 'function' })); + bq.insertNode(makeNode({ id: 'user-1', name: 'UserService', filePath: 'user.ts', kind: 'class' })); + + const overlayPath = path.join(tempDir, 'overlay.db'); + const { db: od, queries: oq } = initDb(overlayPath, { disableForeignKeys: true }); + overlayDb = od; + + // Overlay: auth.ts was modified — AuthService renamed to AuthManager + oq.insertNode(makeNode({ id: 'auth-3', name: 'AuthManager', filePath: 'auth.ts', kind: 'class' })); + oq.insertNode(makeNode({ id: 'auth-4', name: 'authenticate', filePath: 'auth.ts', kind: 'function' })); + + engine = new OverlayQueryEngine( + od.getDb(), + bq, + new Set(['auth.ts']), + new Set() + ); + }); + + afterEach(() => { + baseDb.close(); + overlayDb.close(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('findNodesByExactName should find overlay nodes', () => { + const results = engine.findNodesByExactName(['AuthManager']); + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results[0]!.node.name).toBe('AuthManager'); + }); + + it('findNodesByExactName should find base nodes for unmodified files', () => { + const results = engine.findNodesByExactName(['UserService']); + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results[0]!.node.name).toBe('UserService'); + }); + + it('findNodesByExactName should not find stale base nodes', () => { + const results = engine.findNodesByExactName(['AuthService']); + // AuthService was renamed to AuthManager in the overlay + expect(results).toHaveLength(0); + }); + + it('findNodesByNameSubstring should find overlay nodes', () => { + const results = engine.findNodesByNameSubstring('Manager'); + expect(results.some(r => r.node.name === 'AuthManager')).toBe(true); + }); + + it('findNodesByNameSubstring should find base nodes for unmodified files', () => { + const results = engine.findNodesByNameSubstring('Service'); + expect(results.some(r => r.node.name === 'UserService')).toBe(true); + // Old AuthService should NOT appear + expect(results.some(r => r.node.name === 'AuthService')).toBe(false); + }); +}); diff --git a/package-lock.json b/package-lock.json index 36c592b1..ff5f07e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1431,7 +1431,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/src/index.ts b/src/index.ts index b2acf346..9bf1debe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -47,6 +47,13 @@ import { GraphTraverser, GraphQueryManager } from './graph'; import { ContextBuilder, createContextBuilder } from './context'; import { Mutex, FileLock } from './utils'; import { FileWatcher, WatchOptions } from './sync'; +import * as fs from 'fs'; +import { + RemoteGraphClient, + BranchDiffIndexer, + OverlayQueryEngine, +} from './overlay'; +import type { RemoteGraphConfig } from './overlay'; // Re-export types for consumers export * from './types'; @@ -77,6 +84,12 @@ export { export { Mutex, FileLock, processInBatches, debounce, throttle, MemoryMonitor } from './utils'; export { FileWatcher, WatchOptions } from './sync'; export { MCPServer } from './mcp'; +export { + RemoteGraphClient, + BranchDiffIndexer, + OverlayQueryEngine, +} from './overlay'; +export type { RemoteGraphConfig, BranchDiffResult } from './overlay'; /** * Options for initializing a new CodeGraph project @@ -138,6 +151,9 @@ export class CodeGraph { // File watcher for auto-sync on file changes private watcher: FileWatcher | null = null; + // Remote graph client (non-null only in overlay mode) + private remoteClient: RemoteGraphClient | null = null; + private constructor( db: DatabaseConnection, queries: QueryBuilder, @@ -292,13 +308,118 @@ export class CodeGraph { } /** - * Close the CodeGraph instance and release resources + * Open a CodeGraph project in overlay mode. + * + * Instead of indexing the entire repository locally, this method: + * 1. Fetches a pre-built base graph from a remote source (CI artifact, + * shared filesystem, HTTP server). + * 2. Detects files changed on the current feature branch relative to + * the base branch using `git diff`. + * 3. Indexes only the changed files into a lightweight local overlay + * database. + * 4. Returns a CodeGraph instance whose queries seamlessly merge the + * remote base graph with the local overlay, so the LLM sees a + * complete, up-to-date view. + * + * This is the team-friendly workflow: CI builds the main-branch graph + * once, every developer downloads it and layers on their branch diffs. + * + * Falls back gracefully: if no files changed, the base graph is + * returned as-is. The existing local-only CodeGraph API is unaffected. + * + * @param projectRoot - Path to the project root (git repo) + * @param remoteConfig - Remote base graph configuration + * @param options - Indexing options (progress callback, etc.) + * @returns A CodeGraph instance with overlay-merged queries + * + * @example + * ```ts + * const cg = await CodeGraph.openOverlay('/path/to/repo', { + * url: 'https://ci.example.com/codegraph/main.db', + * baseBranch: 'main', + * }); + * // All queries now merge the base graph + local branch changes + * const results = cg.searchNodes('AuthService'); + * cg.close(); + * ``` + */ + static async openOverlay( + projectRoot: string, + remoteConfig: RemoteGraphConfig, + _options: IndexOptions = {} + ): Promise { + await initGrammars(); + const resolvedRoot = path.resolve(projectRoot); + + // Ensure .codegraph directory exists + if (!isInitialized(resolvedRoot)) { + createDirectory(resolvedRoot); + } + + // Step 1: Fetch remote base graph + const cacheDir = + remoteConfig.cacheDir || path.join(resolvedRoot, '.codegraph'); + const client = new RemoteGraphClient({ ...remoteConfig, cacheDir }); + await client.fetch(); + const baseQueries = client.open(); + + // Step 2: Detect changed files on the feature branch + const diffIndexer = new BranchDiffIndexer(resolvedRoot); + const diff = diffIndexer.getChangedFiles(remoteConfig.baseBranch); + const overlayFiles = new Set([...diff.added, ...diff.modified]); + const deletedFiles = new Set(diff.deleted); + + // Step 3: Initialize local overlay database + const overlayDbPath = path.join(resolvedRoot, '.codegraph', 'overlay.db'); + // Remove stale overlay DB so we start fresh + if (fs.existsSync(overlayDbPath)) { + fs.unlinkSync(overlayDbPath); + } + const overlayDb = DatabaseConnection.initialize(overlayDbPath); + // Disable FK enforcement on the overlay DB: its edges legitimately + // reference nodes that live in the base DB, not in the local overlay. + overlayDb.getDb().pragma('foreign_keys = OFF'); + + // Step 4: Create overlay query engine (merges base + overlay) + const engine = new OverlayQueryEngine( + overlayDb.getDb(), + baseQueries, + overlayFiles, + deletedFiles + ); + + // Step 5: Create CodeGraph instance wired to the overlay engine + const instance = new CodeGraph(overlayDb, engine, resolvedRoot); + instance.remoteClient = client; + + // Step 6: Index changed files into the overlay database + if (overlayFiles.size > 0) { + const absolutePaths = [...overlayFiles].map((f) => + path.resolve(resolvedRoot, f) + ); + await instance.indexFiles(absolutePaths); + // Resolve references using the merged engine so cross-boundary + // calls (overlay → base) are correctly linked + instance.resolveReferences(); + } + + return instance; + } + + /** + * Close the CodeGraph instance and release resources. + * In overlay mode, also closes the remote base graph connection. */ close(): void { this.unwatch(); // Release file lock if held this.fileLock.release(); this.db.close(); + // Close remote graph client if in overlay mode + if (this.remoteClient) { + this.remoteClient.close(); + this.remoteClient = null; + } } /** diff --git a/src/overlay/branch-diff.ts b/src/overlay/branch-diff.ts new file mode 100644 index 00000000..51942733 --- /dev/null +++ b/src/overlay/branch-diff.ts @@ -0,0 +1,144 @@ +/** + * Branch Diff Indexer + * + * Identifies files changed on the current feature branch relative to + * a base branch (e.g., main), enabling selective indexing of only the + * developer's changes instead of the entire codebase. + * + * Uses `git diff --name-status` against the merge-base to correctly + * handle diverged branches: only files the developer actually touched + * appear in the diff, not upstream commits merged into main since the + * branch point. + */ + +import { execSync } from 'child_process'; +import { BranchDiffResult } from './types'; + +/** + * Detects files changed on the current branch relative to a base branch. + * + * Usage: + * ```ts + * const diff = new BranchDiffIndexer('/path/to/repo'); + * const result = diff.getChangedFiles('main'); + * console.log(result.added, result.modified, result.deleted); + * ``` + */ +export class BranchDiffIndexer { + private projectRoot: string; + + /** + * @param projectRoot - Absolute path to the git repository root + */ + constructor(projectRoot: string) { + this.projectRoot = projectRoot; + } + + /** + * Get all files changed between the current HEAD and the base branch. + * + * Uses `git merge-base` to find the common ancestor, then + * `git diff --name-status` to categorize changes as added, + * modified, or deleted. + * + * @param baseBranch - Name of the base branch (e.g., 'main') + * @returns Categorized diff result with file lists + * @throws Error if not inside a git repository or the base branch doesn't exist + */ + getChangedFiles(baseBranch: string): BranchDiffResult { + const currentBranch = this.getCurrentBranch(); + const mergeBase = this.getMergeBase(baseBranch); + + const added: string[] = []; + const modified: string[] = []; + const deleted: string[] = []; + + // git diff --name-status gives lines like: + // A\tpath/to/new-file.ts + // M\tpath/to/changed-file.ts + // D\tpath/to/removed-file.ts + // R100\told-name.ts\tnew-name.ts + const output = this.exec( + `git diff --name-status ${mergeBase} HEAD` + ).trim(); + + if (!output) { + return { added, modified, deleted, currentBranch, baseBranch }; + } + + for (const line of output.split('\n')) { + const parts = line.split('\t'); + const status = parts[0]; + // For renames (R###), the new file path is in parts[2] + const filePath = status?.startsWith('R') ? parts[2] : parts[1]; + + if (!status || !filePath) continue; + + if (status.startsWith('A')) { + added.push(filePath); + } else if (status.startsWith('M') || status.startsWith('R')) { + modified.push(filePath); + } else if (status.startsWith('D')) { + deleted.push(filePath); + } + } + + return { added, modified, deleted, currentBranch, baseBranch }; + } + + /** + * Get the list of files that need to be indexed for the overlay. + * + * Returns the union of added and modified files — these are the + * files whose graph data differs from the base branch. Deleted + * files are excluded (they need to be masked, not indexed). + * + * @param baseBranch - Name of the base branch + * @returns Array of relative file paths to index + */ + getFilesToIndex(baseBranch: string): string[] { + const diff = this.getChangedFiles(baseBranch); + return [...diff.added, ...diff.modified]; + } + + /** + * Get the name of the currently checked-out branch. + * + * @returns Branch name, or 'HEAD' if in detached-HEAD state + */ + getCurrentBranch(): string { + return this.exec('git rev-parse --abbrev-ref HEAD').trim(); + } + + /** + * Find the merge-base (common ancestor) between the base branch and HEAD. + * + * @param baseBranch - Name of the base branch + * @returns Commit hash of the merge-base + * @throws Error if the base branch doesn't exist + */ + getMergeBase(baseBranch: string): string { + try { + return this.exec(`git merge-base ${baseBranch} HEAD`).trim(); + } catch { + throw new Error( + `Cannot find merge-base between '${baseBranch}' and HEAD. ` + + `Does the branch '${baseBranch}' exist?` + ); + } + } + + /** + * Execute a git command in the project root. + * + * @param command - Shell command to run + * @returns stdout as a string + */ + private exec(command: string): string { + return execSync(command, { + cwd: this.projectRoot, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + } +} diff --git a/src/overlay/index.ts b/src/overlay/index.ts new file mode 100644 index 00000000..a0d77c4f --- /dev/null +++ b/src/overlay/index.ts @@ -0,0 +1,12 @@ +/** + * Overlay Module + * + * Provides the remote graph overlay system for team-friendly code + * intelligence. Combines a centrally-built base graph (main branch) + * with local feature branch diffs for seamless, merged queries. + */ + +export { RemoteGraphClient } from './remote-client'; +export { BranchDiffIndexer } from './branch-diff'; +export { OverlayQueryEngine } from './overlay-engine'; +export type { RemoteGraphConfig, BranchDiffResult } from './types'; diff --git a/src/overlay/overlay-engine.ts b/src/overlay/overlay-engine.ts new file mode 100644 index 00000000..b0977c40 --- /dev/null +++ b/src/overlay/overlay-engine.ts @@ -0,0 +1,543 @@ +/** + * Overlay Query Engine + * + * Merges query results from a remote base graph and a local overlay + * database, providing a seamless unified view of the code knowledge + * graph to downstream consumers (GraphTraverser, ContextBuilder, MCP). + * + * Merge semantics: + * - Nodes in overlay files → always served from the overlay DB + * - Nodes in deleted files → hidden (not returned) + * - All other nodes → served from the base DB + * - Edges whose source is in an overlay file → served from overlay DB + * - Edges whose source is in a deleted file → hidden + * - All other edges → served from base DB + * - Search results → merged from both DBs, overlay wins on conflict + * + * The engine extends QueryBuilder so it can be passed to any component + * that expects one (GraphTraverser, GraphQueryManager, ContextBuilder, + * ReferenceResolver). Write operations go to the overlay DB only; + * the base DB is treated as read-only. + */ + +import { SqliteDatabase } from '../db/sqlite-adapter'; +import { QueryBuilder } from '../db/queries'; +import { + Node, + Edge, + EdgeKind, + NodeKind, + FileRecord, + GraphStats, + Language, + SearchOptions, + SearchResult, +} from '../types'; + +/** + * Query engine that overlays local branch changes on top of a remote + * base graph, presenting a unified view to all consumers. + * + * Extends QueryBuilder so it is a drop-in replacement wherever a + * QueryBuilder is expected. The overlay (local) database is the + * "primary" store (writes go here); the base database supplements + * read queries for files not present in the overlay. + */ +export class OverlayQueryEngine extends QueryBuilder { + private baseQueries: QueryBuilder; + private overlayFilePaths: Set; + private deletedFilePaths: Set; + + /** + * @param overlayDb - SQLite database for the local overlay (feature branch changes) + * @param baseQueries - QueryBuilder backed by the remote base graph (read-only) + * @param overlayFiles - Set of file paths that were re-indexed locally (added/modified) + * @param deletedFiles - Set of file paths deleted on the feature branch + */ + constructor( + overlayDb: SqliteDatabase, + baseQueries: QueryBuilder, + overlayFiles: Set, + deletedFiles?: Set + ) { + super(overlayDb); + this.baseQueries = baseQueries; + this.overlayFilePaths = overlayFiles; + this.deletedFilePaths = deletedFiles ?? new Set(); + } + + // --------------------------------------------------------------------------- + // Accessors + // --------------------------------------------------------------------------- + + /** The set of file paths whose data comes from the local overlay DB. */ + getOverlayFilePaths(): Set { + return this.overlayFilePaths; + } + + /** The set of file paths deleted on the feature branch (masked from base). */ + getDeletedFilePaths(): Set { + return this.deletedFilePaths; + } + + /** The base (remote) QueryBuilder for direct access when needed. */ + getBaseQueries(): QueryBuilder { + return this.baseQueries; + } + + // --------------------------------------------------------------------------- + // Node read overrides + // --------------------------------------------------------------------------- + + /** + * Get a node by ID, checking overlay first then base. + * + * If the node's file is in the overlay set, only the overlay version + * is returned (the base version is stale). If the file was deleted, + * null is returned even if the base DB has it. + */ + override getNodeById(id: string): Node | null { + // Overlay DB is authoritative for overlay files + const overlayNode = super.getNodeById(id); + if (overlayNode) return overlayNode; + + // Fall back to base DB, but skip overlay/deleted files + const baseNode = this.baseQueries.getNodeById(id); + if (baseNode && this.isBaseFileVisible(baseNode.filePath)) { + return baseNode; + } + + return null; + } + + /** + * Get all nodes in a file, routed by file ownership. + * + * Files in the overlay set are served entirely from the overlay DB. + * Deleted files return empty. All others come from the base DB. + */ + override getNodesByFile(filePath: string): Node[] { + if (this.deletedFilePaths.has(filePath)) return []; + if (this.overlayFilePaths.has(filePath)) return super.getNodesByFile(filePath); + return this.baseQueries.getNodesByFile(filePath); + } + + /** + * Get all nodes of a specific kind, merged from both databases. + * Overlay files replace their base counterparts; deleted files are excluded. + */ + override getNodesByKind(kind: NodeKind): Node[] { + const overlayNodes = super.getNodesByKind(kind); + const baseNodes = this.baseQueries.getNodesByKind(kind); + return this.mergeNodes(overlayNodes, baseNodes); + } + + /** + * Get all nodes across both databases. + */ + override getAllNodes(): Node[] { + const overlayNodes = super.getAllNodes(); + const baseNodes = this.baseQueries.getAllNodes(); + return this.mergeNodes(overlayNodes, baseNodes); + } + + /** + * Get nodes by exact name, merged from both databases. + */ + override getNodesByName(name: string): Node[] { + const overlayNodes = super.getNodesByName(name); + const baseNodes = this.baseQueries.getNodesByName(name); + return this.mergeNodes(overlayNodes, baseNodes); + } + + /** + * Get nodes by exact qualified name, merged from both databases. + */ + override getNodesByQualifiedNameExact(qualifiedName: string): Node[] { + const overlayNodes = super.getNodesByQualifiedNameExact(qualifiedName); + const baseNodes = this.baseQueries.getNodesByQualifiedNameExact(qualifiedName); + return this.mergeNodes(overlayNodes, baseNodes); + } + + /** + * Get nodes by lowercase name, merged from both databases. + */ + override getNodesByLowerName(lowerName: string): Node[] { + const overlayNodes = super.getNodesByLowerName(lowerName); + const baseNodes = this.baseQueries.getNodesByLowerName(lowerName); + return this.mergeNodes(overlayNodes, baseNodes); + } + + // --------------------------------------------------------------------------- + // Search overrides + // --------------------------------------------------------------------------- + + /** + * Search nodes across both databases, merging and deduplicating results. + * + * Runs the full search pipeline (FTS5 + LIKE + fuzzy) on each database + * independently, then merges. Overlay results take priority for nodes + * whose files are in the overlay set. + */ + override searchNodes(query: string, options?: SearchOptions): SearchResult[] { + const overlayResults = super.searchNodes(query, options); + const baseResults = this.baseQueries.searchNodes(query, options); + + return this.mergeSearchResults(overlayResults, baseResults); + } + + /** + * Find nodes by exact name across both databases. + */ + override findNodesByExactName( + names: string[], + options?: SearchOptions + ): SearchResult[] { + const overlayResults = super.findNodesByExactName(names, options); + const baseResults = this.baseQueries.findNodesByExactName(names, options); + return this.mergeSearchResults(overlayResults, baseResults); + } + + /** + * Find nodes whose name contains a substring, merged from both databases. + */ + override findNodesByNameSubstring( + substring: string, + options?: SearchOptions & { excludePrefix?: boolean } + ): SearchResult[] { + const overlayResults = super.findNodesByNameSubstring(substring, options); + const baseResults = this.baseQueries.findNodesByNameSubstring(substring, options); + return this.mergeSearchResults(overlayResults, baseResults); + } + + // --------------------------------------------------------------------------- + // Edge read overrides + // --------------------------------------------------------------------------- + + /** + * Get outgoing edges from a node. + * + * If the source node is in an overlay file, edges come from the + * overlay DB (the file was re-indexed, so these are authoritative). + * Otherwise edges come from the base DB. + */ + override getOutgoingEdges(sourceId: string, kinds?: EdgeKind[], provenance?: string): Edge[] { + if (this.isNodeInOverlayDb(sourceId)) { + return super.getOutgoingEdges(sourceId, kinds, provenance); + } + // Check if the node is in a base file (not overlay/deleted) + const baseNode = this.baseQueries.getNodeById(sourceId); + if (baseNode && this.isBaseFileVisible(baseNode.filePath)) { + return this.baseQueries.getOutgoingEdges(sourceId, kinds); + } + return []; + } + + /** + * Get incoming edges to a node, merged from both databases. + * + * Incoming edges can originate from either DB: + * - Base DB: edges from unchanged files pointing to this node + * - Overlay DB: edges from changed files pointing to this node + * + * Base edges whose source is in an overlay or deleted file are + * filtered out (they are stale — the overlay DB has the fresh version). + */ + override getIncomingEdges(targetId: string, kinds?: EdgeKind[]): Edge[] { + const overlayEdges = super.getIncomingEdges(targetId, kinds); + const baseEdges = this.baseQueries.getIncomingEdges(targetId, kinds); + + // Filter base edges: remove those from overlay/deleted files + const filteredBase = baseEdges.filter( + (e) => !this.isSourceInOverlayOrDeletedFile(e.source) + ); + + return this.deduplicateEdges(overlayEdges, filteredBase); + } + + /** + * Find edges between a set of nodes, merged from both databases. + */ + override findEdgesBetweenNodes(nodeIds: string[], kinds?: EdgeKind[]): Edge[] { + const overlayEdges = super.findEdgesBetweenNodes(nodeIds, kinds); + const baseEdges = this.baseQueries.findEdgesBetweenNodes(nodeIds, kinds); + + const filteredBase = baseEdges.filter( + (e) => !this.isSourceInOverlayOrDeletedFile(e.source) + ); + + return this.deduplicateEdges(overlayEdges, filteredBase); + } + + // --------------------------------------------------------------------------- + // File read overrides + // --------------------------------------------------------------------------- + + /** + * Get a file record by path, routed by ownership. + */ + override getFileByPath(filePath: string): FileRecord | null { + if (this.deletedFilePaths.has(filePath)) return null; + if (this.overlayFilePaths.has(filePath)) return super.getFileByPath(filePath); + return this.baseQueries.getFileByPath(filePath); + } + + /** + * Get all tracked files, merged from both databases. + * Overlay files replace base counterparts; deleted files are excluded. + */ + override getAllFiles(): FileRecord[] { + const overlayFiles = super.getAllFiles(); + const baseFiles = this.baseQueries.getAllFiles(); + + const result = new Map(); + + // Base files first (lower priority) + for (const file of baseFiles) { + if (this.isBaseFileVisible(file.path)) { + result.set(file.path, file); + } + } + + // Overlay files replace base counterparts + for (const file of overlayFiles) { + result.set(file.path, file); + } + + return Array.from(result.values()); + } + + /** + * Get all tracked file paths, merged from both databases. + */ + override getAllFilePaths(): string[] { + const overlayPaths = new Set(super.getAllFilePaths()); + const basePaths = this.baseQueries.getAllFilePaths(); + + for (const p of basePaths) { + if (this.isBaseFileVisible(p)) { + overlayPaths.add(p); + } + } + + return Array.from(overlayPaths).sort(); + } + + /** + * Get all distinct node names, merged from both databases. + */ + override getAllNodeNames(): string[] { + const overlayNames = new Set(super.getAllNodeNames()); + const baseNames = this.baseQueries.getAllNodeNames(); + + for (const name of baseNames) { + overlayNames.add(name); + } + + return Array.from(overlayNames); + } + + // --------------------------------------------------------------------------- + // Statistics override + // --------------------------------------------------------------------------- + + /** + * Get merged graph statistics from both databases. + * + * File/node/edge counts are computed by combining base counts + * (excluding overlay & deleted files) with overlay counts. + */ + override getStats(): GraphStats { + const baseStats = this.baseQueries.getStats(); + const overlayStats = super.getStats(); + + // Approximate merged counts: overlay replaces base for overlay files. + // For a precise count we'd need to query per-file node counts in the + // base, which is expensive. The approximation is close enough for + // informational display (codegraph status, MCP status tool). + const maskedFileCount = this.overlayFilePaths.size + this.deletedFilePaths.size; + const adjustedBaseFileCount = Math.max(0, baseStats.fileCount - maskedFileCount); + + const mergedNodesByKind = { ...baseStats.nodesByKind }; + for (const [kind, count] of Object.entries(overlayStats.nodesByKind)) { + mergedNodesByKind[kind as NodeKind] = + (mergedNodesByKind[kind as NodeKind] ?? 0) + count; + } + + const mergedEdgesByKind = { ...baseStats.edgesByKind }; + for (const [kind, count] of Object.entries(overlayStats.edgesByKind)) { + mergedEdgesByKind[kind as EdgeKind] = + (mergedEdgesByKind[kind as EdgeKind] ?? 0) + count; + } + + const mergedFilesByLang = { ...baseStats.filesByLanguage }; + for (const [lang, count] of Object.entries(overlayStats.filesByLanguage)) { + mergedFilesByLang[lang as Language] = + (mergedFilesByLang[lang as Language] ?? 0) + count; + } + + return { + nodeCount: baseStats.nodeCount + overlayStats.nodeCount, + edgeCount: baseStats.edgeCount + overlayStats.edgeCount, + fileCount: adjustedBaseFileCount + overlayStats.fileCount, + nodesByKind: mergedNodesByKind, + edgesByKind: mergedEdgesByKind, + filesByLanguage: mergedFilesByLang, + dbSizeBytes: baseStats.dbSizeBytes + overlayStats.dbSizeBytes, + lastUpdated: Math.max(baseStats.lastUpdated, overlayStats.lastUpdated), + }; + } + + // --------------------------------------------------------------------------- + // Cache override + // --------------------------------------------------------------------------- + + /** + * Clear node caches in both the overlay and base QueryBuilders. + */ + override clearCache(): void { + super.clearCache(); + this.baseQueries.clearCache(); + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /** + * Check whether a base-DB file is visible (not masked by overlay or deletion). + * + * @param filePath - Relative file path + * @returns true if the file should be served from the base DB + */ + private isBaseFileVisible(filePath: string): boolean { + return ( + !this.overlayFilePaths.has(filePath) && + !this.deletedFilePaths.has(filePath) + ); + } + + /** + * Check whether a node exists in the overlay DB (i.e., its file is + * an overlay file and the node was re-indexed). + */ + private isNodeInOverlayDb(nodeId: string): boolean { + const node = super.getNodeById(nodeId); + return node !== null; + } + + /** + * Check whether the source of an edge is in an overlay or deleted file. + * + * Used to filter stale base-DB edges. If the source node's file was + * re-indexed or deleted, the base-DB edge is outdated. + */ + private isSourceInOverlayOrDeletedFile(sourceNodeId: string): boolean { + // Check overlay DB first (cheap, cached) + const overlayNode = super.getNodeById(sourceNodeId); + if (overlayNode && this.overlayFilePaths.has(overlayNode.filePath)) { + return true; + } + + // Check base DB for deleted-file nodes + const baseNode = this.baseQueries.getNodeById(sourceNodeId); + if (baseNode) { + return ( + this.overlayFilePaths.has(baseNode.filePath) || + this.deletedFilePaths.has(baseNode.filePath) + ); + } + + return false; + } + + /** + * Merge node arrays from overlay and base, with overlay taking + * priority for files in the overlay set. Deleted-file nodes are excluded. + */ + private mergeNodes(overlayNodes: Node[], baseNodes: Node[]): Node[] { + const result = new Map(); + + // Base nodes first (lower priority) + for (const node of baseNodes) { + if (this.isBaseFileVisible(node.filePath)) { + result.set(node.id, node); + } + } + + // Overlay nodes overwrite base for same ID + for (const node of overlayNodes) { + result.set(node.id, node); + } + + return Array.from(result.values()); + } + + /** + * Merge search results from overlay and base. + * + * - Overlay results always win for nodes in overlay files + * - Base results for overlay/deleted files are excluded + * - Duplicates (same node ID) keep the higher score + * - Final list is sorted by score descending + */ + private mergeSearchResults( + overlayResults: SearchResult[], + baseResults: SearchResult[] + ): SearchResult[] { + const resultMap = new Map(); + + // Overlay results first (higher priority) + for (const r of overlayResults) { + resultMap.set(r.node.id, r); + } + + // Base results: skip overlay/deleted file nodes, keep higher score on dup + for (const r of baseResults) { + if (!this.isBaseFileVisible(r.node.filePath)) continue; + + const existing = resultMap.get(r.node.id); + if (existing) { + if (r.score > existing.score) { + existing.score = r.score; + } + } else { + resultMap.set(r.node.id, r); + } + } + + // Sort by score descending + return Array.from(resultMap.values()).sort((a, b) => b.score - a.score); + } + + /** + * Deduplicate edges from overlay and base. + * + * Overlay edges take priority. Deduplication key is + * `source|target|kind`. + */ + private deduplicateEdges(overlayEdges: Edge[], baseEdges: Edge[]): Edge[] { + const seen = new Set(); + const result: Edge[] = []; + + // Overlay edges first (higher priority) + for (const edge of overlayEdges) { + const key = `${edge.source}|${edge.target}|${edge.kind}`; + if (!seen.has(key)) { + seen.add(key); + result.push(edge); + } + } + + // Base edges (skip duplicates) + for (const edge of baseEdges) { + const key = `${edge.source}|${edge.target}|${edge.kind}`; + if (!seen.has(key)) { + seen.add(key); + result.push(edge); + } + } + + return result; + } +} diff --git a/src/overlay/remote-client.ts b/src/overlay/remote-client.ts new file mode 100644 index 00000000..c0968b89 --- /dev/null +++ b/src/overlay/remote-client.ts @@ -0,0 +1,198 @@ +/** + * Remote Graph Client + * + * Handles fetching and caching of remote base graph databases. + * The remote base graph represents the main/development branch's + * complete code graph, hosted on a file server or shared filesystem. + * + * Fetch lifecycle: + * 1. Check local cache validity (TTL-based) + * 2. If stale/missing, download from URL (file:// or http(s)://) + * 3. Open the cached database read-only + * 4. Return a QueryBuilder backed by the base graph + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as http from 'http'; +import * as https from 'https'; +import { DatabaseConnection } from '../db'; +import { QueryBuilder } from '../db/queries'; +import { RemoteGraphConfig } from './types'; + +/** Default cache TTL: 1 hour */ +const DEFAULT_CACHE_TTL = 3_600_000; + +/** Filename for the cached base graph database */ +const BASE_GRAPH_FILENAME = 'base-graph.db'; + +/** + * Client for fetching, caching, and opening remote base graph databases. + * + * Usage: + * ```ts + * const client = new RemoteGraphClient({ + * url: 'https://ci.example.com/codegraph.db', + * baseBranch: 'main', + * }); + * await client.fetch(); + * const baseQueries = client.open(); + * // ... use baseQueries for lookups ... + * client.close(); + * ``` + */ +export class RemoteGraphClient { + private config: RemoteGraphConfig; + private db: DatabaseConnection | null = null; + private queries: QueryBuilder | null = null; + private cachePath: string; + + constructor(config: RemoteGraphConfig) { + this.config = config; + const cacheDir = config.cacheDir || path.join(process.cwd(), '.codegraph'); + this.cachePath = path.join(cacheDir, BASE_GRAPH_FILENAME); + } + + /** + * Fetch the remote base graph database into the local cache. + * + * Skips the download if the cached copy is still within TTL. + * Supports local file paths (with or without `file://` prefix) + * and HTTP(S) URLs. + * + * @throws Error if the source is unreachable or the download fails + */ + async fetch(): Promise { + // Ensure cache directory exists + const cacheDir = path.dirname(this.cachePath); + if (!fs.existsSync(cacheDir)) { + fs.mkdirSync(cacheDir, { recursive: true }); + } + + // Skip download if cache is fresh + if (this.isCacheValid()) { + return; + } + + const url = this.config.url; + + if (url.startsWith('http://') || url.startsWith('https://')) { + await this.downloadHttp(url, this.cachePath); + } else { + // Local file path (strip file:// prefix if present) + const filePath = url.replace(/^file:\/\//, ''); + if (!fs.existsSync(filePath)) { + throw new Error(`Remote base graph not found: ${filePath}`); + } + fs.copyFileSync(filePath, this.cachePath); + } + } + + /** + * Open the cached base graph database and return a QueryBuilder. + * + * The database is opened in the default mode (WAL). Must call + * {@link fetch} before calling this method. + * + * @returns QueryBuilder backed by the base graph database + * @throws Error if the cache file does not exist + */ + open(): QueryBuilder { + if (this.queries) return this.queries; + + if (!fs.existsSync(this.cachePath)) { + throw new Error( + 'Remote base graph not cached. Call fetch() first.' + ); + } + + this.db = DatabaseConnection.open(this.cachePath); + this.queries = new QueryBuilder(this.db.getDb()); + return this.queries; + } + + /** + * Close the database connection and release resources. + * Safe to call multiple times. + */ + close(): void { + if (this.db) { + this.db.close(); + this.db = null; + this.queries = null; + } + } + + /** Get the overlay configuration. */ + getConfig(): RemoteGraphConfig { + return this.config; + } + + /** Get the local cache path for the base graph database. */ + getCachePath(): string { + return this.cachePath; + } + + /** + * Check whether the local cache is still within TTL. + * + * @returns true if the cached file exists and is younger than cacheTTL + */ + isCacheValid(): boolean { + if (!fs.existsSync(this.cachePath)) return false; + const ttl = this.config.cacheTTL ?? DEFAULT_CACHE_TTL; + const stat = fs.statSync(this.cachePath); + return Date.now() - stat.mtimeMs < ttl; + } + + /** + * Download a file over HTTP(S), following one level of redirects. + * + * @param url - Source URL + * @param dest - Local destination path + */ + private downloadHttp(url: string, dest: string): Promise { + return new Promise((resolve, reject) => { + const proto = url.startsWith('https') ? https : http; + const file = fs.createWriteStream(dest); + + proto + .get(url, (response) => { + // Follow one redirect + if ( + (response.statusCode === 301 || response.statusCode === 302) && + response.headers.location + ) { + file.close(); + fs.unlinkSync(dest); + this.downloadHttp(response.headers.location, dest) + .then(resolve) + .catch(reject); + return; + } + + if (response.statusCode !== 200) { + file.close(); + fs.unlinkSync(dest); + reject( + new Error( + `Failed to download remote base graph: HTTP ${response.statusCode}` + ) + ); + return; + } + + response.pipe(file); + file.on('finish', () => { + file.close(); + resolve(); + }); + }) + .on('error', (err) => { + file.close(); + if (fs.existsSync(dest)) fs.unlinkSync(dest); + reject(err); + }); + }); + } +} diff --git a/src/overlay/types.ts b/src/overlay/types.ts new file mode 100644 index 00000000..08b82506 --- /dev/null +++ b/src/overlay/types.ts @@ -0,0 +1,72 @@ +/** + * Overlay System Types + * + * Type definitions for the remote graph overlay system that enables + * team-friendly code intelligence by combining a shared base graph + * with local feature branch changes. + * + * Architecture: + * Remote base graph (main branch, built by CI) + * + + * Local overlay (feature branch diffs only) + * = + * Seamless merged view for LLM queries + */ + +/** + * Configuration for connecting to a remote base graph. + * + * The base graph represents the main/development branch's complete + * code graph, built and hosted centrally (e.g., by CI/CD). + */ +export interface RemoteGraphConfig { + /** + * URL or file path to the remote database. + * + * Supported schemes: + * - Local file paths: `/path/to/base-graph.db` + * - file:// URIs: `file:///path/to/base-graph.db` + * - HTTP(S): `https://ci-server.example.com/codegraph/base-graph.db` + */ + url: string; + + /** + * Base branch name to compare against for diff detection. + * Typically 'main' or 'development'. + */ + baseBranch: string; + + /** + * Local cache directory for the downloaded database. + * Defaults to `.codegraph/` within the project root. + */ + cacheDir?: string; + + /** + * Cache time-to-live in milliseconds. + * The cached base graph is considered fresh within this period. + * Default: 3_600_000 (1 hour). + */ + cacheTTL?: number; +} + +/** + * Result of branch diff detection, categorizing files by their + * change status relative to the base branch. + */ +export interface BranchDiffResult { + /** Files added on the feature branch (not present on base) */ + added: string[]; + + /** Files modified on the feature branch (content differs from base) */ + modified: string[]; + + /** Files deleted on the feature branch (present on base but removed) */ + deleted: string[]; + + /** Name of the current feature branch */ + currentBranch: string; + + /** Name of the base branch being compared against */ + baseBranch: string; +} From c0a07373727521371725e3fdb19b23d77e8709e2 Mon Sep 17 00:00:00 2001 From: GrokCode Date: Fri, 22 May 2026 15:58:43 +0000 Subject: [PATCH 3/4] turn 2 From 59ba2f383003054e86c3fbabd847871187974529 Mon Sep 17 00:00:00 2001 From: GrokCode Date: Fri, 22 May 2026 15:59:39 +0000 Subject: [PATCH 4/4] turn 3