Skip to content
Merged
233 changes: 163 additions & 70 deletions src/features/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,26 +331,73 @@ export function exportJSON(
db: BetterSqlite3Database,
opts: ExportOpts = {},
): { nodes: unknown[]; edges: unknown[] } {
const fileLevel = opts.fileLevel !== false;
const noTests = opts.noTests || false;
const minConf = opts.minConfidence ?? DEFAULT_MIN_CONFIDENCE;

let nodes = db
.prepare(`
SELECT id, name, kind, file, line FROM nodes WHERE kind = 'file'
`)
.all() as Array<{ id: number; name: string; kind: string; file: string; line: number }>;
if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));

let edges = db
.prepare(`
SELECT DISTINCT n1.file AS source, n2.file AS target, e.kind, e.confidence
FROM edges e
JOIN nodes n1 ON e.source_id = n1.id
JOIN nodes n2 ON e.target_id = n2.id
WHERE n1.file != n2.file AND e.confidence >= ?
`)
.all(minConf) as Array<{ source: string; target: string; kind: string; confidence: number }>;
if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target));
if (fileLevel) {
let nodes = db
.prepare(`
SELECT id, name, kind, file, line FROM nodes WHERE kind = 'file'
`)
.all() as Array<{ id: number; name: string; kind: string; file: string; line: number }>;
if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));

let edges = db
.prepare(`
SELECT DISTINCT n1.file AS source, n2.file AS target, e.kind, e.confidence
FROM edges e
JOIN nodes n1 ON e.source_id = n1.id
JOIN nodes n2 ON e.target_id = n2.id
WHERE n1.file != n2.file AND e.confidence >= ?
`)
.all(minConf) as Array<{ source: string; target: string; kind: string; confidence: number }>;
if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target));

const base = { nodes, edges };
return paginateResult(base, 'edges', { limit: opts.limit, offset: opts.offset }) as {
nodes: unknown[];
edges: unknown[];
};
}

const { edges: fnEdges } = loadFunctionLevelEdges(db, {
noTests,
minConfidence: opts.minConfidence,
});
const nodeMap = new Map<
number,
{ id: number; name: string; kind: string; file: string; line: number; role: string | null }
>();
for (const e of fnEdges) {
if (!nodeMap.has(e.source_id)) {
nodeMap.set(e.source_id, {
id: e.source_id,
name: e.source_name,
kind: e.source_kind,
file: e.source_file,
line: e.source_line,
role: e.source_role,
});
}
if (!nodeMap.has(e.target_id)) {
nodeMap.set(e.target_id, {
id: e.target_id,
name: e.target_name,
kind: e.target_kind,
file: e.target_file,
line: e.target_line,
role: e.target_role,
});
}
}
const nodes = [...nodeMap.values()];
const edges = fnEdges.map((e) => ({
source: e.source_id,
target: e.target_id,
kind: e.edge_kind,
confidence: e.confidence,
}));

const base = { nodes, edges };
return paginateResult(base, 'edges', { limit: opts.limit, offset: opts.offset }) as {
Expand Down Expand Up @@ -384,64 +431,110 @@ export function exportGraphSON(
db: BetterSqlite3Database,
opts: ExportOpts = {},
): { vertices: unknown[]; edges: unknown[] } {
const fileLevel = opts.fileLevel !== false;
const noTests = opts.noTests || false;
const minConf = opts.minConfidence ?? DEFAULT_MIN_CONFIDENCE;

let nodes = db
.prepare(`
SELECT id, name, kind, file, line, role FROM nodes
WHERE kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module', 'constant', 'file')
`)
.all() as Array<{
id: number;
name: string;
kind: string;
file: string;
line: number | null;
role: string | null;
}>;
if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));

