diff --git a/src/features/export.ts b/src/features/export.ts index 9933c0702..c95de7904 100644 --- a/src/features/export.ts +++ b/src/features/export.ts @@ -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 { @@ -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 }>; + let gEdges: Array<{ + id: unknown; + label: string; + inV: unknown; + outV: unknown; + properties: Record; }>; - 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(); + 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 { diff --git a/tests/graph/export.test.ts b/tests/graph/export.test.ts index fa3d4e111..13d2f2495 100644 --- a/tests/graph/export.test.ts +++ b/tests/graph/export.test.ts @@ -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(); + }); + + 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', () => { @@ -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' }]); @@ -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'); @@ -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', () => {