Skip to content

Commit 1687729

Browse files
authored
fix(export): honor --functions for json and graphson formats (#2141)
* fix(export): honor --functions for json and graphson formats exportJSON and exportGraphSON ignored opts.fileLevel entirely, always returning file-level (json) or a fixed all-kinds (graphson) shape regardless of --functions. dot/mermaid/graphml/neo4j already branched on fileLevel correctly. Closes #2136 Impact: 2 functions changed, 2 affected * docs: add dogfood report for v3.16.0 * docs(dogfood-skill): fix stale benchmark script refs, add safety notes from v3.16.0 session - benchmark scripts are .ts, not .js; note npm run benchmark alias and git fetch --tags requirement for correct version labeling - warn against running registry prune --ttl 0 against the real global registry; recommend CODEGRAPH_REGISTRY_PATH isolation instead - warn about update-graph.sh hook contamination risk from scratch files with tracked extensions - recommend copying .codegraphrc.json aside before editing it in place and testing config-driven commands from a different cwd than the target - recommend full edge-set diffs (not just counts) for incremental rebuild verification — this is what caught #2138 in the v3.16.0 session * Revert "docs(dogfood-skill): fix stale benchmark script refs, add safety notes from v3.16.0 session" This reverts commit 14ce543. * Revert "docs: add dogfood report for v3.16.0" This reverts commit ad7c2b0. * fix: close db handle in exportJSON function-level test to match sibling tests (#2141) * fix(export): align exportGraphSON function-level scope with other formats The function-level (--functions) branch retained pre-existing behavior that queried all non-file nodes independently of edges (including isolated nodes with no calls) and included every edge kind (inherits, implements, imports, etc.), not just calls. dot/mermaid/graphml/neo4j all already use loadFunctionLevelEdges for this scope. Now graphson does too, so all five function-level exporters agree on scope: calls edges only, nodes limited to those participating in at least one call. Impact: 1 functions changed, 2 affected
1 parent 8147ce1 commit 1687729

2 files changed

Lines changed: 236 additions & 73 deletions

File tree

src/features/export.ts

Lines changed: 163 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -331,26 +331,73 @@ export function exportJSON(
331331
db: BetterSqlite3Database,
332332
opts: ExportOpts = {},
333333
): { nodes: unknown[]; edges: unknown[] } {
334+
const fileLevel = opts.fileLevel !== false;
334335
const noTests = opts.noTests || false;
335336
const minConf = opts.minConfidence ?? DEFAULT_MIN_CONFIDENCE;
336337

337-
let nodes = db
338-
.prepare(`
339-
SELECT id, name, kind, file, line FROM nodes WHERE kind = 'file'
340-
`)
341-
.all() as Array<{ id: number; name: string; kind: string; file: string; line: number }>;
342-
if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
343-
344-
let edges = db
345-
.prepare(`
346-
SELECT DISTINCT n1.file AS source, n2.file AS target, e.kind, e.confidence
347-
FROM edges e
348-
JOIN nodes n1 ON e.source_id = n1.id
349-
JOIN nodes n2 ON e.target_id = n2.id
350-
WHERE n1.file != n2.file AND e.confidence >= ?
351-
`)
352-
.all(minConf) as Array<{ source: string; target: string; kind: string; confidence: number }>;
353-
if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target));
338+
if (fileLevel) {
339+
let nodes = db
340+
.prepare(`
341+
SELECT id, name, kind, file, line FROM nodes WHERE kind = 'file'
342+
`)
343+
.all() as Array<{ id: number; name: string; kind: string; file: string; line: number }>;
344+
if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
345+
346+
let edges = db
347+
.prepare(`
348+
SELECT DISTINCT n1.file AS source, n2.file AS target, e.kind, e.confidence
349+
FROM edges e
350+
JOIN nodes n1 ON e.source_id = n1.id
351+
JOIN nodes n2 ON e.target_id = n2.id
352+
WHERE n1.file != n2.file AND e.confidence >= ?
353+
`)
354+
.all(minConf) as Array<{ source: string; target: string; kind: string; confidence: number }>;
355+
if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target));
356+
357+
const base = { nodes, edges };
358+
return paginateResult(base, 'edges', { limit: opts.limit, offset: opts.offset }) as {
359+
nodes: unknown[];
360+
edges: unknown[];
361+
};
362+
}
363+
364+
const { edges: fnEdges } = loadFunctionLevelEdges(db, {
365+
noTests,
366+
minConfidence: opts.minConfidence,
367+
});
368+
const nodeMap = new Map<
369+
number,
370+
{ id: number; name: string; kind: string; file: string; line: number; role: string | null }
371+
>();
372+
for (const e of fnEdges) {
373+
if (!nodeMap.has(e.source_id)) {
374+
nodeMap.set(e.source_id, {
375+
id: e.source_id,
376+
name: e.source_name,
377+
kind: e.source_kind,
378+
file: e.source_file,
379+
line: e.source_line,
380+
role: e.source_role,
381+
});
382+
}
383+
if (!nodeMap.has(e.target_id)) {
384+
nodeMap.set(e.target_id, {
385+
id: e.target_id,
386+
name: e.target_name,
387+
kind: e.target_kind,
388+
file: e.target_file,
389+
line: e.target_line,
390+
role: e.target_role,
391+
});
392+
}
393+
}
394+
const nodes = [...nodeMap.values()];
395+
const edges = fnEdges.map((e) => ({
396+
source: e.source_id,
397+
target: e.target_id,
398+
kind: e.edge_kind,
399+
confidence: e.confidence,
400+
}));
354401

355402
const base = { nodes, edges };
356403
return paginateResult(base, 'edges', { limit: opts.limit, offset: opts.offset }) as {
@@ -384,64 +431,110 @@ export function exportGraphSON(
384431
db: BetterSqlite3Database,
385432
opts: ExportOpts = {},
386433
): { vertices: unknown[]; edges: unknown[] } {
434+
const fileLevel = opts.fileLevel !== false;
387435
const noTests = opts.noTests || false;
388-
const minConf = opts.minConfidence ?? DEFAULT_MIN_CONFIDENCE;
389436

390-
let nodes = db
391-
.prepare(`
392-
SELECT id, name, kind, file, line, role FROM nodes
393-
WHERE kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module', 'constant', 'file')
394-
`)
395-
.all() as Array<{
396-
id: number;
397-
name: string;
398-
kind: string;
399-
file: string;
400-
line: number | null;
401-
role: string | null;
402-
}>;
403-
if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
404-
405-
let edges = db
406-
.prepare(`
407-
SELECT e.rowid AS id, n1.id AS outV, n2.id AS inV, e.kind, e.confidence
408-
FROM edges e
409-
JOIN nodes n1 ON e.source_id = n1.id
410-
JOIN nodes n2 ON e.target_id = n2.id
411-
WHERE e.confidence >= ?
412-
`)
413-
.all(minConf) as Array<{
414-
id: number;
415-
outV: number;
416-
inV: number;
417-
kind: string;
418-
confidence: number;
437+
let vertices: Array<{ id: unknown; label: string; properties: Record<string, unknown> }>;
438+
let gEdges: Array<{
439+
id: unknown;
440+
label: string;
441+
inV: unknown;
442+
outV: unknown;
443+
properties: Record<string, unknown>;
419444
}>;
420-
if (noTests) {
421-
const nodeIds = new Set(nodes.map((n) => n.id));
422-
edges = edges.filter((e) => nodeIds.has(e.outV) && nodeIds.has(e.inV));
423-
}
424445

425-
const vertices = nodes.map((n) => ({
426-
id: n.id,
427-
label: n.kind,
428-
properties: {
429-
name: [{ id: 0, value: n.name }],
430-
file: [{ id: 0, value: n.file }],
431-
...(n.line != null ? { line: [{ id: 0, value: n.line }] } : {}),
432-
...(n.role ? { role: [{ id: 0, value: n.role }] } : {}),
433-
},
434-
}));
446+
if (fileLevel) {
447+
const { edges: fileEdges } = loadFileLevelEdges(db, {
448+
noTests,
449+
minConfidence: opts.minConfidence,
450+
includeKind: true,
451+
includeConfidence: true,
452+
});
453+
const filesInvolved = new Set<string>();
454+
for (const e of fileEdges) {
455+
filesInvolved.add(e.source);
456+
filesInvolved.add(e.target);
457+
}
458+
const fileNodes = db
459+
.prepare(`SELECT id, name, file, line FROM nodes WHERE kind = 'file'`)
460+
.all() as Array<{ id: number; name: string; file: string; line: number | null }>;
461+
const idByFile = new Map(
462+
fileNodes.filter((n) => filesInvolved.has(n.file)).map((n) => [n.file, n]),
463+
);
464+
465+
vertices = [...idByFile.values()].map((n) => ({
466+
id: n.id,
467+
label: 'file',
468+
properties: {
469+
name: [{ id: 0, value: n.name }],
470+
file: [{ id: 0, value: n.file }],
471+
...(n.line != null ? { line: [{ id: 0, value: n.line }] } : {}),
472+
},
473+
}));
435474

436-
const gEdges = edges.map((e) => ({
437-
id: e.id,
438-
label: e.kind,
439-
inV: e.inV,
440-
outV: e.outV,
441-
properties: {
442-
confidence: e.confidence,
443-
},
444-
}));
475+
gEdges = fileEdges
476+
.filter((e) => idByFile.has(e.source) && idByFile.has(e.target))
477+
.map((e, i) => ({
478+
id: i,
479+
label: e.edge_kind ?? 'edge',
480+
inV: idByFile.get(e.target)?.id,
481+
outV: idByFile.get(e.source)?.id,
482+
properties: { confidence: e.confidence },
483+
}));
484+
} else {
485+
const { edges: fnEdges } = loadFunctionLevelEdges(db, {
486+
noTests,
487+
minConfidence: opts.minConfidence,
488+
});
489+
490+
const nodeMap = new Map<
491+
number,
492+
{ id: number; name: string; kind: string; file: string; line: number; role: string | null }
493+
>();
494+
for (const e of fnEdges) {
495+
if (!nodeMap.has(e.source_id)) {
496+
nodeMap.set(e.source_id, {
497+
id: e.source_id,
498+
name: e.source_name,
499+
kind: e.source_kind,
500+
file: e.source_file,
501+
line: e.source_line,
502+
role: e.source_role,
503+
});
504+
}
505+
if (!nodeMap.has(e.target_id)) {
506+
nodeMap.set(e.target_id, {
507+
id: e.target_id,
508+
name: e.target_name,
509+
kind: e.target_kind,
510+
file: e.target_file,
511+
line: e.target_line,
512+
role: e.target_role,
513+
});
514+
}
515+
}
516+
517+
vertices = [...nodeMap.values()].map((n) => ({
518+
id: n.id,
519+
label: n.kind,
520+
properties: {
521+
name: [{ id: 0, value: n.name }],
522+
file: [{ id: 0, value: n.file }],
523+
...(n.line != null ? { line: [{ id: 0, value: n.line }] } : {}),
524+
...(n.role ? { role: [{ id: 0, value: n.role }] } : {}),
525+
},
526+
}));
527+
528+
gEdges = fnEdges.map((e, i) => ({
529+
id: i,
530+
label: e.edge_kind,
531+
inV: e.target_id,
532+
outV: e.source_id,
533+
properties: {
534+
confidence: e.confidence,
535+
},
536+
}));
537+
}
445538

446539
const base = { vertices, edges: gEdges };
447540
return paginateResult(base, 'edges', { limit: opts.limit, offset: opts.offset }) as {

tests/graph/export.test.ts

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,35 @@ describe('exportJSON', () => {
258258
expect(data.edges.length).toBeGreaterThanOrEqual(1);
259259
db.close();
260260
});
261+
262+
it('returns function-level nodes and edges with fileLevel: false', () => {
263+
const db = createTestDb();
264+
const fn = insertNode(db, 'doWork', 'function', 'src/a.js', 5);
265+
const fn2 = insertNode(db, 'helper', 'function', 'src/b.js', 10);
266+
insertEdge(db, fn, fn2, 'calls');
267+
268+
const data = exportJSON(db, { fileLevel: false });
269+
expect(data.nodes.every((n) => n.kind !== 'file')).toBe(true);
270+
expect(data.nodes.some((n) => n.name === 'doWork')).toBe(true);
271+
expect(data.edges.some((e) => e.source === fn && e.target === fn2)).toBe(true);
272+
db.close();
273+
});
274+
275+
it('produces different output for fileLevel vs functions', () => {
276+
const db = createTestDb();
277+
const a = insertNode(db, 'src/a.js', 'file', 'src/a.js', 0);
278+
const b = insertNode(db, 'src/b.js', 'file', 'src/b.js', 0);
279+
insertEdge(db, a, b, 'imports');
280+
const fn = insertNode(db, 'doWork', 'function', 'src/a.js', 5);
281+
const fn2 = insertNode(db, 'helper', 'function', 'src/b.js', 10);
282+
insertEdge(db, fn, fn2, 'calls');
283+
284+
const fileLevel = exportJSON(db);
285+
const functionLevel = exportJSON(db, { fileLevel: false });
286+
expect(fileLevel.nodes.every((n) => n.kind === 'file')).toBe(true);
287+
expect(functionLevel.nodes.some((n) => n.kind === 'function')).toBe(true);
288+
db.close();
289+
});
261290
});
262291

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

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

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

390-
const data = exportGraphSON(db);
419+
const data = exportGraphSON(db, { fileLevel: false });
391420
const edge = data.edges[0];
392421
expect(edge.properties).toHaveProperty('confidence');
393422
expect(edge.properties.confidence).toBe(1.0);
394423
db.close();
395424
});
425+
426+
it('produces different output for fileLevel vs functions', () => {
427+
const db = createTestDb();
428+
const a = insertNode(db, 'src/a.js', 'file', 'src/a.js', 0);
429+
const b = insertNode(db, 'src/b.js', 'file', 'src/b.js', 0);
430+
insertEdge(db, a, b, 'imports');
431+
const fn = insertNode(db, 'doWork', 'function', 'src/a.js', 5);
432+
const fn2 = insertNode(db, 'helper', 'function', 'src/b.js', 10);
433+
insertEdge(db, fn, fn2, 'calls');
434+
435+
const fileLevel = exportGraphSON(db);
436+
const functionLevel = exportGraphSON(db, { fileLevel: false });
437+
expect(fileLevel.vertices.every((v) => v.label === 'file')).toBe(true);
438+
expect(functionLevel.vertices.some((v) => v.label === 'function')).toBe(true);
439+
expect(functionLevel.vertices.every((v) => v.label !== 'file')).toBe(true);
440+
db.close();
441+
});
442+
443+
it('function-level matches loadFunctionLevelEdges semantics: calls-only edges, no isolated nodes', () => {
444+
const db = createTestDb();
445+
const fnA = insertNode(db, 'doWork', 'function', 'src/a.js', 5);
446+
const fnB = insertNode(db, 'helper', 'function', 'src/b.js', 10);
447+
insertEdge(db, fnA, fnB, 'calls');
448+
// A class that implements/extends another — should NOT appear as a graphson edge
449+
// in function-level output, matching dot/mermaid/graphml/neo4j's calls-only scope.
450+
const base = insertNode(db, 'Base', 'class', 'src/c.js', 1);
451+
const impl = insertNode(db, 'Impl', 'class', 'src/d.js', 1);
452+
insertEdge(db, impl, base, 'implements');
453+
// An isolated function with no edges at all — should not appear as a vertex,
454+
// matching loadFunctionLevelEdges (which only returns nodes that participate
455+
// in a calls edge), not an independent "all matching-kind nodes" query.
456+
insertNode(db, 'unreachable', 'function', 'src/e.js', 1);
457+
458+
const data = exportGraphSON(db, { fileLevel: false });
459+
460+
expect(data.edges.every((e) => e.label === 'calls')).toBe(true);
461+
expect(data.vertices.some((v) => v.properties.name[0].value === 'unreachable')).toBe(false);
462+
expect(data.vertices.some((v) => v.properties.name[0].value === 'Impl')).toBe(false);
463+
expect(data.vertices.some((v) => v.properties.name[0].value === 'doWork')).toBe(true);
464+
db.close();
465+
});
396466
});
397467

398468
describe('exportNeo4jCSV', () => {

0 commit comments

Comments
 (0)