let edges = db
.prepare(`
SELECT e.rowid AS id, n1.id AS outV, n2.id AS inV, e.kind, e.confidence
FROM edges e
JOIN nodes n1 ON e.source_id = n1.id
JOIN nodes n2 ON e.target_id = n2.id
WHERE e.confidence >= ?
`)
.all(minConf) as Array<{
id: number;
outV: number;
inV: number;
kind: string;
confidence: number;
let vertices: Array<{ id: unknown; label: string; properties: Record<string, unknown> }>;
let gEdges: Array<{
id: unknown;
label: string;
inV: unknown;
outV: unknown;
properties: Record<string, unknown>;
}>;
if (noTests) {
const nodeIds = new Set(nodes.map((n) => n.id));
edges = edges.filter((e) => nodeIds.has(e.outV) && nodeIds.has(e.inV));
}

const vertices = nodes.map((n) => ({
id: n.id,
label: n.kind,
properties: {
name: [{ id: 0, value: n.name }],
file: [{ id: 0, value: n.file }],
...(n.line != null ? { line: [{ id: 0, value: n.line }] } : {}),
...(n.role ? { role: [{ id: 0, value: n.role }] } : {}),
},
}));
if (fileLevel) {
const { edges: fileEdges } = loadFileLevelEdges(db, {
noTests,
minConfidence: opts.minConfidence,
includeKind: true,
includeConfidence: true,
});
const filesInvolved = new Set<string>();
for (const e of fileEdges) {
filesInvolved.add(e.source);
filesInvolved.add(e.target);
}
const fileNodes = db
.prepare(`SELECT id, name, file, line FROM nodes WHERE kind = 'file'`)
.all() as Array<{ id: number; name: string; file: string; line: number | null }>;
const idByFile = new Map(
fileNodes.filter((n) => filesInvolved.has(n.file)).map((n) => [n.file, n]),
);

vertices = [...idByFile.values()].map((n) => ({
id: n.id,
label: 'file',
properties: {
name: [{ id: 0, value: n.name }],
file: [{ id: 0, value: n.file }],
...(n.line != null ? { line: [{ id: 0, value: n.line }] } : {}),
},
}));

const gEdges = edges.map((e) => ({
id: e.id,
label: e.kind,
inV: e.inV,
outV: e.outV,
properties: {
confidence: e.confidence,
},
}));
gEdges = fileEdges
.filter((e) => idByFile.has(e.source) && idByFile.has(e.target))
.map((e, i) => ({
id: i,
label: e.edge_kind ?? 'edge',
inV: idByFile.get(e.target)?.id,
outV: idByFile.get(e.source)?.id,
properties: { confidence: e.confidence },
}));
} else {
const { edges: fnEdges } = loadFunctionLevelEdges(db, {
noTests,
minConfidence: opts.minConfidence,
});

const nodeMap = new Map<
number,
{ id: number; name: string; kind: string; file: string; line: number; role: string | null }
>();
for (const e of fnEdges) {
if (!nodeMap.has(e.source_id)) {
nodeMap.set(e.source_id, {
id: e.source_id,
name: e.source_name,
kind: e.source_kind,
file: e.source_file,
line: e.source_line,
role: e.source_role,
});
}
if (!nodeMap.has(e.target_id)) {
nodeMap.set(e.target_id, {
id: e.target_id,
name: e.target_name,
kind: e.target_kind,
file: e.target_file,
line: e.target_line,
role: e.target_role,
});
}
}

vertices = [...nodeMap.values()].map((n) => ({
id: n.id,
label: n.kind,
properties: {
name: [{ id: 0, value: n.name }],
file: [{ id: 0, value: n.file }],
...(n.line != null ? { line: [{ id: 0, value: n.line }] } : {}),
...(n.role ? { role: [{ id: 0, value: n.role }] } : {}),
},
}));

gEdges = fnEdges.map((e, i) => ({
id: i,
label: e.edge_kind,
inV: e.target_id,
outV: e.source_id,
properties: {
confidence: e.confidence,
},
}));
}

const base = { vertices, edges: gEdges };
return paginateResult(base, 'edges', { limit: opts.limit, offset: opts.offset }) as {
Expand Down
76 changes: 73 additions & 3 deletions tests/graph/export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,35 @@ describe('exportJSON', () => {
expect(data.edges.length).toBeGreaterThanOrEqual(1);
db.close();
});

it('returns function-level nodes and edges with fileLevel: false', () => {
const db = createTestDb();
const fn = insertNode(db, 'doWork', 'function', 'src/a.js', 5);
const fn2 = insertNode(db, 'helper', 'function', 'src/b.js', 10);
insertEdge(db, fn, fn2, 'calls');

const data = exportJSON(db, { fileLevel: false });
expect(data.nodes.every((n) => n.kind !== 'file')).toBe(true);
expect(data.nodes.some((n) => n.name === 'doWork')).toBe(true);
expect(data.edges.some((e) => e.source === fn && e.target === fn2)).toBe(true);
db.close();
});
Comment on lines +268 to +273

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing db.close() in the first new JSON test leaves an open in-memory database handle. Every surrounding test does close its db; this one should too.

Suggested change
const data = exportJSON(db, { fileLevel: false });
expect(data.nodes.every((n) => n.kind !== 'file')).toBe(true);
expect(data.nodes.some((n) => n.name === 'doWork')).toBe(true);
expect(data.edges.some((e) => e.source === fn && e.target === fn2)).toBe(true);
});
const data = exportJSON(db, { fileLevel: false });
expect(data.nodes.every((n) => n.kind !== 'file')).toBe(true);
expect(data.nodes.some((n) => n.name === 'doWork')).toBe(true);
expect(data.edges.some((e) => e.source === fn && e.target === fn2)).toBe(true);
db.close();
});

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — added the missing db.close() to this test to match its sibling tests.


it('produces different output for fileLevel vs functions', () => {
const db = createTestDb();
const a = insertNode(db, 'src/a.js', 'file', 'src/a.js', 0);
const b = insertNode(db, 'src/b.js', 'file', 'src/b.js', 0);
insertEdge(db, a, b, 'imports');
const fn = insertNode(db, 'doWork', 'function', 'src/a.js', 5);
const fn2 = insertNode(db, 'helper', 'function', 'src/b.js', 10);
insertEdge(db, fn, fn2, 'calls');

const fileLevel = exportJSON(db);
const functionLevel = exportJSON(db, { fileLevel: false });
expect(fileLevel.nodes.every((n) => n.kind === 'file')).toBe(true);
expect(functionLevel.nodes.some((n) => n.kind === 'function')).toBe(true);
db.close();
});
});

describe('exportGraphML', () => {
Expand Down Expand Up @@ -357,7 +386,7 @@ describe('exportGraphSON', () => {
const fn2 = insertNode(db, 'helper', 'function', 'src/b.js', 10);
insertEdge(db, fn, fn2, 'calls');

const data = exportGraphSON(db);
const data = exportGraphSON(db, { fileLevel: false });
const vertex = data.vertices.find((v) => v.properties.name[0].value === 'doWork');
expect(vertex).toBeDefined();
expect(vertex.properties.name).toEqual([{ id: 0, value: 'doWork' }]);
Expand All @@ -371,7 +400,7 @@ describe('exportGraphSON', () => {
const fn2 = insertNode(db, 'helper', 'function', 'src/b.js', 10);
insertEdge(db, fn, fn2, 'calls');

const data = exportGraphSON(db);
const data = exportGraphSON(db, { fileLevel: false });
expect(data.edges.length).toBeGreaterThanOrEqual(1);
const edge = data.edges[0];
expect(edge).toHaveProperty('inV');
Expand All @@ -387,12 +416,53 @@ describe('exportGraphSON', () => {
const fn2 = insertNode(db, 'helper', 'function', 'src/b.js', 10);
insertEdge(db, fn, fn2, 'calls');

const data = exportGraphSON(db);
const data = exportGraphSON(db, { fileLevel: false });
const edge = data.edges[0];
expect(edge.properties).toHaveProperty('confidence');
expect(edge.properties.confidence).toBe(1.0);
db.close();
});

it('produces different output for fileLevel vs functions', () => {
const db = createTestDb();
const a = insertNode(db, 'src/a.js', 'file', 'src/a.js', 0);
const b = insertNode(db, 'src/b.js', 'file', 'src/b.js', 0);
insertEdge(db, a, b, 'imports');
const fn = insertNode(db, 'doWork', 'function', 'src/a.js', 5);
const fn2 = insertNode(db, 'helper', 'function', 'src/b.js', 10);
insertEdge(db, fn, fn2, 'calls');

const fileLevel = exportGraphSON(db);
const functionLevel = exportGraphSON(db, { fileLevel: false });
expect(fileLevel.vertices.every((v) => v.label === 'file')).toBe(true);
expect(functionLevel.vertices.some((v) => v.label === 'function')).toBe(true);
expect(functionLevel.vertices.every((v) => v.label !== 'file')).toBe(true);
db.close();
});

it('function-level matches loadFunctionLevelEdges semantics: calls-only edges, no isolated nodes', () => {
const db = createTestDb();
const fnA = insertNode(db, 'doWork', 'function', 'src/a.js', 5);
const fnB = insertNode(db, 'helper', 'function', 'src/b.js', 10);
insertEdge(db, fnA, fnB, 'calls');
// A class that implements/extends another — should NOT appear as a graphson edge
// in function-level output, matching dot/mermaid/graphml/neo4j's calls-only scope.
const base = insertNode(db, 'Base', 'class', 'src/c.js', 1);
const impl = insertNode(db, 'Impl', 'class', 'src/d.js', 1);
insertEdge(db, impl, base, 'implements');
// An isolated function with no edges at all — should not appear as a vertex,
// matching loadFunctionLevelEdges (which only returns nodes that participate
// in a calls edge), not an independent "all matching-kind nodes" query.
insertNode(db, 'unreachable', 'function', 'src/e.js', 1);

const data = exportGraphSON(db, { fileLevel: false });

expect(data.edges.every((e) => e.label === 'calls')).toBe(true);
expect(data.vertices.some((v) => v.properties.name[0].value === 'unreachable')).toBe(false);
expect(data.vertices.some((v) => v.properties.name[0].value === 'Impl')).toBe(false);
expect(data.vertices.some((v) => v.properties.name[0].value === 'doWork')).toBe(true);
db.close();
});
});

describe('exportNeo4jCSV', () => {
Expand Down
Loading