Files
codegraph/__tests__/context.test.ts
T
danusha2345andClaude Opus 5.5 43a4f5b9f9 fix(graph): the type hierarchy returns a type's subtypes, not only its ancestors
getTypeHierarchy() walked a type's ancestors and then its descendants
with one visited set. The ancestor walk marks the focal node first, so
the descendant walk returned at its visited guard before reading a
single incoming edge: no type ever had subtypes or implementors, in any
language. Each direction now keeps its own visited set.

The same walk feeds the hierarchy step of findRelevantContext, which
codegraph_explore and codegraph context build on. That step promised
subclasses within a budget of a quarter of maxNodes, but checked the
budget only between entry points, which was harmless while the walk
returned ancestors alone. With descendants coming back, one widely
implemented interface brought its whole tree: vscode's IDisposable, in
src/vs/base and src/vs/editor, has 613 types in its hierarchy, and
codegraph context IDisposable then traded the interface's own dispose
for an arbitrary implementor. Added on top of the contributor's fix:
the budget now holds per node, and only edges between kept nodes are
added.

Validation: graph.test.ts gains a test that getTypeHierarchy() returns
a base class's subclass and an interface's implementor; it fails on
main. context.test.ts gains one that the hierarchy step adds an
interface's implementors within its budget (12 nodes, no traversal:
1 to 3); main gives 0, and the traversal fix without the cap gives 10.
On vscode's base and editor subtree, codegraph context and
codegraph_explore keep main's size and speed (0.8 to 1.3 s), and the
hierarchy they show is now the queried type's own: StandaloneCodeEditor
and EmbeddedCodeEditorWidget for "ICodeEditor CodeEditorWidget". On
prometheus, 3 of 4 explore answers are identical, and the fourth names
more of storage.Appender's implementors. The contributor's codegraph
implementations command and its test-code helper are not part of this
change. This is the getTypeHierarchy() half of issue #2833.

Original PR #2839 by @danusha2345.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 20:28:22 +00:00

414 lines
12 KiB
TypeScript

