diff --git a/gitnexus/README.md b/gitnexus/README.md index 55cdc12f7..1a6c2b856 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -166,6 +166,12 @@ gitnexus clean # Delete index for current repo gitnexus clean --all --force # Delete all indexes gitnexus wiki [path] # Generate LLM-powered docs from knowledge graph gitnexus wiki --model # Wiki with custom LLM model (default: gpt-4o-mini) +gitnexus export [path] # Export index to .gitnexus/export/ as JSON (nodes, edges, meta.json) +gitnexus export --format parquet # Export as Parquet files +gitnexus export --format csv # Export as CSV files +gitnexus export --output # Write to a custom directory instead of .gitnexus/export/ +gitnexus export --embeddings # Also export embeddings (requires prior `analyze --embeddings`) +gitnexus export --force # Overwrite existing export directory without prompting # Repository groups (multi-repo / monorepo service tracking) gitnexus group create # Create a repository group diff --git a/gitnexus/src/cli/export.ts b/gitnexus/src/cli/export.ts new file mode 100644 index 000000000..be16f7416 --- /dev/null +++ b/gitnexus/src/cli/export.ts @@ -0,0 +1,153 @@ +/** + * Export Command + * + * Reads LadybugDB for the current repo and writes its contents to + * .gitnexus/export/ as Parquet files — one per non-empty node table, + * one for edges, one for embeddings (optional), and a copy of meta.json. + */ + +import fs from 'fs/promises'; +import path from 'path'; +import { findRepo } from '../storage/repo-manager.js'; +import { withLbugDb, executeQuery, loadJsonExtension } from '../core/lbug/lbug-adapter.js'; +import { + NODE_TABLES, + REL_TABLE_NAME, + EMBEDDING_TABLE_NAME, + BACKTICK_NODE_TABLES, +} from '../core/lbug/schema.js'; +import { cliError, cliWarn } from './cli-message.js'; + +const normalizeCopyPath = (p: string): string => p.replace(/\\/g, '/'); + +const escapeTableName = (table: string): string => + BACKTICK_NODE_TABLES.has(table) ? `\`${table}\`` : table; + +interface ExportOptions { + output?: string; + force?: boolean; + /** true when --embeddings is passed; omitted by default */ + embeddings?: boolean; + /** Output format: 'json' (default), 'csv', or 'parquet' */ + format?: string; +} + +export const exportCommand = async (pathArg?: string, options: ExportOptions = {}) => { + const cwd = pathArg ? path.resolve(pathArg) : process.cwd(); + + const repo = await findRepo(cwd); + if (!repo) { + cliError('No indexed repository found. Run `gitnexus analyze` first.'); + process.exitCode = 1; + return; + } + + const exportDir = options.output + ? path.resolve(options.output) + : path.join(repo.storagePath, 'export'); + + // Mirror clean.ts confirmation-prompt pattern: warn and bail without --force + if (!options.force) { + try { + const existing = await fs.readdir(exportDir); + if (existing.length > 0) { + console.log(`Export directory already contains files: ${exportDir}`); + console.log('Run with --force to overwrite.'); + return; + } + } catch { + // Directory does not exist yet — no prompt needed + } + } + + await fs.mkdir(exportDir, { recursive: true }); + + const exportedFiles: string[] = []; + + const fmt = (options.format ?? 'json').toLowerCase(); + const ext = fmt === 'parquet' ? 'parquet' : fmt === 'csv' ? 'csv' : 'json'; + // KuzuDB COPY TO infers format from the file extension; no FORMAT clause is needed. + // CSV output only needs HEADER=true so the first row contains column names. + const copySuffix = fmt === 'csv' ? ` (HEADER=true)` : ''; + + let jsonExtensionFailed = false; + + await withLbugDb(repo.lbugPath, async () => { + // JSON extension is required for COPY TO '*.json' — delegate to the + // shared ExtensionManager so install runs out-of-process, policy + // (GITNEXUS_LBUG_EXTENSION_INSTALL) is respected, and capability is cached. + if (fmt === 'json') { + const jsonReady = await loadJsonExtension(); + if (!jsonReady) { + jsonExtensionFailed = true; + return; + } + } + + // Export node tables — skip empty ones + for (const table of NODE_TABLES) { + const escaped = escapeTableName(table); + const countRows = await executeQuery(`MATCH (n:${escaped}) RETURN count(n) AS cnt`); + const count = Number(countRows[0]?.cnt ?? 0); + if (count === 0) continue; + + const outFile = `nodes_${table}.${ext}`; + const outPath = path.join(exportDir, outFile); + const copyPath = normalizeCopyPath(outPath); + await executeQuery(`COPY (MATCH (n:${escaped}) RETURN n.*) TO '${copyPath}'${copySuffix}`); + exportedFiles.push(outFile); + } + + // Export edges + const edgeCountRows = await executeQuery( + `MATCH ()-[r:${REL_TABLE_NAME}]->() RETURN count(r) AS cnt`, + ); + const edgeCount = Number(edgeCountRows[0]?.cnt ?? 0); + if (edgeCount > 0) { + const outFile = `edges_${REL_TABLE_NAME}.${ext}`; + const outPath = path.join(exportDir, outFile); + const copyPath = normalizeCopyPath(outPath); + await executeQuery( + `COPY (MATCH ()-[r:${REL_TABLE_NAME}]->() RETURN r.*) TO '${copyPath}'${copySuffix}`, + ); + exportedFiles.push(outFile); + } + + // Export embeddings — opt-in via --embeddings; skip if count is 0 + if (options.embeddings === true) { + const embCountRows = await executeQuery( + `MATCH (n:${EMBEDDING_TABLE_NAME}) RETURN count(n) AS cnt`, + ); + const embCount = Number(embCountRows[0]?.cnt ?? 0); + if (embCount === 0) { + cliWarn( + 'No embeddings found in the index (embeddings: 0). Skipping embeddings export.\n' + + 'Run `gitnexus analyze --embeddings` to generate them, then re-run export.', + ); + } else if (embCount > 0) { + const outFile = `embeddings_${EMBEDDING_TABLE_NAME}.${ext}`; + const outPath = path.join(exportDir, outFile); + const copyPath = normalizeCopyPath(outPath); + await executeQuery( + `COPY (MATCH (n:${EMBEDDING_TABLE_NAME}) RETURN n.*) TO '${copyPath}'${copySuffix}`, + ); + exportedFiles.push(outFile); + } + } + }); + + if (jsonExtensionFailed) { + cliError('JSON extension unavailable. Install it manually or use --format parquet.'); + process.exitCode = 1; + return; + } + + // Always copy meta.json as-is + await fs.copyFile(repo.metaPath, path.join(exportDir, 'meta.json')); + exportedFiles.push('meta.json'); + + console.log(`Exported ${exportedFiles.length} file(s) to: ${exportDir}`); + for (const f of exportedFiles) { + console.log(` ${f}`); + } +}; diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index d38675f03..024c1f354 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -140,6 +140,20 @@ program .option('-f, --force', 'Skip confirmation prompt') .action(createLazyAction(() => import('./remove.js'), 'removeCommand')); +program + .command('export [path]') + .description( + 'Export the knowledge graph to JSON, CSV, or Parquet (see --format; default: json, output: .gitnexus/export/)', + ) + .option('-o, --output ', 'Output directory (default: .gitnexus/export)') + .option('--format ', 'Output format: json (default), csv, or parquet') + .option('-f, --force', 'Overwrite existing export without prompting') + .option( + '--embeddings', + 'Include the embeddings table in the export (omitted by default; can be large)', + ) + .action(createLazyAction(() => import('./export.js'), 'exportCommand')); + program .command('wiki [path]') .description('Generate repository wiki from knowledge graph') diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index fe831cd43..93430105a 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -154,6 +154,7 @@ let conn: lbug.Connection | null = null; let currentDbPath: string | null = null; let ftsLoaded = false; let vectorExtensionLoaded = false; +let jsonExtensionLoaded = false; /** * In-process cache of FTS indexes observed against the current singleton @@ -254,6 +255,7 @@ export const withLbugDb = async (dbPath: string, operation: () => Promise) currentDbPath = null; ftsLoaded = false; vectorExtensionLoaded = false; + jsonExtensionLoaded = false; ensuredFTSIndexes.clear(); }); // Sleep outside the lock — no need to block others while waiting @@ -280,6 +282,7 @@ const doInitLbug = async (dbPath: string) => { currentDbPath = null; ftsLoaded = false; vectorExtensionLoaded = false; + jsonExtensionLoaded = false; ensuredFTSIndexes.clear(); } @@ -1124,6 +1127,7 @@ export const closeLbug = async (): Promise => { currentDbPath = null; ftsLoaded = false; vectorExtensionLoaded = false; + jsonExtensionLoaded = false; ensuredFTSIndexes.clear(); }; @@ -1266,6 +1270,33 @@ export const loadVectorExtension = async ( if (loaded && useModuleState) vectorExtensionLoaded = true; return loaded; }; + +/** + * Load the JSON extension on the supplied connection (or the singleton + * writable connection when none is given). + * + * Delegates to the shared `ExtensionManager` so install policy (auto / + * load-only / never), out-of-process bounded INSTALL, and capability + * caching are owned in one place. Required for COPY TO '*.json' in + * the export command; callers must treat a `false` return as a hard error. + */ +export const loadJsonExtension = async ( + targetConn?: lbug.Connection, + opts: ExtensionEnsureOptions = {}, +): Promise => { + const useModuleState = targetConn === undefined; + if (useModuleState && jsonExtensionLoaded) return true; + + const c: lbug.Connection | null = targetConn ?? conn; + if (!c) { + throw new Error('LadybugDB not initialized. Call initLbug first.'); + } + + const loaded = await extensionManager.ensure((sql) => c.query(sql), 'json', 'JSON', opts); + if (loaded && useModuleState) jsonExtensionLoaded = true; + return loaded; +}; + /** * Create a full-text search index on a table * @param tableName - The node table name (e.g., 'File', 'CodeSymbol') diff --git a/gitnexus/src/core/lbug/schema.ts b/gitnexus/src/core/lbug/schema.ts index cd6838dd8..e67893ee0 100644 --- a/gitnexus/src/core/lbug/schema.ts +++ b/gitnexus/src/core/lbug/schema.ts @@ -185,6 +185,31 @@ export const ANNOTATION_SCHEMA = CODE_ELEMENT_BASE('Annotation'); export const CONSTRUCTOR_SCHEMA = CODE_ELEMENT_BASE('Constructor'); export const TEMPLATE_SCHEMA = CODE_ELEMENT_BASE('Template'); export const MODULE_SCHEMA = CODE_ELEMENT_BASE('Module'); + +// Node tables whose DDL was created with backtick-quoted names (CODE_ELEMENT_BASE or inline). +// Any Cypher that references these labels by name must also use backticks. +export const BACKTICK_NODE_TABLES = new Set([ + 'Struct', + 'Enum', + 'Macro', + 'Typedef', + 'Union', + 'Namespace', + 'Trait', + 'Impl', + 'TypeAlias', + 'Const', + 'Static', + 'Variable', + 'Property', + 'Record', + 'Delegate', + 'Annotation', + 'Constructor', + 'Template', + 'Module', +]); + // API route endpoints (Next.js, Express, etc.) export const ROUTE_SCHEMA = ` CREATE NODE TABLE Route ( diff --git a/gitnexus/test/integration/export-smoke.test.ts b/gitnexus/test/integration/export-smoke.test.ts new file mode 100644 index 000000000..355a60727 --- /dev/null +++ b/gitnexus/test/integration/export-smoke.test.ts @@ -0,0 +1,195 @@ +/** + * Integration smoke test: `gitnexus export` + * + * Runs analyze on the mini-repo fixture, then exercises the three export + * formats (json, csv, parquet) and verifies that per-table files are + * created and non-empty. Uses the same spawning helpers as cli-e2e.test.ts. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawnSync } from 'child_process'; +import path from 'path'; +import fs from 'fs'; +import os from 'os'; +import { fileURLToPath, pathToFileURL } from 'url'; +import { createRequire } from 'module'; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(testDir, '../..'); +const cliEntry = path.join(repoRoot, 'src/cli/index.ts'); +const FIXTURE_SRC = path.resolve(testDir, '..', 'fixtures', 'mini-repo'); + +const _require = createRequire(import.meta.url); +const tsxPkgDir = path.dirname(_require.resolve('tsx/package.json')); +const tsxImportUrl = pathToFileURL(path.join(tsxPkgDir, 'dist', 'loader.mjs')).href; + +let MINI_REPO: string; +let tmpParent: string; + +function runCliRaw( + extraArgs: string[], + cwd: string, + extraEnv: Record = {}, + timeoutMs = 60000, +) { + return spawnSync(process.execPath, ['--import', tsxImportUrl, cliEntry, ...extraArgs], { + cwd, + encoding: 'utf8', + timeout: timeoutMs, + stdio: ['pipe', 'pipe', 'pipe'], + env: { + ...process.env, + NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), + ...extraEnv, + }, + }); +} + +beforeAll(() => { + tmpParent = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-export-smoke-')); + MINI_REPO = path.join(tmpParent, 'mini-repo'); + fs.cpSync(FIXTURE_SRC, MINI_REPO, { recursive: true }); + + spawnSync('git', ['init'], { cwd: MINI_REPO, stdio: 'pipe' }); + spawnSync('git', ['add', '-A'], { cwd: MINI_REPO, stdio: 'pipe' }); + spawnSync('git', ['commit', '-m', 'initial commit'], { + cwd: MINI_REPO, + stdio: 'pipe', + env: { + ...process.env, + GIT_AUTHOR_NAME: 'test', + GIT_AUTHOR_EMAIL: 'test@test', + GIT_COMMITTER_NAME: 'test', + GIT_COMMITTER_EMAIL: 'test@test', + }, + }); + + // Index the repo once so all export tests can share the artifact. + const analyzeResult = runCliRaw(['analyze', '--index-only'], MINI_REPO, {}, 60000); + if (analyzeResult.status !== 0 && analyzeResult.status !== null) { + throw new Error( + `analyze setup failed (exit ${analyzeResult.status}):\n${analyzeResult.stdout}\n${analyzeResult.stderr}`, + ); + } +}, 120_000); + +afterAll(() => { + if (tmpParent) { + fs.rmSync(tmpParent, { recursive: true, force: true }); + } +}); + +describe('gitnexus export smoke tests', () => { + it('export --format json writes non-empty node and meta.json files', () => { + const exportDir = path.join(tmpParent, 'export-json'); + + const result = runCliRaw( + ['export', MINI_REPO, '-o', exportDir, '--format', 'json', '--force'], + MINI_REPO, + ); + + if (result.status === null) return; // timeout — slow CI + + expect( + result.status, + `export json failed:\nstdout: ${result.stdout}\nstderr: ${result.stderr}`, + ).toBe(0); + + const files = fs.readdirSync(exportDir); + expect(files).toContain('meta.json'); + + const nodeFiles = files.filter((f) => f.startsWith('nodes_') && f.endsWith('.json')); + expect(nodeFiles.length).toBeGreaterThan(0); + + // Every exported file must be non-empty + for (const f of files) { + const size = fs.statSync(path.join(exportDir, f)).size; + expect(size, `${f} is empty`).toBeGreaterThan(0); + } + }, 90_000); + + it('export --format csv writes non-empty .csv files', () => { + const exportDir = path.join(tmpParent, 'export-csv'); + + const result = runCliRaw( + ['export', MINI_REPO, '-o', exportDir, '--format', 'csv', '--force'], + MINI_REPO, + ); + + if (result.status === null) return; + + expect( + result.status, + `export csv failed:\nstdout: ${result.stdout}\nstderr: ${result.stderr}`, + ).toBe(0); + + const files = fs.readdirSync(exportDir); + expect(files).toContain('meta.json'); + + const csvFiles = files.filter((f) => f.endsWith('.csv')); + expect(csvFiles.length).toBeGreaterThan(0); + + for (const f of csvFiles) { + const size = fs.statSync(path.join(exportDir, f)).size; + expect(size, `${f} is empty`).toBeGreaterThan(0); + } + }, 90_000); + + it('export --format parquet writes non-empty .parquet files', () => { + const exportDir = path.join(tmpParent, 'export-parquet'); + + const result = runCliRaw( + ['export', MINI_REPO, '-o', exportDir, '--format', 'parquet', '--force'], + MINI_REPO, + ); + + if (result.status === null) return; + + expect( + result.status, + `export parquet failed:\nstdout: ${result.stdout}\nstderr: ${result.stderr}`, + ).toBe(0); + + const files = fs.readdirSync(exportDir); + expect(files).toContain('meta.json'); + + const parquetFiles = files.filter((f) => f.endsWith('.parquet')); + expect(parquetFiles.length).toBeGreaterThan(0); + + for (const f of parquetFiles) { + const size = fs.statSync(path.join(exportDir, f)).size; + expect(size, `${f} is empty`).toBeGreaterThan(0); + } + }, 90_000); + + it('export without --force exits cleanly when output dir already has files', () => { + const exportDir = path.join(tmpParent, 'export-guard'); + fs.mkdirSync(exportDir, { recursive: true }); + fs.writeFileSync(path.join(exportDir, 'existing.json'), '{}'); + + const result = runCliRaw(['export', MINI_REPO, '-o', exportDir], MINI_REPO); + + if (result.status === null) return; + + expect(result.status).toBe(0); // exits cleanly, not an error + expect(result.stdout + result.stderr).toMatch(/already contains files|--force/i); + // The existing file must not have been overwritten + const files = fs.readdirSync(exportDir); + expect(files).toEqual(['existing.json']); + }, 30_000); + + it('export with no indexed repo exits with non-zero code', () => { + const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-no-index-')); + spawnSync('git', ['init'], { cwd: emptyDir, stdio: 'pipe' }); + + try { + const result = runCliRaw(['export', emptyDir, '--force'], emptyDir); + + if (result.status === null) return; + + expect(result.status).not.toBe(0); + expect(result.stdout + result.stderr).toMatch(/no indexed repository|gitnexus analyze/i); + } finally { + fs.rmSync(emptyDir, { recursive: true, force: true }); + } + }, 30_000); +}); diff --git a/gitnexus/test/unit/cli/export.test.ts b/gitnexus/test/unit/cli/export.test.ts new file mode 100644 index 000000000..28fb028bb --- /dev/null +++ b/gitnexus/test/unit/cli/export.test.ts @@ -0,0 +1,498 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import path from 'node:path'; + +// ─── fs/promises mocks ──────────────────────────────────────────────────────── +const mockMkdir = vi.fn(); +const mockCopyFile = vi.fn(); +const mockReaddir = vi.fn(); + +vi.mock('fs/promises', () => ({ + default: { + mkdir: mockMkdir, + copyFile: mockCopyFile, + readdir: mockReaddir, + }, +})); + +// ─── repo-manager mocks ─────────────────────────────────────────────────────── +const mockFindRepo = vi.fn(); + +vi.mock('../../../src/storage/repo-manager.js', () => ({ + findRepo: mockFindRepo, +})); + +// ─── lbug-adapter mocks ─────────────────────────────────────────────────────── +const mockWithLbugDb = vi.fn(); +const mockExecuteQuery = vi.fn(); +const mockLoadJsonExtension = vi.fn(); + +vi.mock('../../../src/core/lbug/lbug-adapter.js', () => ({ + withLbugDb: mockWithLbugDb, + executeQuery: mockExecuteQuery, + loadJsonExtension: mockLoadJsonExtension, +})); + +// ─── Fake repo fixture ──────────────────────────────────────────────────────── +const fakeRepo = { + repoPath: path.resolve('/repo'), + storagePath: path.resolve('/repo/.gitnexus'), + lbugPath: path.resolve('/repo/.gitnexus/lbug'), + metaPath: path.resolve('/repo/.gitnexus/meta.json'), + meta: { repoPath: path.resolve('/repo'), lastCommit: 'abc', indexedAt: '2026-01-01T00:00:00Z' }, +}; + +describe('exportCommand', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.exitCode = undefined; + + // Default: repo is indexed + mockFindRepo.mockResolvedValue(fakeRepo); + + // Default: export dir does not exist yet (readdir throws ENOENT) + mockReaddir.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + + // Default: mkdir and copyFile succeed + mockMkdir.mockResolvedValue(undefined); + mockCopyFile.mockResolvedValue(undefined); + + // Default: withLbugDb calls through + mockWithLbugDb.mockImplementation(async (_dbPath: string, operation: () => Promise) => { + await operation(); + }); + + // Default: JSON extension is available + mockLoadJsonExtension.mockResolvedValue(true); + + // Default: executeQuery returns count=0 for all tables, [] for COPY/extension queries + mockExecuteQuery.mockImplementation(async (cypher: string) => { + if (cypher.includes('RETURN count')) return [{ cnt: 0 }]; + return []; + }); + }); + + it('fails cleanly when repo not indexed', async () => { + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + mockFindRepo.mockResolvedValue(null); + + const { exportCommand } = await import('../../../src/cli/export.js'); + await exportCommand(undefined, {}); + + expect(process.exitCode).toBe(1); + expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('No indexed repository')); + expect(mockWithLbugDb).not.toHaveBeenCalled(); + }); + + it('default output path resolves to /export', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + const expectedDir = path.join(fakeRepo.storagePath, 'export'); + + const { exportCommand } = await import('../../../src/cli/export.js'); + await exportCommand(undefined, { force: true }); + + expect(mockMkdir).toHaveBeenCalledWith(expectedDir, { recursive: true }); + }); + + it('--output override uses provided path', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + const customDir = path.resolve('/custom/export/dir'); + + const { exportCommand } = await import('../../../src/cli/export.js'); + await exportCommand(undefined, { output: customDir, force: true }); + + expect(mockMkdir).toHaveBeenCalledWith(customDir, { recursive: true }); + }); + + it('skips empty tables — no COPY call when count is 0', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + // All counts return 0 (default mock) + + const { exportCommand } = await import('../../../src/cli/export.js'); + await exportCommand(undefined, { force: true }); + + const copyCalls = mockExecuteQuery.mock.calls.filter((args: any[]) => + (args[0] as string).trimStart().startsWith('COPY'), + ); + expect(copyCalls).toHaveLength(0); + }); + + it('exports non-empty node tables with correct json filename', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + const exportDir = path.join(fakeRepo.storagePath, 'export'); + + // Only Function table is non-empty + mockExecuteQuery.mockImplementation(async (cypher: string) => { + if (cypher.includes('RETURN count') && cypher.includes(':Function')) return [{ cnt: 10 }]; + if (cypher.includes('RETURN count')) return [{ cnt: 0 }]; + return []; + }); + + const { exportCommand } = await import('../../../src/cli/export.js'); + await exportCommand(undefined, { force: true }); + + const copyCalls = mockExecuteQuery.mock.calls.filter((args: any[]) => + (args[0] as string).trimStart().startsWith('COPY'), + ); + expect(copyCalls).toHaveLength(1); + const copyQuery = copyCalls[0][0] as string; + const expectedPath = path.join(exportDir, 'nodes_Function.json').replace(/\\/g, '/'); + expect(copyQuery).toContain(expectedPath); + expect(copyQuery).not.toContain('FORMAT PARQUET'); + }); + + it('exports edges when edge count is non-zero', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + const exportDir = path.join(fakeRepo.storagePath, 'export'); + + mockExecuteQuery.mockImplementation(async (cypher: string) => { + if (cypher.includes('RETURN count') && cypher.includes('CodeRelation')) return [{ cnt: 50 }]; + if (cypher.includes('RETURN count')) return [{ cnt: 0 }]; + return []; + }); + + const { exportCommand } = await import('../../../src/cli/export.js'); + await exportCommand(undefined, { force: true }); + + const copyCalls = mockExecuteQuery.mock.calls.filter((args: any[]) => + (args[0] as string).trimStart().startsWith('COPY'), + ); + expect(copyCalls).toHaveLength(1); + const copyQuery = copyCalls[0][0] as string; + const expectedPath = path.join(exportDir, 'edges_CodeRelation.json').replace(/\\/g, '/'); + expect(copyQuery).toContain(expectedPath); + expect(copyQuery).toContain('CodeRelation'); + expect(copyQuery).not.toContain('FORMAT PARQUET'); + }); + + it('--format parquet produces .parquet files', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + const exportDir = path.join(fakeRepo.storagePath, 'export'); + + mockExecuteQuery.mockImplementation(async (cypher: string) => { + if (cypher.includes('RETURN count') && cypher.includes(':Function')) return [{ cnt: 10 }]; + if (cypher.includes('RETURN count') && cypher.includes('CodeRelation')) return [{ cnt: 5 }]; + if (cypher.includes('RETURN count')) return [{ cnt: 0 }]; + return []; + }); + + const { exportCommand } = await import('../../../src/cli/export.js'); + await exportCommand(undefined, { force: true, format: 'parquet' }); + + const copyCalls = mockExecuteQuery.mock.calls.filter((args: any[]) => + (args[0] as string).trimStart().startsWith('COPY'), + ); + expect(copyCalls.length).toBeGreaterThanOrEqual(2); + + const nodeCopy = copyCalls.find((args: any[]) => + (args[0] as string).includes('nodes_Function'), + ); + const edgeCopy = copyCalls.find((args: any[]) => + (args[0] as string).includes('edges_CodeRelation'), + ); + expect(nodeCopy).toBeDefined(); + expect(edgeCopy).toBeDefined(); + + const nodeQuery = nodeCopy![0] as string; + const edgeQuery = edgeCopy![0] as string; + expect(nodeQuery).toContain(path.join(exportDir, 'nodes_Function.parquet').replace(/\\/g, '/')); + expect(nodeQuery).not.toContain('FORMAT PARQUET'); + expect(edgeQuery).toContain( + path.join(exportDir, 'edges_CodeRelation.parquet').replace(/\\/g, '/'), + ); + expect(edgeQuery).not.toContain('FORMAT PARQUET'); + }); + + it('--format csv produces .csv files with CSV header clause', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + const exportDir = path.join(fakeRepo.storagePath, 'export'); + + mockExecuteQuery.mockImplementation(async (cypher: string) => { + if (cypher.includes('RETURN count') && cypher.includes(':Function')) return [{ cnt: 10 }]; + if (cypher.includes('RETURN count') && cypher.includes('CodeRelation')) return [{ cnt: 5 }]; + if (cypher.includes('RETURN count')) return [{ cnt: 0 }]; + return []; + }); + + const { exportCommand } = await import('../../../src/cli/export.js'); + await exportCommand(undefined, { force: true, format: 'csv' }); + + const copyCalls = mockExecuteQuery.mock.calls.filter((args: any[]) => + (args[0] as string).trimStart().startsWith('COPY'), + ); + expect(copyCalls.length).toBeGreaterThanOrEqual(2); + + const nodeCopy = copyCalls.find((args: any[]) => + (args[0] as string).includes('nodes_Function'), + ); + const edgeCopy = copyCalls.find((args: any[]) => + (args[0] as string).includes('edges_CodeRelation'), + ); + expect(nodeCopy).toBeDefined(); + expect(edgeCopy).toBeDefined(); + + const nodeQuery = nodeCopy![0] as string; + const edgeQuery = edgeCopy![0] as string; + expect(nodeQuery).toContain(path.join(exportDir, 'nodes_Function.csv').replace(/\\/g, '/')); + expect(nodeQuery).toContain('HEADER=true'); + expect(nodeQuery).not.toContain('FORMAT CSV'); + expect(nodeQuery).not.toContain('FORMAT PARQUET'); + expect(edgeQuery).toContain(path.join(exportDir, 'edges_CodeRelation.csv').replace(/\\/g, '/')); + expect(edgeQuery).toContain('HEADER=true'); + expect(edgeQuery).not.toContain('FORMAT CSV'); + expect(edgeQuery).not.toContain('FORMAT PARQUET'); + }); + + it('default produces .json files', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + const exportDir = path.join(fakeRepo.storagePath, 'export'); + + mockExecuteQuery.mockImplementation(async (cypher: string) => { + if (cypher.includes('RETURN count') && cypher.includes(':Function')) return [{ cnt: 10 }]; + if (cypher.includes('RETURN count') && cypher.includes('CodeRelation')) return [{ cnt: 5 }]; + if (cypher.includes('RETURN count')) return [{ cnt: 0 }]; + return []; + }); + + const { exportCommand } = await import('../../../src/cli/export.js'); + await exportCommand(undefined, { force: true }); + + const copyCalls = mockExecuteQuery.mock.calls.filter((args: any[]) => + (args[0] as string).trimStart().startsWith('COPY'), + ); + expect(copyCalls.length).toBeGreaterThanOrEqual(2); + + const nodeCopy = copyCalls.find((args: any[]) => + (args[0] as string).includes('nodes_Function'), + ); + const edgeCopy = copyCalls.find((args: any[]) => + (args[0] as string).includes('edges_CodeRelation'), + ); + expect(nodeCopy).toBeDefined(); + expect(edgeCopy).toBeDefined(); + + const nodeQuery = nodeCopy![0] as string; + const edgeQuery = edgeCopy![0] as string; + expect(nodeQuery).toContain(path.join(exportDir, 'nodes_Function.json').replace(/\\/g, '/')); + expect(nodeQuery).not.toContain('FORMAT PARQUET'); + expect(edgeQuery).toContain( + path.join(exportDir, 'edges_CodeRelation.json').replace(/\\/g, '/'), + ); + expect(edgeQuery).not.toContain('FORMAT PARQUET'); + }); + + it('embeddings omitted by default (no --embeddings flag)', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Make all tables non-empty to confirm it's specifically embeddings being omitted + mockExecuteQuery.mockImplementation(async (cypher: string) => { + if (cypher.includes('RETURN count')) return [{ cnt: 5 }]; + return []; + }); + + const { exportCommand } = await import('../../../src/cli/export.js'); + await exportCommand(undefined, { force: true }); // no embeddings flag + + const copyCalls = mockExecuteQuery.mock.calls.filter((args: any[]) => + (args[0] as string).trimStart().startsWith('COPY'), + ); + const embeddingCopy = copyCalls.find((args: any[]) => + (args[0] as string).includes('CodeEmbedding'), + ); + expect(embeddingCopy).toBeUndefined(); + }); + + it('--embeddings includes embeddings table when non-empty', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + const exportDir = path.join(fakeRepo.storagePath, 'export'); + + mockExecuteQuery.mockImplementation(async (cypher: string) => { + if (cypher.includes('RETURN count') && cypher.includes('CodeEmbedding')) return [{ cnt: 5 }]; + if (cypher.includes('RETURN count')) return [{ cnt: 0 }]; + return []; + }); + + const { exportCommand } = await import('../../../src/cli/export.js'); + await exportCommand(undefined, { force: true, embeddings: true }); + + const copyCalls = mockExecuteQuery.mock.calls.filter((args: any[]) => + (args[0] as string).trimStart().startsWith('COPY'), + ); + const embeddingCopy = copyCalls.find((args: any[]) => + (args[0] as string).includes('CodeEmbedding'), + ); + expect(embeddingCopy).toBeDefined(); + expect(embeddingCopy![0] as string).toContain( + path.join(exportDir, 'embeddings_CodeEmbedding.json').replace(/\\/g, '/'), + ); + }); + + it('default export (no --embeddings) still exports node and edge tables', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Function and edges are non-empty + mockExecuteQuery.mockImplementation(async (cypher: string) => { + if (cypher.includes('RETURN count') && cypher.includes(':Function')) return [{ cnt: 5 }]; + if (cypher.includes('RETURN count') && cypher.includes('CodeRelation')) return [{ cnt: 5 }]; + if (cypher.includes('RETURN count')) return [{ cnt: 0 }]; + return []; + }); + + const { exportCommand } = await import('../../../src/cli/export.js'); + await exportCommand(undefined, { force: true }); // no embeddings flag + + const copyCalls = mockExecuteQuery.mock.calls.filter((args: any[]) => + (args[0] as string).trimStart().startsWith('COPY'), + ); + const nodeCopy = copyCalls.find((args: any[]) => + (args[0] as string).includes('nodes_Function'), + ); + const edgeCopy = copyCalls.find((args: any[]) => + (args[0] as string).includes('edges_CodeRelation'), + ); + expect(nodeCopy).toBeDefined(); + expect(edgeCopy).toBeDefined(); + }); + + it('--embeddings skips COPY when embeddings table is empty', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + + mockExecuteQuery.mockImplementation(async (cypher: string) => { + if (cypher.includes('RETURN count') && cypher.includes('CodeEmbedding')) return [{ cnt: 0 }]; + if (cypher.includes('RETURN count')) return [{ cnt: 0 }]; + return []; + }); + + const { exportCommand } = await import('../../../src/cli/export.js'); + await exportCommand(undefined, { force: true, embeddings: true }); + + const embeddingCopy = mockExecuteQuery.mock.calls.find( + (args: any[]) => + (args[0] as string).trimStart().startsWith('COPY') && + (args[0] as string).includes('CodeEmbedding'), + ); + expect(embeddingCopy).toBeUndefined(); + }); + + it('forward-slash path passed to COPY even on Windows-style paths', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + + mockExecuteQuery.mockImplementation(async (cypher: string) => { + if (cypher.includes('RETURN count') && cypher.includes(':Function')) return [{ cnt: 10 }]; + if (cypher.includes('RETURN count')) return [{ cnt: 0 }]; + return []; + }); + + const { exportCommand } = await import('../../../src/cli/export.js'); + await exportCommand(undefined, { force: true }); + + const copyCalls = mockExecuteQuery.mock.calls.filter((args: any[]) => + (args[0] as string).trimStart().startsWith('COPY'), + ); + for (const args of copyCalls) { + expect(args[0] as string).not.toMatch(/\\/); + } + }); + + it('--force skips confirmation — exports without checking existing files', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + + const { exportCommand } = await import('../../../src/cli/export.js'); + await exportCommand(undefined, { force: true }); + + // readdir should NOT have been called when --force is set + expect(mockReaddir).not.toHaveBeenCalled(); + // DB was opened + expect(mockWithLbugDb).toHaveBeenCalled(); + }); + + it('confirmation prompt on existing dir — returns early without exporting', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Export dir has existing files + mockReaddir.mockResolvedValue(['old_file.parquet']); + + const { exportCommand } = await import('../../../src/cli/export.js'); + await exportCommand(undefined, {}); // no --force + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('already contains files')); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('--force')); + expect(mockWithLbugDb).not.toHaveBeenCalled(); + }); + + it('json format — extension unavailable: errors out, skips export and meta.json copy', async () => { + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + mockLoadJsonExtension.mockResolvedValue(false); + + const { exportCommand } = await import('../../../src/cli/export.js'); + await exportCommand(undefined, { force: true }); // default json format + + expect(process.exitCode).toBe(1); + expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('JSON extension unavailable')); + const copyCalls = mockExecuteQuery.mock.calls.filter((args: any[]) => + (args[0] as string).trimStart().startsWith('COPY'), + ); + expect(copyCalls).toHaveLength(0); + expect(mockCopyFile).not.toHaveBeenCalled(); + }); + + it('meta.json is always copied to the export directory', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + const exportDir = path.join(fakeRepo.storagePath, 'export'); + + const { exportCommand } = await import('../../../src/cli/export.js'); + await exportCommand(undefined, { force: true }); + + expect(mockCopyFile).toHaveBeenCalledWith(fakeRepo.metaPath, path.join(exportDir, 'meta.json')); + }); + + it('summary output mentions the export directory and file count', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const exportDir = path.join(fakeRepo.storagePath, 'export'); + + const { exportCommand } = await import('../../../src/cli/export.js'); + await exportCommand(undefined, { force: true }); + + // Should log summary with exportDir and file count + const summaryCall = logSpy.mock.calls.find( + ([msg]) => typeof msg === 'string' && msg.includes(exportDir), + ); + expect(summaryCall).toBeDefined(); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('file(s)')); + }); + + it('backtick-reserved table names are backtick-escaped in Cypher', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Make Struct (backtick-required) and Function (unquoted) both non-empty + mockExecuteQuery.mockImplementation(async (cypher: string) => { + if (cypher.includes('RETURN count') && cypher.includes('`Struct`')) return [{ cnt: 5 }]; + if (cypher.includes('RETURN count') && cypher.includes(':Struct')) return [{ cnt: 5 }]; + if (cypher.includes('RETURN count') && cypher.includes(':Function')) return [{ cnt: 5 }]; + if (cypher.includes('RETURN count')) return [{ cnt: 0 }]; + return []; + }); + + const { exportCommand } = await import('../../../src/cli/export.js'); + await exportCommand(undefined, { force: true }); + + const copyCalls = mockExecuteQuery.mock.calls.filter((args: any[]) => + (args[0] as string).trimStart().startsWith('COPY'), + ); + + const structCopy = copyCalls.find((args: any[]) => + (args[0] as string).includes('nodes_Struct'), + ); + const functionCopy = copyCalls.find((args: any[]) => + (args[0] as string).includes('nodes_Function'), + ); + + expect(structCopy).toBeDefined(); + // Struct must be backtick-escaped in the MATCH clause + expect(structCopy![0] as string).toMatch(/MATCH \(n:`Struct`\)/); + + expect(functionCopy).toBeDefined(); + // Function must NOT be backtick-escaped + expect(functionCopy![0] as string).toMatch(/MATCH \(n:Function\)/); + expect(functionCopy![0] as string).not.toMatch(/MATCH \(n:`Function`\)/); + }); +}); diff --git a/gitnexus/test/unit/schema.test.ts b/gitnexus/test/unit/schema.test.ts index 17db9bdf9..ad7dcfe60 100644 --- a/gitnexus/test/unit/schema.test.ts +++ b/gitnexus/test/unit/schema.test.ts @@ -20,6 +20,7 @@ import { RELATION_SCHEMA, EMBEDDING_SCHEMA, CREATE_VECTOR_INDEX_QUERY, + BACKTICK_NODE_TABLES, } from '../../src/core/lbug/schema.js'; describe('LadybugDB Schema', () => { @@ -194,6 +195,68 @@ describe('LadybugDB Schema', () => { }); }); + describe('BACKTICK_NODE_TABLES', () => { + it('contains all tables created via CODE_ELEMENT_BASE (backtick DDL)', () => { + const codeElementBaseTables = [ + 'Struct', + 'Enum', + 'Macro', + 'Typedef', + 'Union', + 'Namespace', + 'Trait', + 'Impl', + 'TypeAlias', + 'Const', + 'Static', + 'Variable', + 'Record', + 'Delegate', + 'Annotation', + 'Constructor', + 'Template', + 'Module', + ]; + for (const t of codeElementBaseTables) { + expect(BACKTICK_NODE_TABLES.has(t)).toBe(true); + } + }); + + it('contains Property (inline backtick DDL)', () => { + expect(BACKTICK_NODE_TABLES.has('Property')).toBe(true); + }); + + it('includes Variable (previously missing bug fix)', () => { + expect(BACKTICK_NODE_TABLES.has('Variable')).toBe(true); + }); + + it('does not include tables with unquoted DDL', () => { + const unquoted = [ + 'File', + 'Folder', + 'Function', + 'Class', + 'Interface', + 'Method', + 'CodeElement', + 'Community', + 'Process', + 'Section', + 'Route', + 'Tool', + ]; + for (const t of unquoted) { + expect(BACKTICK_NODE_TABLES.has(t)).toBe(false); + } + }); + + it('every member is present in NODE_TABLES', () => { + for (const t of BACKTICK_NODE_TABLES) { + expect(NODE_TABLES).toContain(t); + } + }); + }); + describe('embedding schema', () => { it('creates CodeEmbedding table', () => { expect(EMBEDDING_SCHEMA).toContain(`CREATE NODE TABLE ${EMBEDDING_TABLE_NAME}`);