From 3cf05d45a02763d11627bd1669fa28690f81285d Mon Sep 17 00:00:00 2001 From: bohe76 Date: Tue, 26 May 2026 10:15:38 +0900 Subject: [PATCH] fix: include anonymous callback and file-read dependents --- __tests__/graph.test.ts | 50 +++++++++++++++++++++ __tests__/mcp-initialize.test.ts | 24 ++++++++--- __tests__/mcp-roots.test.ts | 26 ++++++++--- __tests__/pr19-improvements.test.ts | 46 ++++++++++++++++++++ src/extraction/tree-sitter.ts | 67 ++++++++++++++++++++++++++++- 5 files changed, 199 insertions(+), 14 deletions(-) diff --git a/__tests__/graph.test.ts b/__tests__/graph.test.ts index 7c771af0b..1c5b0aafd 100644 --- a/__tests__/graph.test.ts +++ b/__tests__/graph.test.ts @@ -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'); @@ -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()', () => { diff --git a/__tests__/mcp-initialize.test.ts b/__tests__/mcp-initialize.test.ts index 31899aa7c..eb1ee4213 100644 --- a/__tests__/mcp-initialize.test.ts +++ b/__tests__/mcp-initialize.test.ts @@ -99,6 +99,20 @@ function waitFor( }); } +function stopServer(child: ChildProcessWithoutNullStreams | null): Promise { + 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; @@ -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 () => { diff --git a/__tests__/mcp-roots.test.ts b/__tests__/mcp-roots.test.ts index 8e1d4520d..5eea9272b 100644 --- a/__tests__/mcp-roots.test.ts +++ b/__tests__/mcp-roots.test.ts @@ -72,6 +72,20 @@ function send(child: ChildProcessWithoutNullStreams, msg: object): void { child.stdin.write(JSON.stringify(msg) + '\n'); } +function stopServer(child: ChildProcessWithoutNullStreams | null): Promise { + 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)', () => { @@ -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 () => { diff --git a/__tests__/pr19-improvements.test.ts b/__tests__/pr19-improvements.test.ts index 6741e905e..297407b62 100644 --- a/__tests__/pr19-improvements.test.ts +++ b/__tests__/pr19-improvements.test.ts @@ -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 === '' + ); + 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 { diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 99c7f9aaa..96fcd501b 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -122,6 +122,15 @@ const INSTANTIATION_KINDS: ReadonlySet = new Set([ 'instance_creation_expression', // some grammars ]); +const FILE_READ_CALL_NAMES: ReadonlySet = 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 */ @@ -562,7 +571,18 @@ export class TreeSitterExtractor { } } } - if (name === '') return; // Skip anonymous functions + if (name === '') { + // 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 @@ -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('.'); } /**