/**
* Context Builder Tests
*
* Tests for the context building 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';
describe('Context Builder', () => {
let testDir: string;
let cg: CodeGraph;
beforeEach(async () => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-context-test-'));
// Create a sample codebase
const srcDir = path.join(testDir, 'src');
fs.mkdirSync(srcDir);
// Create a payment service file
fs.writeFileSync(
path.join(srcDir, 'payment.ts'),
`/**
* Payment Service
* Handles payment processing logic.
*/
export interface PaymentResult {
success: boolean;
transactionId: string;
amount: number;
}
export class PaymentService {
private apiKey: string;
constructor(apiKey: string) {
this.apiKey = apiKey;
}
/**
* Process a payment for the given amount
*/
async processPayment(amount: number): Promise<PaymentResult> {
// Validate amount
if (amount <= 0) {
throw new Error('Invalid amount');
}
// Process payment
const transactionId = this.generateTransactionId();
return {
success: true,
transactionId,
amount,
};
}
private generateTransactionId(): string {
return 'txn_' + Math.random().toString(36).substring(2);
}
}
export function createPaymentService(apiKey: string): PaymentService {
return new PaymentService(apiKey);
}
`
);
// Create a checkout controller file
fs.writeFileSync(
path.join(srcDir, 'checkout.ts'),
`/**
* Checkout Controller
* Handles the checkout flow.
*/
import { PaymentService, PaymentResult } from './payment';
export interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
export class CheckoutController {
private paymentService: PaymentService;
constructor(paymentService: PaymentService) {
this.paymentService = paymentService;
}
/**
* Process checkout for the given cart
*/
async processCheckout(cart: CartItem[]): Promise<PaymentResult> {
const total = this.calculateTotal(cart);
if (total === 0) {
throw new Error('Cart is empty');
}
return this.paymentService.processPayment(total);
}
/**
* Calculate the total price of the cart
*/
calculateTotal(cart: CartItem[]): number {
return cart.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
}
`
);
// Create a utilities file
fs.writeFileSync(
path.join(srcDir, 'utils.ts'),
`/**
* Utility functions
*/
export function formatCurrency(amount: number): string {
return '$' + amount.toFixed(2);
}
export function validateEmail(email: string): boolean {
return email.includes('@');
}
`
);
fs.writeFileSync(
path.join(srcDir, 'callback_ops.c'),
`union CallbackOps { int (*run)(int); };
`
);
// Initialize CodeGraph
cg = CodeGraph.initSync(testDir, {
config: {
include: ['**/*.ts', '**/*.c'],
exclude: [],
},
});
// Index the codebase
await cg.indexAll();
});
afterEach(() => {
if (cg) {
cg.destroy();
}
if (fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true, force: true });
}
});
describe('getCode()', () => {
it('should extract code for a node', async () => {
// Find the PaymentService class
const nodes = cg.getNodesByKind('class');
const paymentService = nodes.find((n) => n.name === 'PaymentService');
expect(paymentService).toBeDefined();
const code = await cg.getCode(paymentService!.id);
expect(code).not.toBeNull();
expect(code).toContain('class PaymentService');
expect(code).toContain('processPayment');
});
it('should return null for non-existent node', async () => {
const code = await cg.getCode('non-existent-id');
expect(code).toBeNull();
});
});
describe('findRelevantContext()', () => {
it('should find relevant nodes for a query', async () => {
// Use simple query that matches symbol names (FTS5 treats spaces as AND)
const result = await cg.findRelevantContext('PaymentService');
expect(result.nodes.size).toBeGreaterThan(0);
// Should find payment-related nodes
const nodeNames = Array.from(result.nodes.values()).map((n) => n.name);
expect(
nodeNames.some(
(name) =>
name.toLowerCase().includes('payment') ||
name.toLowerCase().includes('checkout')
)
).toBe(true);
});
it('includes union definitions in the default context search', async () => {
const result = await cg.findRelevantContext('CallbackOps');
const union = [...result.nodes.values()].find(
(node) => node.kind === 'union' && node.name === 'CallbackOps'
);
expect(union).toBeDefined();
});
it('should include edges in the result', async () => {
const result = await cg.findRelevantContext('checkout', {
traversalDepth: 2,
});
// Should have some edges from traversal
expect(result.edges).toBeDefined();
});
it('should respect maxNodes option', async () => {
const result = await cg.findRelevantContext('function', {
maxNodes: 5,
});
expect(result.nodes.size).toBeLessThanOrEqual(5);
});
it('adds a widely implemented interface\'s subtypes within the hierarchy budget', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-context-hierarchy-'));
fs.writeFileSync(path.join(dir, 'lifecycle.ts'), 'export interface IDisposable {\n release(): void;\n}\n');
for (let i = 0; i < 15; i++) {
fs.writeFileSync(
path.join(dir, `impl${i}.ts`),
`import { IDisposable } from './lifecycle';\nexport class Impl${i} implements IDisposable {\n release(): void {}\n}\n`
);
}
const graph = CodeGraph.initSync(dir, { config: { include: ['**/*.ts'], exclude: [] } });
try {
await graph.indexAll();
// No traversal, so every implementor here comes from the hierarchy
// step, which takes at most a quarter of the budget: 3 of 12.
const result = await graph.findRelevantContext('IDisposable', { maxNodes: 12, traversalDepth: 0 });
const implementors = [...result.nodes.values()].filter((n) => /^Impl\d+$/.test(n.name));
expect(implementors.length).toBeGreaterThan(0);
expect(implementors.length).toBeLessThanOrEqual(3);
} finally {
graph.destroy();
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
describe('buildContext()', () => {
it('should build context with markdown format', async () => {
const result = await cg.buildContext('Fix checkout error', {
format: 'markdown',
maxCodeBlocks: 3,
});
expect(typeof result).toBe('string');
const markdown = result as string;
// Should contain markdown structure
expect(markdown).toContain('## Code Context');
expect(markdown).toContain('**Query:** Fix checkout error');
});
it('should build context with JSON format', async () => {
const result = await cg.buildContext('payment processing', {
format: 'json',
});
expect(typeof result).toBe('string');
const parsed = JSON.parse(result as string);
expect(parsed.query).toBe('payment processing');
expect(parsed.nodes).toBeDefined();
expect(Array.isArray(parsed.nodes)).toBe(true);
});
it('should accept object input with title and description', async () => {
const result = await cg.buildContext(
{
title: 'Checkout bug',
description: 'Cart total calculation is wrong',
},
{ format: 'markdown' }
);
expect(typeof result).toBe('string');
expect(result).toContain('Checkout bug: Cart total calculation is wrong');
});
it('should include code blocks when requested', async () => {
const result = await cg.buildContext('PaymentService', {
format: 'markdown',
includeCode: true,
maxCodeBlocks: 2,
});
const markdown = result as string;
// Should contain code blocks
expect(markdown).toContain('### Code');
expect(markdown).toContain('```typescript');
});
it('should exclude code blocks when requested', async () => {
const result = await cg.buildContext('payment', {
format: 'markdown',
includeCode: false,
});
const markdown = result as string;
// Should not contain code section
expect(markdown).not.toContain('### Code');
});
it('should include related symbols in compact format', async () => {
const result = await cg.buildContext('checkout', {
format: 'markdown',
maxNodes: 10,
});
const markdown = result as string;
// Compact format uses "Related Symbols" instead of verbose "Related Files"
// and groups symbols by file for compactness
expect(markdown).toContain('### Entry Points');
});
it('should have compact output without verbose stats footer', async () => {
const result = await cg.buildContext('payment', {
format: 'markdown',
});
const markdown = result as string;
// Compact format should NOT have verbose stats footer
expect(markdown).not.toMatch(/\*Context:.*symbols.*relationships.*files/);
// But should still have query
expect(markdown).toContain('**Query:**');
});
});
describe('Context structure', () => {
it('should find entry points from search', async () => {
const result = await cg.buildContext('PaymentService', {
format: 'json',
});
const parsed = JSON.parse(result as string);
expect(parsed.entryPoints).toBeDefined();
expect(parsed.entryPoints.length).toBeGreaterThan(0);
});
it('should traverse graph from entry points', async () => {
const result = await cg.buildContext('CheckoutController', {
format: 'json',
traversalDepth: 2,
});
const parsed = JSON.parse(result as string);
// Should have found related nodes through traversal
const nodeNames = parsed.nodes.map((n: { name: string }) => n.name);
// CheckoutController calls PaymentService, so both should be present
expect(
nodeNames.some((name: string) => name.includes('Checkout'))
).toBe(true);
});
});
describe('Edge cases', () => {
it('should handle empty query', async () => {
const result = await cg.buildContext('', { format: 'markdown' });
expect(typeof result).toBe('string');
});
it('should handle query with no matches', async () => {
const result = await cg.buildContext('xyznonexistent123', {
format: 'json',
});
const parsed = JSON.parse(result as string);
// Should return empty or minimal results
expect(parsed.nodes).toBeDefined();
});
it('should truncate long code blocks', async () => {
const result = await cg.buildContext('PaymentService', {
format: 'markdown',
maxCodeBlockSize: 100,
includeCode: true,
});
const markdown = result as string;
// Long code blocks should be truncated
if (markdown.includes('```typescript')) {
// If there's a code block, check for truncation marker if content was long
// This test validates the truncation logic works
expect(typeof markdown).toBe('string');
}
});
});
});