fix(graph): complete edge sets & correct node limits in traversal (#1086, #1087, #1088, #1089, #1090)
Three root defects in src/graph/traversal.ts (reported by @inth3shadows as #1086–#1090): - Depth guard returned before visited.add → duplicate callers/callees at maxDepth=1 and getImpact loop disagreement. - Dedup gate also gated edge collection → traverseBFS dropped a parallel edge; getImpact dropped a direct incoming dependency edge. - limit checked per-frame not per-add → high-degree node overshot opts.limit in traverseBFS and dfsRecursive. traverseBFS now collects every distinct edge among kept nodes (deduped on edge identity), enqueues each node once, and caps per-add. getCallers/getCallees/getImpactRecursive mark visited before the depth check; getImpactRecursive records the incoming edge unconditionally and unifies its loops on visited. 7 regression tests in graph.test.ts, each failing on the pre-fix code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ed39233f1a
commit
43a6fa68f6
@@ -10,6 +10,7 @@ import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import CodeGraph from '../src/index';
|
||||
import { Node, Edge } from '../src/types';
|
||||
import { GraphTraverser } from '../src/graph/traversal';
|
||||
|
||||
describe('Graph Queries', () => {
|
||||
let testDir: string;
|
||||
@@ -486,3 +487,129 @@ export { main };
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Traversal edge-completeness & node-limit regressions (#1086–#1090)
|
||||
//
|
||||
// These drive GraphTraverser directly against an in-memory graph (the same
|
||||
// approach the reporter used), so the exact parallel-edge / high-degree
|
||||
// topologies can be constructed deterministically without round-tripping
|
||||
// through extraction.
|
||||
// =============================================================================
|
||||
|
||||
/** Minimal Node stub — the traversal code only reads id/kind/name. */
|
||||
function tNode(id: string, kind: Node['kind'] = 'function'): Node {
|
||||
return {
|
||||
id,
|
||||
kind,
|
||||
name: id,
|
||||
qualifiedName: id,
|
||||
filePath: `src/${id}.ts`,
|
||||
language: 'typescript',
|
||||
startLine: 1,
|
||||
endLine: 10,
|
||||
startColumn: 0,
|
||||
endColumn: 0,
|
||||
} as unknown as Node;
|
||||
}
|
||||
|
||||
/** Build a GraphTraverser over a fixed node/edge set, honoring the `kinds` filter. */
|
||||
function tGraph(nodes: Node[], edges: Edge[]): GraphTraverser {
|
||||
const byId = new Map(nodes.map((n) => [n.id, n]));
|
||||
const q = {
|
||||
getNodeById: (id: string) => byId.get(id) ?? null,
|
||||
getNodesByIds: (ids: readonly string[]) => {
|
||||
const m = new Map<string, Node>();
|
||||
for (const id of ids) {
|
||||
const n = byId.get(id);
|
||||
if (n) m.set(id, n);
|
||||
}
|
||||
return m;
|
||||
},
|
||||
getOutgoingEdges: (source: string, kinds?: string[]) =>
|
||||
edges.filter((e) => e.source === source && (!kinds || kinds.includes(e.kind))),
|
||||
getIncomingEdges: (target: string, kinds?: string[]) =>
|
||||
edges.filter((e) => e.target === target && (!kinds || kinds.includes(e.kind))),
|
||||
};
|
||||
return new GraphTraverser(q as never);
|
||||
}
|
||||
|
||||
describe('Traversal edge-completeness & limits (#1086–#1090)', () => {
|
||||
it('traverseBFS keeps every parallel edge to the same target (#1090)', () => {
|
||||
// A reaches B via both `calls` and `references` — two distinct edges.
|
||||
const edges: Edge[] = [
|
||||
{ source: 'A', target: 'B', kind: 'calls', line: 1 },
|
||||
{ source: 'A', target: 'B', kind: 'references', line: 2 },
|
||||
];
|
||||
const sub = tGraph([tNode('A'), tNode('B')], edges).traverseBFS('A', { direction: 'outgoing' });
|
||||
|
||||
const ab = sub.edges.filter((e) => e.source === 'A' && e.target === 'B');
|
||||
// Pre-fix: only the higher-priority `calls` edge survived; `references` was dropped.
|
||||
expect(ab.map((e) => e.kind).sort()).toEqual(['calls', 'references']);
|
||||
expect(sub.nodes.has('B')).toBe(true);
|
||||
});
|
||||
|
||||
it('traverseBFS keeps two same-kind edges on different lines (#1090)', () => {
|
||||
const edges: Edge[] = [
|
||||
{ source: 'A', target: 'B', kind: 'calls', line: 3 },
|
||||
{ source: 'A', target: 'B', kind: 'calls', line: 7 },
|
||||
];
|
||||
const sub = tGraph([tNode('A'), tNode('B')], edges).traverseBFS('A', { direction: 'outgoing' });
|
||||
expect(sub.edges.filter((e) => e.source === 'A' && e.target === 'B')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('traverseBFS does not overshoot opts.limit on a high-degree node (#1087)', () => {
|
||||
const neighbors = ['B', 'C', 'D', 'E', 'F'];
|
||||
const nodes = [tNode('A'), ...neighbors.map((n) => tNode(n))];
|
||||
const edges: Edge[] = neighbors.map((n) => ({ source: 'A', target: n, kind: 'calls' as const }));
|
||||
const sub = tGraph(nodes, edges).traverseBFS('A', { limit: 3, direction: 'outgoing' });
|
||||
// Pre-fix: all 5 neighbors were added in one pass → 6 nodes despite limit 3.
|
||||
expect(sub.nodes.size).toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('traverseDFS does not overshoot opts.limit on a high-degree node (#1088)', () => {
|
||||
const neighbors = ['B', 'C', 'D', 'E', 'F'];
|
||||
const nodes = [tNode('A'), ...neighbors.map((n) => tNode(n))];
|
||||
const edges: Edge[] = neighbors.map((n) => ({ source: 'A', target: n, kind: 'calls' as const }));
|
||||
const sub = tGraph(nodes, edges).traverseDFS('A', { limit: 2, direction: 'outgoing' });
|
||||
expect(sub.nodes.size).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('getCallers returns each caller once when reached via multiple edges (#1086)', () => {
|
||||
// Y calls X at two sites and also references it — three incoming edges.
|
||||
const edges: Edge[] = [
|
||||
{ source: 'Y', target: 'X', kind: 'calls', line: 1 },
|
||||
{ source: 'Y', target: 'X', kind: 'calls', line: 2 },
|
||||
{ source: 'Y', target: 'X', kind: 'references', line: 3 },
|
||||
];
|
||||
const callers = tGraph([tNode('X'), tNode('Y')], edges).getCallers('X'); // default maxDepth = 1
|
||||
// Pre-fix: Y appeared three times (depth guard returned before visited.add).
|
||||
expect(callers.map((c) => c.node.id)).toEqual(['Y']);
|
||||
});
|
||||
|
||||
it('getCallees returns each callee once when reached via multiple edges (#1086)', () => {
|
||||
const edges: Edge[] = [
|
||||
{ source: 'X', target: 'Y', kind: 'calls', line: 1 },
|
||||
{ source: 'X', target: 'Y', kind: 'calls', line: 2 },
|
||||
];
|
||||
const callees = tGraph([tNode('X'), tNode('Y')], edges).getCallees('X');
|
||||
expect(callees.map((c) => c.node.id)).toEqual(['Y']);
|
||||
});
|
||||
|
||||
it('getImpactRadius keeps a direct edge into a node already collected via another path (#1089)', () => {
|
||||
// Class P contains method M. Q calls both M and P. Reaching M first collects
|
||||
// Q; the pre-fix `!nodes.has()` gate then dropped the direct Q→P edge.
|
||||
const nodes = [tNode('P', 'class'), tNode('M', 'method'), tNode('Q')];
|
||||
const edges: Edge[] = [
|
||||
{ source: 'P', target: 'M', kind: 'contains' },
|
||||
{ source: 'Q', target: 'M', kind: 'calls', line: 1 },
|
||||
{ source: 'Q', target: 'P', kind: 'calls', line: 2 },
|
||||
];
|
||||
const sub = tGraph(nodes, edges).getImpactRadius('P', 2);
|
||||
|
||||
expect(sub.nodes.has('Q')).toBe(true);
|
||||
expect(sub.edges.some((e) => e.source === 'Q' && e.target === 'M' && e.kind === 'calls')).toBe(true);
|
||||
// The regression: this direct dependency edge used to vanish.
|
||||
expect(sub.edges.some((e) => e.source === 'Q' && e.target === 'P' && e.kind === 'calls')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user