Files
wehub-resource-sync 5f98960d22
Deploy site to GitHub Pages / build (push) Failing after 1s
Deploy site to GitHub Pages / deploy (push) Has been skipped
chore: import upstream snapshot with attribution
2026-07-13 12:02:56 +08:00

616 lines
20 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Graph Query Tests
*
* Tests for graph traversal and query functionality.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
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;
let cg: CodeGraph;
beforeEach(async () => {
// Create temp directory
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-graph-test-'));
// Create test files with relationships
const srcDir = path.join(testDir, 'src');
fs.mkdirSync(srcDir, { recursive: true });
// Create base class
fs.writeFileSync(
path.join(srcDir, 'base.ts'),
`
export class BaseClass {
protected value: number;
constructor(value: number) {
this.value = value;
}
getValue(): number {
return this.value;
}
}
export interface Printable {
print(): void;
}
`
);
// Create derived class
fs.writeFileSync(
path.join(srcDir, 'derived.ts'),
`
import { BaseClass, Printable } from './base';
export class DerivedClass extends BaseClass implements Printable {
private name: string;
constructor(value: number, name: string) {
super(value);
this.name = name;
}
print(): void {
console.log(this.getName(), this.getValue());
}
getName(): string {
return this.name;
}
}
`
);
// Create utility functions
fs.writeFileSync(
path.join(srcDir, 'utils.ts'),
`
export function formatValue(value: number): string {
return value.toFixed(2);
}
export function processValue(value: number): number {
const formatted = formatValue(value);
return parseFloat(formatted);
}
export function doubleValue(value: number): number {
return value * 2;
}
// Unused function (dead code)
function unusedHelper(): void {
console.log('never called');
}
`
);
// Create main file that uses everything
fs.writeFileSync(
path.join(srcDir, 'main.ts'),
`
import { DerivedClass } from './derived';
import { processValue, doubleValue } from './utils';
function main(): void {
const obj = new DerivedClass(10, 'test');
obj.print();
const result = processValue(doubleValue(obj.getValue()));
console.log(result);
}
export { main };
`
);
// Initialize and index
cg = CodeGraph.initSync(testDir, {
config: {
include: ['src/**/*.ts'],
exclude: [],
},
});
await cg.indexAll();
cg.resolveReferences();
});
afterEach(() => {
if (cg) {
cg.destroy();
}
if (fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true, force: true });
}
});
describe('traverse()', () => {
it('should traverse graph from a starting node', () => {
const nodes = cg.getNodesByKind('function');
const mainFunc = nodes.find((n) => n.name === 'main');
if (!mainFunc) {
console.log('main function not found, skipping test');
return;
}
const subgraph = cg.traverse(mainFunc.id, {
maxDepth: 2,
direction: 'outgoing',
});
expect(subgraph.nodes.size).toBeGreaterThan(0);
expect(subgraph.roots).toContain(mainFunc.id);
});
it('should respect maxDepth option', () => {
const nodes = cg.getNodesByKind('function');
const mainFunc = nodes.find((n) => n.name === 'main');
if (!mainFunc) {
return;
}
const shallow = cg.traverse(mainFunc.id, { maxDepth: 1 });
const deep = cg.traverse(mainFunc.id, { maxDepth: 3 });
expect(deep.nodes.size).toBeGreaterThanOrEqual(shallow.nodes.size);
});
it('should support incoming direction', () => {
const nodes = cg.getNodesByKind('function');
const formatValue = nodes.find((n) => n.name === 'formatValue');
if (!formatValue) {
return;
}
const subgraph = cg.traverse(formatValue.id, {
maxDepth: 2,
direction: 'incoming',
});
expect(subgraph.nodes.size).toBeGreaterThan(0);
});
});
describe('getContext()', () => {
it('should return context for a node', () => {
const nodes = cg.getNodesByKind('class');
const derivedClass = nodes.find((n) => n.name === 'DerivedClass');
if (!derivedClass) {
console.log('DerivedClass not found, skipping test');
return;
}
const context = cg.getContext(derivedClass.id);
expect(context.focal).toBeDefined();
expect(context.focal.id).toBe(derivedClass.id);
expect(context.ancestors).toBeDefined();
expect(context.children).toBeDefined();
expect(context.incomingRefs).toBeDefined();
expect(context.outgoingRefs).toBeDefined();
});
it('should throw for non-existent node', () => {
expect(() => cg.getContext('non-existent-id')).toThrow('Node not found');
});
});
describe('getCallGraph()', () => {
it('should return call graph for a function', () => {
const nodes = cg.getNodesByKind('function');
const processValue = nodes.find((n) => n.name === 'processValue');
if (!processValue) {
console.log('processValue not found, skipping test');
return;
}
const callGraph = cg.getCallGraph(processValue.id, 2);
expect(callGraph.nodes.size).toBeGreaterThan(0);
expect(callGraph.nodes.has(processValue.id)).toBe(true);
});
});
describe('getTypeHierarchy()', () => {
it('should return type hierarchy for a class', () => {
const nodes = cg.getNodesByKind('class');
const derivedClass = nodes.find((n) => n.name === 'DerivedClass');
if (!derivedClass) {
return;
}
const hierarchy = cg.getTypeHierarchy(derivedClass.id);
expect(hierarchy.nodes.size).toBeGreaterThan(0);
expect(hierarchy.nodes.has(derivedClass.id)).toBe(true);
});
it('should return empty subgraph for non-existent node', () => {
const hierarchy = cg.getTypeHierarchy('non-existent-id');
expect(hierarchy.nodes.size).toBe(0);
expect(hierarchy.edges.length).toBe(0);
});
});
describe('findUsages()', () => {
it('should find usages of a symbol', () => {
const nodes = cg.getNodesByKind('class');
const baseClass = nodes.find((n) => n.name === 'BaseClass');
if (!baseClass) {
return;
}
const usages = cg.findUsages(baseClass.id);
// Should find at least the extends relationship
expect(usages).toBeDefined();
expect(Array.isArray(usages)).toBe(true);
});
});
describe('getCallers() and getCallees()', () => {
it('should get callers of a function', () => {
const nodes = cg.getNodesByKind('function');
const formatValue = nodes.find((n) => n.name === 'formatValue');
if (!formatValue) {
return;
}
const callers = cg.getCallers(formatValue.id);
// processValue calls formatValue
expect(Array.isArray(callers)).toBe(true);
});
it('should get callees of a function', () => {
const nodes = cg.getNodesByKind('function');
const processValue = nodes.find((n) => n.name === 'processValue');
if (!processValue) {
return;
}
const callees = cg.getCallees(processValue.id);
expect(Array.isArray(callees)).toBe(true);
});
it('treats class instantiation as a caller/callee of the class (#774)', () => {
// main() does `new DerivedClass(10, 'test')`. Constructing a class is
// calling its constructor, so main is a caller of DerivedClass and
// DerivedClass is a callee of main. Before #774 the `instantiates` edge
// was excluded from the caller/callee traversal, so `callers <Class>`
// returned the importing file (or nothing) and missed every
// construction site.
const derived = cg.getNodesByKind('class').find((n) => n.name === 'DerivedClass');
const main = cg.getNodesByKind('function').find((n) => n.name === 'main');
expect(derived).toBeDefined();
expect(main).toBeDefined();
const callerNames = cg.getCallers(derived!.id).map((c) => c.node.name);
expect(callerNames).toContain('main');
const calleeNames = cg.getCallees(main!.id).map((c) => c.node.name);
expect(calleeNames).toContain('DerivedClass');
});
});
describe('getImpactRadius()', () => {
it('should calculate impact radius', () => {
const nodes = cg.getNodesByKind('function');
const formatValue = nodes.find((n) => n.name === 'formatValue');
if (!formatValue) {
return;
}
const impact = cg.getImpactRadius(formatValue.id, 3);
expect(impact.nodes.size).toBeGreaterThan(0);
expect(impact.nodes.has(formatValue.id)).toBe(true);
});
it('does not drag in sibling members via the structural contains edge (#536)', () => {
const getName = cg.getNodesByKind('method').find((n) => n.name === 'getName');
const derived = cg.getNodesByKind('class').find((n) => n.name === 'DerivedClass');
expect(getName).toBeDefined();
expect(derived).toBeDefined();
const impact = cg.getImpactRadius(getName!.id, 3);
// The containing class must NOT be pulled into impact just because it
// *contains* getName — climbing that contains edge would re-expand every
// sibling method and explode impact for a leaf symbol. (#536)
expect(impact.nodes.has(derived!.id)).toBe(false);
});
});
describe('findPath()', () => {
it('should find path between connected nodes', () => {
const stats = cg.getStats();
if (stats.nodeCount < 2) {
return;
}
const functions = cg.getNodesByKind('function');
if (functions.length < 2) {
return;
}
// Try to find any path
const processValue = functions.find((n) => n.name === 'processValue');
const formatValue = functions.find((n) => n.name === 'formatValue');
if (processValue && formatValue) {
const path = cg.findPath(processValue.id, formatValue.id);
// Path might exist or might not depending on edge direction
expect(path === null || Array.isArray(path)).toBe(true);
}
});
it('should return null for disconnected nodes', () => {
// Create two nodes that definitely don't have a path
const path = cg.findPath('non-existent-1', 'non-existent-2');
expect(path).toBeNull();
});
});
describe('getAncestors() and getChildren()', () => {
it('should get ancestors of a node', () => {
const methods = cg.getNodesByKind('method');
const printMethod = methods.find((n) => n.name === 'print');
if (!printMethod) {
return;
}
const ancestors = cg.getAncestors(printMethod.id);
// Should have class and file as ancestors
expect(Array.isArray(ancestors)).toBe(true);
});
it('should get children of a node', () => {
const classes = cg.getNodesByKind('class');
const derivedClass = classes.find((n) => n.name === 'DerivedClass');
if (!derivedClass) {
return;
}
const children = cg.getChildren(derivedClass.id);
// Should have methods as children
expect(Array.isArray(children)).toBe(true);
});
});
describe('File dependency analysis', () => {
// Regression: getFileDependents/getFileDependencies used to follow
// ONLY `imports` edges, which in this engine are same-file (a file → its
// own local import declarations). That made both return [] for EVERY file,
// so `codegraph affected` found no dependents on any language/framework.
// They must follow the cross-file symbol graph instead (calls / references
// / instantiates / extends / implements / ...).
it('reports cross-file dependencies via the symbol graph, not just imports', () => {
const deps = cg.getFileDependencies('src/main.ts');
// main() instantiates DerivedClass (derived.ts) and calls
// processValue/doubleValue (utils.ts) — both are real dependencies.
expect(deps).toContain('src/utils.ts');
expect(deps).toContain('src/derived.ts');
});
it('reports cross-file dependents via the symbol graph, not just imports', () => {
// utils.ts is used by main.ts (processValue/doubleValue calls); the old
// imports-only implementation returned [] here.
expect(cg.getFileDependents('src/utils.ts')).toContain('src/main.ts');
});
it('counts extends/implements as a dependency edge', () => {
// derived.ts extends BaseClass / implements Printable, both in base.ts.
expect(cg.getFileDependencies('src/derived.ts')).toContain('src/base.ts');
expect(cg.getFileDependents('src/base.ts')).toContain('src/derived.ts');
});
it('never lists a file as its own dependent or dependency', () => {
for (const f of ['src/main.ts', 'src/utils.ts', 'src/base.ts', 'src/derived.ts']) {
expect(cg.getFileDependents(f)).not.toContain(f);
expect(cg.getFileDependencies(f)).not.toContain(f);
}
});
});
describe('findCircularDependencies()', () => {
it('should detect circular dependencies', () => {
const cycles = cg.findCircularDependencies();
// Our test files don't have circular deps
expect(Array.isArray(cycles)).toBe(true);
});
});
describe('findDeadCode()', () => {
it('should find dead code', () => {
const deadCode = cg.findDeadCode(['function']);
expect(Array.isArray(deadCode)).toBe(true);
// unusedHelper should be detected
const hasUnused = deadCode.some((n) => n.name === 'unusedHelper');
// Note: This depends on extraction properly detecting function scope
expect(deadCode.length).toBeGreaterThanOrEqual(0);
});
});
describe('getNodeMetrics()', () => {
it('should return metrics for a node', () => {
const functions = cg.getNodesByKind('function');
const func = functions[0];
if (!func) {
return;
}
const metrics = cg.getNodeMetrics(func.id);
expect(metrics).toHaveProperty('incomingEdgeCount');
expect(metrics).toHaveProperty('outgoingEdgeCount');
expect(metrics).toHaveProperty('callCount');
expect(metrics).toHaveProperty('callerCount');
expect(metrics).toHaveProperty('childCount');
expect(metrics).toHaveProperty('depth');
expect(typeof metrics.incomingEdgeCount).toBe('number');
expect(typeof metrics.outgoingEdgeCount).toBe('number');
});
});
});
// =============================================================================
// 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);
});
});