graph.test.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615
  1. /**
  2. * Graph Query Tests
  3. *
  4. * Tests for graph traversal and query functionality.
  5. */
  6. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  7. import * as fs from 'fs';
  8. import * as path from 'path';
  9. import * as os from 'os';
  10. import CodeGraph from '../src/index';
  11. import { Node, Edge } from '../src/types';
  12. import { GraphTraverser } from '../src/graph/traversal';
  13. describe('Graph Queries', () => {
  14. let testDir: string;
  15. let cg: CodeGraph;
  16. beforeEach(async () => {
  17. // Create temp directory
  18. testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-graph-test-'));
  19. // Create test files with relationships
  20. const srcDir = path.join(testDir, 'src');
  21. fs.mkdirSync(srcDir, { recursive: true });
  22. // Create base class
  23. fs.writeFileSync(
  24. path.join(srcDir, 'base.ts'),
  25. `
  26. export class BaseClass {
  27. protected value: number;
  28. constructor(value: number) {
  29. this.value = value;
  30. }
  31. getValue(): number {
  32. return this.value;
  33. }
  34. }
  35. export interface Printable {
  36. print(): void;
  37. }
  38. `
  39. );
  40. // Create derived class
  41. fs.writeFileSync(
  42. path.join(srcDir, 'derived.ts'),
  43. `
  44. import { BaseClass, Printable } from './base';
  45. export class DerivedClass extends BaseClass implements Printable {
  46. private name: string;
  47. constructor(value: number, name: string) {
  48. super(value);
  49. this.name = name;
  50. }
  51. print(): void {
  52. console.log(this.getName(), this.getValue());
  53. }
  54. getName(): string {
  55. return this.name;
  56. }
  57. }
  58. `
  59. );
  60. // Create utility functions
  61. fs.writeFileSync(
  62. path.join(srcDir, 'utils.ts'),
  63. `
  64. export function formatValue(value: number): string {
  65. return value.toFixed(2);
  66. }
  67. export function processValue(value: number): number {
  68. const formatted = formatValue(value);
  69. return parseFloat(formatted);
  70. }
  71. export function doubleValue(value: number): number {
  72. return value * 2;
  73. }
  74. // Unused function (dead code)
  75. function unusedHelper(): void {
  76. console.log('never called');
  77. }
  78. `
  79. );
  80. // Create main file that uses everything
  81. fs.writeFileSync(
  82. path.join(srcDir, 'main.ts'),
  83. `
  84. import { DerivedClass } from './derived';
  85. import { processValue, doubleValue } from './utils';
  86. function main(): void {
  87. const obj = new DerivedClass(10, 'test');
  88. obj.print();
  89. const result = processValue(doubleValue(obj.getValue()));
  90. console.log(result);
  91. }
  92. export { main };
  93. `
  94. );
  95. // Initialize and index
  96. cg = CodeGraph.initSync(testDir, {
  97. config: {
  98. include: ['src/**/*.ts'],
  99. exclude: [],
  100. },
  101. });
  102. await cg.indexAll();
  103. cg.resolveReferences();
  104. });
  105. afterEach(() => {
  106. if (cg) {
  107. cg.destroy();
  108. }
  109. if (fs.existsSync(testDir)) {
  110. fs.rmSync(testDir, { recursive: true, force: true });
  111. }
  112. });
  113. describe('traverse()', () => {
  114. it('should traverse graph from a starting node', () => {
  115. const nodes = cg.getNodesByKind('function');
  116. const mainFunc = nodes.find((n) => n.name === 'main');
  117. if (!mainFunc) {
  118. console.log('main function not found, skipping test');
  119. return;
  120. }
  121. const subgraph = cg.traverse(mainFunc.id, {
  122. maxDepth: 2,
  123. direction: 'outgoing',
  124. });
  125. expect(subgraph.nodes.size).toBeGreaterThan(0);
  126. expect(subgraph.roots).toContain(mainFunc.id);
  127. });
  128. it('should respect maxDepth option', () => {
  129. const nodes = cg.getNodesByKind('function');
  130. const mainFunc = nodes.find((n) => n.name === 'main');
  131. if (!mainFunc) {
  132. return;
  133. }
  134. const shallow = cg.traverse(mainFunc.id, { maxDepth: 1 });
  135. const deep = cg.traverse(mainFunc.id, { maxDepth: 3 });
  136. expect(deep.nodes.size).toBeGreaterThanOrEqual(shallow.nodes.size);
  137. });
  138. it('should support incoming direction', () => {
  139. const nodes = cg.getNodesByKind('function');
  140. const formatValue = nodes.find((n) => n.name === 'formatValue');
  141. if (!formatValue) {
  142. return;
  143. }
  144. const subgraph = cg.traverse(formatValue.id, {
  145. maxDepth: 2,
  146. direction: 'incoming',
  147. });
  148. expect(subgraph.nodes.size).toBeGreaterThan(0);
  149. });
  150. });
  151. describe('getContext()', () => {
  152. it('should return context for a node', () => {
  153. const nodes = cg.getNodesByKind('class');
  154. const derivedClass = nodes.find((n) => n.name === 'DerivedClass');
  155. if (!derivedClass) {
  156. console.log('DerivedClass not found, skipping test');
  157. return;
  158. }
  159. const context = cg.getContext(derivedClass.id);
  160. expect(context.focal).toBeDefined();
  161. expect(context.focal.id).toBe(derivedClass.id);
  162. expect(context.ancestors).toBeDefined();
  163. expect(context.children).toBeDefined();
  164. expect(context.incomingRefs).toBeDefined();
  165. expect(context.outgoingRefs).toBeDefined();
  166. });
  167. it('should throw for non-existent node', () => {
  168. expect(() => cg.getContext('non-existent-id')).toThrow('Node not found');
  169. });
  170. });
  171. describe('getCallGraph()', () => {
  172. it('should return call graph for a function', () => {
  173. const nodes = cg.getNodesByKind('function');
  174. const processValue = nodes.find((n) => n.name === 'processValue');
  175. if (!processValue) {
  176. console.log('processValue not found, skipping test');
  177. return;
  178. }
  179. const callGraph = cg.getCallGraph(processValue.id, 2);
  180. expect(callGraph.nodes.size).toBeGreaterThan(0);
  181. expect(callGraph.nodes.has(processValue.id)).toBe(true);
  182. });
  183. });
  184. describe('getTypeHierarchy()', () => {
  185. it('should return type hierarchy for a class', () => {
  186. const nodes = cg.getNodesByKind('class');
  187. const derivedClass = nodes.find((n) => n.name === 'DerivedClass');
  188. if (!derivedClass) {
  189. return;
  190. }
  191. const hierarchy = cg.getTypeHierarchy(derivedClass.id);
  192. expect(hierarchy.nodes.size).toBeGreaterThan(0);
  193. expect(hierarchy.nodes.has(derivedClass.id)).toBe(true);
  194. });
  195. it('should return empty subgraph for non-existent node', () => {
  196. const hierarchy = cg.getTypeHierarchy('non-existent-id');
  197. expect(hierarchy.nodes.size).toBe(0);
  198. expect(hierarchy.edges.length).toBe(0);
  199. });
  200. });
  201. describe('findUsages()', () => {
  202. it('should find usages of a symbol', () => {
  203. const nodes = cg.getNodesByKind('class');
  204. const baseClass = nodes.find((n) => n.name === 'BaseClass');
  205. if (!baseClass) {
  206. return;
  207. }
  208. const usages = cg.findUsages(baseClass.id);
  209. // Should find at least the extends relationship
  210. expect(usages).toBeDefined();
  211. expect(Array.isArray(usages)).toBe(true);
  212. });
  213. });
  214. describe('getCallers() and getCallees()', () => {
  215. it('should get callers of a function', () => {
  216. const nodes = cg.getNodesByKind('function');
  217. const formatValue = nodes.find((n) => n.name === 'formatValue');
  218. if (!formatValue) {
  219. return;
  220. }
  221. const callers = cg.getCallers(formatValue.id);
  222. // processValue calls formatValue
  223. expect(Array.isArray(callers)).toBe(true);
  224. });
  225. it('should get callees of a function', () => {
  226. const nodes = cg.getNodesByKind('function');
  227. const processValue = nodes.find((n) => n.name === 'processValue');
  228. if (!processValue) {
  229. return;
  230. }
  231. const callees = cg.getCallees(processValue.id);
  232. expect(Array.isArray(callees)).toBe(true);
  233. });
  234. it('treats class instantiation as a caller/callee of the class (#774)', () => {
  235. // main() does `new DerivedClass(10, 'test')`. Constructing a class is
  236. // calling its constructor, so main is a caller of DerivedClass and
  237. // DerivedClass is a callee of main. Before #774 the `instantiates` edge
  238. // was excluded from the caller/callee traversal, so `callers <Class>`
  239. // returned the importing file (or nothing) and missed every
  240. // construction site.
  241. const derived = cg.getNodesByKind('class').find((n) => n.name === 'DerivedClass');
  242. const main = cg.getNodesByKind('function').find((n) => n.name === 'main');
  243. expect(derived).toBeDefined();
  244. expect(main).toBeDefined();
  245. const callerNames = cg.getCallers(derived!.id).map((c) => c.node.name);
  246. expect(callerNames).toContain('main');
  247. const calleeNames = cg.getCallees(main!.id).map((c) => c.node.name);
  248. expect(calleeNames).toContain('DerivedClass');
  249. });
  250. });
  251. describe('getImpactRadius()', () => {
  252. it('should calculate impact radius', () => {
  253. const nodes = cg.getNodesByKind('function');
  254. const formatValue = nodes.find((n) => n.name === 'formatValue');
  255. if (!formatValue) {
  256. return;
  257. }
  258. const impact = cg.getImpactRadius(formatValue.id, 3);
  259. expect(impact.nodes.size).toBeGreaterThan(0);
  260. expect(impact.nodes.has(formatValue.id)).toBe(true);
  261. });
  262. it('does not drag in sibling members via the structural contains edge (#536)', () => {
  263. const getName = cg.getNodesByKind('method').find((n) => n.name === 'getName');
  264. const derived = cg.getNodesByKind('class').find((n) => n.name === 'DerivedClass');
  265. expect(getName).toBeDefined();
  266. expect(derived).toBeDefined();
  267. const impact = cg.getImpactRadius(getName!.id, 3);
  268. // The containing class must NOT be pulled into impact just because it
  269. // *contains* getName — climbing that contains edge would re-expand every
  270. // sibling method and explode impact for a leaf symbol. (#536)
  271. expect(impact.nodes.has(derived!.id)).toBe(false);
  272. });
  273. });
  274. describe('findPath()', () => {
  275. it('should find path between connected nodes', () => {
  276. const stats = cg.getStats();
  277. if (stats.nodeCount < 2) {
  278. return;
  279. }
  280. const functions = cg.getNodesByKind('function');
  281. if (functions.length < 2) {
  282. return;
  283. }
  284. // Try to find any path
  285. const processValue = functions.find((n) => n.name === 'processValue');
  286. const formatValue = functions.find((n) => n.name === 'formatValue');
  287. if (processValue && formatValue) {
  288. const path = cg.findPath(processValue.id, formatValue.id);
  289. // Path might exist or might not depending on edge direction
  290. expect(path === null || Array.isArray(path)).toBe(true);
  291. }
  292. });
  293. it('should return null for disconnected nodes', () => {
  294. // Create two nodes that definitely don't have a path
  295. const path = cg.findPath('non-existent-1', 'non-existent-2');
  296. expect(path).toBeNull();
  297. });
  298. });
  299. describe('getAncestors() and getChildren()', () => {
  300. it('should get ancestors of a node', () => {
  301. const methods = cg.getNodesByKind('method');
  302. const printMethod = methods.find((n) => n.name === 'print');
  303. if (!printMethod) {
  304. return;
  305. }
  306. const ancestors = cg.getAncestors(printMethod.id);
  307. // Should have class and file as ancestors
  308. expect(Array.isArray(ancestors)).toBe(true);
  309. });
  310. it('should get children of a node', () => {
  311. const classes = cg.getNodesByKind('class');
  312. const derivedClass = classes.find((n) => n.name === 'DerivedClass');
  313. if (!derivedClass) {
  314. return;
  315. }
  316. const children = cg.getChildren(derivedClass.id);
  317. // Should have methods as children
  318. expect(Array.isArray(children)).toBe(true);
  319. });
  320. });
  321. describe('File dependency analysis', () => {
  322. // Regression: getFileDependents/getFileDependencies used to follow
  323. // ONLY `imports` edges, which in this engine are same-file (a file → its
  324. // own local import declarations). That made both return [] for EVERY file,
  325. // so `codegraph affected` found no dependents on any language/framework.
  326. // They must follow the cross-file symbol graph instead (calls / references
  327. // / instantiates / extends / implements / ...).
  328. it('reports cross-file dependencies via the symbol graph, not just imports', () => {
  329. const deps = cg.getFileDependencies('src/main.ts');
  330. // main() instantiates DerivedClass (derived.ts) and calls
  331. // processValue/doubleValue (utils.ts) — both are real dependencies.
  332. expect(deps).toContain('src/utils.ts');
  333. expect(deps).toContain('src/derived.ts');
  334. });
  335. it('reports cross-file dependents via the symbol graph, not just imports', () => {
  336. // utils.ts is used by main.ts (processValue/doubleValue calls); the old
  337. // imports-only implementation returned [] here.
  338. expect(cg.getFileDependents('src/utils.ts')).toContain('src/main.ts');
  339. });
  340. it('counts extends/implements as a dependency edge', () => {
  341. // derived.ts extends BaseClass / implements Printable, both in base.ts.
  342. expect(cg.getFileDependencies('src/derived.ts')).toContain('src/base.ts');
  343. expect(cg.getFileDependents('src/base.ts')).toContain('src/derived.ts');
  344. });
  345. it('never lists a file as its own dependent or dependency', () => {
  346. for (const f of ['src/main.ts', 'src/utils.ts', 'src/base.ts', 'src/derived.ts']) {
  347. expect(cg.getFileDependents(f)).not.toContain(f);
  348. expect(cg.getFileDependencies(f)).not.toContain(f);
  349. }
  350. });
  351. });
  352. describe('findCircularDependencies()', () => {
  353. it('should detect circular dependencies', () => {
  354. const cycles = cg.findCircularDependencies();
  355. // Our test files don't have circular deps
  356. expect(Array.isArray(cycles)).toBe(true);
  357. });
  358. });
  359. describe('findDeadCode()', () => {
  360. it('should find dead code', () => {
  361. const deadCode = cg.findDeadCode(['function']);
  362. expect(Array.isArray(deadCode)).toBe(true);
  363. // unusedHelper should be detected
  364. const hasUnused = deadCode.some((n) => n.name === 'unusedHelper');
  365. // Note: This depends on extraction properly detecting function scope
  366. expect(deadCode.length).toBeGreaterThanOrEqual(0);
  367. });
  368. });
  369. describe('getNodeMetrics()', () => {
  370. it('should return metrics for a node', () => {
  371. const functions = cg.getNodesByKind('function');
  372. const func = functions[0];
  373. if (!func) {
  374. return;
  375. }
  376. const metrics = cg.getNodeMetrics(func.id);
  377. expect(metrics).toHaveProperty('incomingEdgeCount');
  378. expect(metrics).toHaveProperty('outgoingEdgeCount');
  379. expect(metrics).toHaveProperty('callCount');
  380. expect(metrics).toHaveProperty('callerCount');
  381. expect(metrics).toHaveProperty('childCount');
  382. expect(metrics).toHaveProperty('depth');
  383. expect(typeof metrics.incomingEdgeCount).toBe('number');
  384. expect(typeof metrics.outgoingEdgeCount).toBe('number');
  385. });
  386. });
  387. });
  388. // =============================================================================
  389. // Traversal edge-completeness & node-limit regressions (#1086–#1090)
  390. //
  391. // These drive GraphTraverser directly against an in-memory graph (the same
  392. // approach the reporter used), so the exact parallel-edge / high-degree
  393. // topologies can be constructed deterministically without round-tripping
  394. // through extraction.
  395. // =============================================================================
  396. /** Minimal Node stub — the traversal code only reads id/kind/name. */
  397. function tNode(id: string, kind: Node['kind'] = 'function'): Node {
  398. return {
  399. id,
  400. kind,
  401. name: id,
  402. qualifiedName: id,
  403. filePath: `src/${id}.ts`,
  404. language: 'typescript',
  405. startLine: 1,
  406. endLine: 10,
  407. startColumn: 0,
  408. endColumn: 0,
  409. } as unknown as Node;
  410. }
  411. /** Build a GraphTraverser over a fixed node/edge set, honoring the `kinds` filter. */
  412. function tGraph(nodes: Node[], edges: Edge[]): GraphTraverser {
  413. const byId = new Map(nodes.map((n) => [n.id, n]));
  414. const q = {
  415. getNodeById: (id: string) => byId.get(id) ?? null,
  416. getNodesByIds: (ids: readonly string[]) => {
  417. const m = new Map<string, Node>();
  418. for (const id of ids) {
  419. const n = byId.get(id);
  420. if (n) m.set(id, n);
  421. }
  422. return m;
  423. },
  424. getOutgoingEdges: (source: string, kinds?: string[]) =>
  425. edges.filter((e) => e.source === source && (!kinds || kinds.includes(e.kind))),
  426. getIncomingEdges: (target: string, kinds?: string[]) =>
  427. edges.filter((e) => e.target === target && (!kinds || kinds.includes(e.kind))),
  428. };
  429. return new GraphTraverser(q as never);
  430. }
  431. describe('Traversal edge-completeness & limits (#1086–#1090)', () => {
  432. it('traverseBFS keeps every parallel edge to the same target (#1090)', () => {
  433. // A reaches B via both `calls` and `references` — two distinct edges.
  434. const edges: Edge[] = [
  435. { source: 'A', target: 'B', kind: 'calls', line: 1 },
  436. { source: 'A', target: 'B', kind: 'references', line: 2 },
  437. ];
  438. const sub = tGraph([tNode('A'), tNode('B')], edges).traverseBFS('A', { direction: 'outgoing' });
  439. const ab = sub.edges.filter((e) => e.source === 'A' && e.target === 'B');
  440. // Pre-fix: only the higher-priority `calls` edge survived; `references` was dropped.
  441. expect(ab.map((e) => e.kind).sort()).toEqual(['calls', 'references']);
  442. expect(sub.nodes.has('B')).toBe(true);
  443. });
  444. it('traverseBFS keeps two same-kind edges on different lines (#1090)', () => {
  445. const edges: Edge[] = [
  446. { source: 'A', target: 'B', kind: 'calls', line: 3 },
  447. { source: 'A', target: 'B', kind: 'calls', line: 7 },
  448. ];
  449. const sub = tGraph([tNode('A'), tNode('B')], edges).traverseBFS('A', { direction: 'outgoing' });
  450. expect(sub.edges.filter((e) => e.source === 'A' && e.target === 'B')).toHaveLength(2);
  451. });
  452. it('traverseBFS does not overshoot opts.limit on a high-degree node (#1087)', () => {
  453. const neighbors = ['B', 'C', 'D', 'E', 'F'];
  454. const nodes = [tNode('A'), ...neighbors.map((n) => tNode(n))];
  455. const edges: Edge[] = neighbors.map((n) => ({ source: 'A', target: n, kind: 'calls' as const }));
  456. const sub = tGraph(nodes, edges).traverseBFS('A', { limit: 3, direction: 'outgoing' });
  457. // Pre-fix: all 5 neighbors were added in one pass → 6 nodes despite limit 3.
  458. expect(sub.nodes.size).toBeLessThanOrEqual(3);
  459. });
  460. it('traverseDFS does not overshoot opts.limit on a high-degree node (#1088)', () => {
  461. const neighbors = ['B', 'C', 'D', 'E', 'F'];
  462. const nodes = [tNode('A'), ...neighbors.map((n) => tNode(n))];
  463. const edges: Edge[] = neighbors.map((n) => ({ source: 'A', target: n, kind: 'calls' as const }));
  464. const sub = tGraph(nodes, edges).traverseDFS('A', { limit: 2, direction: 'outgoing' });
  465. expect(sub.nodes.size).toBeLessThanOrEqual(2);
  466. });
  467. it('getCallers returns each caller once when reached via multiple edges (#1086)', () => {
  468. // Y calls X at two sites and also references it — three incoming edges.
  469. const edges: Edge[] = [
  470. { source: 'Y', target: 'X', kind: 'calls', line: 1 },
  471. { source: 'Y', target: 'X', kind: 'calls', line: 2 },
  472. { source: 'Y', target: 'X', kind: 'references', line: 3 },
  473. ];
  474. const callers = tGraph([tNode('X'), tNode('Y')], edges).getCallers('X'); // default maxDepth = 1
  475. // Pre-fix: Y appeared three times (depth guard returned before visited.add).
  476. expect(callers.map((c) => c.node.id)).toEqual(['Y']);
  477. });
  478. it('getCallees returns each callee once when reached via multiple edges (#1086)', () => {
  479. const edges: Edge[] = [
  480. { source: 'X', target: 'Y', kind: 'calls', line: 1 },
  481. { source: 'X', target: 'Y', kind: 'calls', line: 2 },
  482. ];
  483. const callees = tGraph([tNode('X'), tNode('Y')], edges).getCallees('X');
  484. expect(callees.map((c) => c.node.id)).toEqual(['Y']);
  485. });
  486. it('getImpactRadius keeps a direct edge into a node already collected via another path (#1089)', () => {
  487. // Class P contains method M. Q calls both M and P. Reaching M first collects
  488. // Q; the pre-fix `!nodes.has()` gate then dropped the direct Q→P edge.
  489. const nodes = [tNode('P', 'class'), tNode('M', 'method'), tNode('Q')];
  490. const edges: Edge[] = [
  491. { source: 'P', target: 'M', kind: 'contains' },
  492. { source: 'Q', target: 'M', kind: 'calls', line: 1 },
  493. { source: 'Q', target: 'P', kind: 'calls', line: 2 },
  494. ];
  495. const sub = tGraph(nodes, edges).getImpactRadius('P', 2);
  496. expect(sub.nodes.has('Q')).toBe(true);
  497. expect(sub.edges.some((e) => e.source === 'Q' && e.target === 'M' && e.kind === 'calls')).toBe(true);
  498. // The regression: this direct dependency edge used to vanish.
  499. expect(sub.edges.some((e) => e.source === 'Q' && e.target === 'P' && e.kind === 'calls')).toBe(true);
  500. });
  501. });