Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions __tests__/graph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,36 @@ export { main };
expect(Array.isArray(callers)).toBe(true);
});

it('should include top-level anonymous callback bodies as file callers', async () => {
const callbacksPath = path.join(testDir, 'src', 'callbacks.ts');
fs.writeFileSync(
callbacksPath,
`
import { formatValue } from './utils';

Deno.serve(async (request) => {
return formatValue(1);
});

it('formats a value', async () => {
expect(formatValue(2)).toBe('2.00');
});
`
);

await cg.sync();
cg.resolveReferences();

const nodes = cg.getNodesByKind('function');
const formatValue = nodes.find((n) => n.name === 'formatValue');
expect(formatValue).toBeDefined();

const callers = cg.getCallers(formatValue!.id);
expect(callers.some(
(c) => c.node.kind === 'file' && c.node.filePath === 'src/callbacks.ts'
)).toBe(true);
});

it('should get callees of a function', () => {
const nodes = cg.getNodesByKind('function');
const processValue = nodes.find((n) => n.name === 'processValue');
Expand Down Expand Up @@ -386,6 +416,26 @@ export { main };

expect(Array.isArray(dependents)).toBe(true);
});

it('should treat static file-read string paths as file dependents', async () => {
fs.writeFileSync(
path.join(testDir, 'src', 'source-contract.test.ts'),
`
import { readFileSync } from 'fs';

test('source contract', () => {
const source = readFileSync("src/utils.ts", "utf8");
expect(source).toContain('formatValue');
});
`
);

await cg.sync();
cg.resolveReferences();

const dependents = cg.getFileDependents('src/utils.ts');
expect(dependents).toContain('src/source-contract.test.ts');
});
});

describe('findCircularDependencies()', () => {
Expand Down
24 changes: 18 additions & 6 deletions __tests__/mcp-initialize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,20 @@ function waitFor<T>(
});
}

function stopServer(child: ChildProcessWithoutNullStreams | null): Promise<void> {
if (!child || child.exitCode !== null || child.signalCode !== null) {
return Promise.resolve();
}
return new Promise((resolve) => {
const timer = setTimeout(resolve, 5000);
child.once('exit', () => {
clearTimeout(timer);
resolve();
});
child.kill('SIGKILL');
});
}

