mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-23 00:41:36 +00:00
feat(cli): add export command with multi-format graph output
This commit is contained in:
parent
fdf1effb2a
commit
949777e081
8 changed files with 985 additions and 0 deletions
|
|
@ -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
153
gitnexus/src/cli/export.ts
Normal 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}`);
|
||||
}
|
||||
};
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
195
gitnexus/test/integration/export-smoke.test.ts
Normal file
195
gitnexus/test/integration/export-smoke.test.ts
Normal 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);
|
||||
});
|
||||
498
gitnexus/test/unit/cli/export.test.ts
Normal file
498
gitnexus/test/unit/cli/export.test.ts
Normal 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`\)/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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}`);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue