feat(cli): add export command with multi-format graph output

This commit is contained in:
Richard Russoniello 2026-05-11 17:50:29 -04:00
parent fdf1effb2a
commit 949777e081
8 changed files with 985 additions and 0 deletions

View file

@ -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 <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 <dir> # 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 <name> # Create a repository group

153
gitnexus/src/cli/export.ts Normal file
View file

@ -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}`);
}
};

View file

@ -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 <dir>', 'Output directory (default: .gitnexus/export)')
.option('--format <fmt>', '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')

View file

@ -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 <T>(dbPath: string, operation: () => Promise<T>)
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<void> => {
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<boolean> => {
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')

View file

@ -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 (

View file

@ -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<string, string> = {},
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);
});

View file

@ -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<void>) => {
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 <storagePath>/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 <dir> 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`\)/);
});
});

View file

@ -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}`);