describe('MCP initialize handshake (issue #172)', () => {
let tempDir: string;
let child: ChildProcessWithoutNullStreams | null = null;
Expand All @@ -107,12 +121,10 @@ describe('MCP initialize handshake (issue #172)', () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-init-'));
});

afterEach(() => {
if (child && !child.killed) {
child.kill('SIGKILL');
child = null;
}
fs.rmSync(tempDir, { recursive: true, force: true });
afterEach(async () => {
await stopServer(child);
child = null;
fs.rmSync(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
});

it('responds to initialize quickly when no .codegraph exists in cwd', async () => {
Expand Down
26 changes: 19 additions & 7 deletions __tests__/mcp-roots.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,20 @@ function send(child: ChildProcessWithoutNullStreams, msg: object): void {
child.stdin.write(JSON.stringify(msg) + '\n');
}

function stopServer(child: ChildProcessWithoutNullStreams | null): Promise<void> {
if (!child || child.exitCode !== null || child.signalCode !== null) {
return Promise.resolve();
}
return new Promise((resolve) => {
const timer = setTimeout(resolve, 5000);
child.once('exit', () => {
clearTimeout(timer);
resolve();
});
child.kill('SIGKILL');
});
}

const CLIENT_INFO = { name: 'test', version: '0.0.0' };

describe('MCP project resolution via roots/list (issue #196)', () => {
Expand All @@ -84,13 +98,11 @@ describe('MCP project resolution via roots/list (issue #196)', () => {
projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-proj-'));
});

afterEach(() => {
if (child && !child.killed) {
child.kill('SIGKILL');
child = null;
}
fs.rmSync(cwdDir, { recursive: true, force: true });
fs.rmSync(projectDir, { recursive: true, force: true });
afterEach(async () => {
await stopServer(child);
child = null;
fs.rmSync(cwdDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
fs.rmSync(projectDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
});

it('resolves the project from the client roots/list when no rootUri is sent', async () => {
Expand Down
46 changes: 46 additions & 0 deletions __tests__/pr19-improvements.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,52 @@ export const useAuth = () => {
expect(callNames).toContain('generateToken');
});

it('should extract unresolved references from anonymous callback bodies', () => {
const code = `
Deno.serve(async (request) => {
const result = await confirmPayment(request);
return json(result);
});

it('runs crawler', async () => {
await runCrawl();
});
`;
const result = extractFromSource('callbacks.ts', code);

const anonymousFunctions = result.nodes.filter(
(n) => n.kind === 'function' && n.name === '<anonymous>'
);
expect(anonymousFunctions).toHaveLength(0);

const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls');
const callNames = calls.map((c) => c.referenceName);
expect(callNames).toContain('confirmPayment');
expect(callNames).toContain('json');
expect(callNames).toContain('runCrawl');
});

it('should extract project file references from file read calls', () => {
const code = `
import { readFileSync } from 'fs';

test('checks source contract', () => {
const source = readFileSync("supabase/functions/_shared/payment.ts", "utf8");
const page = Deno.readTextFileSync('app/app/page.tsx');
const dynamic = readFileSync(\`src/\${name}.ts\`, "utf8");
const remote = readFileSync("https://example.com/file.ts", "utf8");
});
`;
const result = extractFromSource('payment.test.ts', code);

const fileRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'imports');
const refNames = fileRefs.map((r) => r.referenceName);
expect(refNames).toContain('supabase/functions/_shared/payment.ts');
expect(refNames).toContain('app/app/page.tsx');
expect(refNames).not.toContain('https://example.com/file.ts');
expect(refNames.some((name) => name.includes('${'))).toBe(false);
});

it('should extract unresolved references from function expression bodies', () => {
const code = `
export const processData = function(input: string): string {
Expand Down
67 changes: 66 additions & 1 deletion src/extraction/tree-sitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,15 @@ const INSTANTIATION_KINDS: ReadonlySet<string> = new Set([
'instance_creation_expression', // some grammars
]);

const FILE_READ_CALL_NAMES: ReadonlySet<string> = new Set([
'readFileSync',
'readFile',
'readTextFile',
'readTextFileSync',
]);

const PROJECT_FILE_EXT_RE = /\.(?:[cm]?[jt]sx?|json|ya?ml|sql|md|css|scss|html)$/i;

/**
* TreeSitterExtractor - Main extraction class
*/
Expand Down Expand Up @@ -562,7 +571,18 @@ export class TreeSitterExtractor {
}
}
}
if (name === '<anonymous>') return; // Skip anonymous functions
if (name === '<anonymous>') {
// Anonymous callbacks still contain real calls. Do not create a
// synthetic symbol, but do walk the body under the current scope so
// top-level wrappers like `Deno.serve(async () => handler())` and
// test callbacks like `it(..., async () => run())` preserve call edges.
const body = this.extractor.resolveBody?.(node, this.extractor.bodyField)
?? getChildByField(node, this.extractor.bodyField);
if (body) {
this.visitFunctionBody(body, '');
}
return;
}

// Check for misparse artifacts (e.g. C++ macros causing "namespace detail" functions)
// Skip the node but still visit the body for calls and structural nodes
Expand Down Expand Up @@ -1517,7 +1537,52 @@ export class TreeSitterExtractor {
line: node.startPosition.row + 1,
column: node.startPosition.column,
});
this.extractFilePathArgumentReferences(node, callerId, calleeName);
}
}

private extractFilePathArgumentReferences(node: SyntaxNode, callerId: string, calleeName: string): void {
const callName = calleeName.split(/[.:]/).pop() ?? calleeName;
if (!FILE_READ_CALL_NAMES.has(callName)) return;

const args = getChildByField(node, 'arguments');
if (!args) return;

for (let i = 0; i < args.namedChildCount; i++) {
const arg = args.namedChild(i);
if (!arg) continue;
const filePath = this.getStaticStringLiteral(arg);
if (!filePath || !this.looksLikeProjectFilePath(filePath)) continue;

this.unresolvedReferences.push({
fromNodeId: callerId,
referenceName: filePath.replace(/\\/g, '/').replace(/^\.\//, ''),
referenceKind: 'imports',
line: arg.startPosition.row + 1,
column: arg.startPosition.column,
});
}
}

private getStaticStringLiteral(node: SyntaxNode): string | null {
const text = getNodeText(node, this.source).trim();
if (
(text.startsWith('"') && text.endsWith('"')) ||
(text.startsWith("'") && text.endsWith("'"))
) {
return text.slice(1, -1);
}
if (text.startsWith('`') && text.endsWith('`') && !text.includes('${')) {
return text.slice(1, -1);
}
return null;
}

private looksLikeProjectFilePath(value: string): boolean {
if (!value || value.includes('\0')) return false;
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return false;
if (!PROJECT_FILE_EXT_RE.test(value)) return false;
return value.includes('/') || value.includes('\\') || value.startsWith('.');
}

/**
Expand Down