diff --git a/__tests__/field-channel-scope.test.ts b/__tests__/field-channel-scope.test.ts new file mode 100644 index 000000000..4ef9725c7 --- /dev/null +++ b/__tests__/field-channel-scope.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { CodeGraph } from '../src'; +import { ToolHandler } from '../src/mcp/tools'; + +let dir: string; +let cg: CodeGraph | undefined; +afterEach(() => { + cg?.close(); + cg = undefined; + if (dir) fs.rmSync(dir, { recursive: true, force: true }); +}); + +async function index(files: Record) { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-field-scope-')); + files['store.ts'] = `export class Store { + handlers = new Set(); + subscribe(cb: Function) { this.handlers.add(cb); } + emit() { this.handlers.forEach(h => h()); } +}`; + for (const [file, source] of Object.entries(files)) fs.writeFileSync(path.join(dir, file), source); + cg = CodeGraph.initSync(dir); + await cg.indexAll(); + await cg.resolveReferencesBatched(); + const emit = cg.getNodesByName('emit').find(n => n.qualifiedName === 'Store::emit')!; + return cg.getOutgoingEdges(emit.id).filter(e => e.metadata?.synthesizedBy === 'callback'); +} + +const real = `import { Store } from './store'; +export class Real { + store = new Store(); + init() { this.store.subscribe(this.triggerRender); } + triggerRender() { return 'real'; } +}`; +const decoy = `export class Decoy { triggerRender() { return 'decoy'; } }`; + +describe('field-channel registration scope (#1355)', () => { + it.each(['ts', 'tsx', 'js', 'jsx'])('uses the registered owner with a decoy first (%s)', async ext => { + const file = `z_real.${ext}`; + const edges = await index({ [`a_decoy.${ext}`]: decoy, [file]: real }); + expect(edges).toHaveLength(1); + expect(cg!.getNode(edges[0]!.target)).toMatchObject({ qualifiedName: 'Real::triggerRender', filePath: file }); + expect(edges[0]).toMatchObject({ provenance: 'heuristic', metadata: { registeredAt: `${file}:4` } }); + const result = await new ToolHandler(cg!).execute('codegraph_explore', { query: 'Store.emit Real.triggerRender' }); + const text = result.content?.[0]?.text ?? ''; + expect(text).toContain(`emit → triggerRender [dynamic: callback via \`subscribe\` @${file}:4]`); + expect(text).toContain(`${file}:4`); + }); + + it('keeps the target when file and insertion order are reversed', async () => { + const edges = await index({ 'a_real.ts': real, 'z_decoy.ts': decoy }); + expect(edges).toHaveLength(1); + expect(cg!.getNode(edges[0]!.target)?.qualifiedName).toBe('Real::triggerRender'); + }); + + it('distinguishes owners in the same file', async () => { + const edges = await index({ 'real.ts': `${decoy}\n${real}` }); + expect(edges).toHaveLength(1); + expect(cg!.getNode(edges[0]!.target)?.qualifiedName).toBe('Real::triggerRender'); + }); + + it('follows an inherited handler in another file', async () => { + const edges = await index({ + 'a_decoy.ts': decoy, + 'base.ts': 'export class Base { triggerRender() {} }', + 'real.ts': `import { Base } from './base';\n${real.replace('class Real {', 'class Real extends Base {').replace(" triggerRender() { return 'real'; }", '')}`, + }); + expect(edges).toHaveLength(1); + expect(cg!.getNode(edges[0]!.target)).toMatchObject({ qualifiedName: 'Base::triggerRender', filePath: 'base.ts' }); + }); + + it('follows an imported alias instead of a globally matching name', async () => { + const edges = await index({ + 'a_decoy.ts': 'export function handler() {}', + 'handlers.ts': 'export function render() {}', + 'real.ts': `import { Store } from './store'; +import { render as handler } from './handlers'; +export function wire() { const store = new Store(); store.subscribe(handler); }`, + }); + expect(edges).toHaveLength(1); + expect(cg!.getNode(edges[0]!.target)).toMatchObject({ name: 'render', filePath: 'handlers.ts' }); + }); + + it('uses a local bare function', async () => { + const edges = await index({ + 'a_decoy.ts': 'export function handler() {}', + 'real.ts': `import { Store } from './store'; +function handler() {} +export function wire() { const store = new Store(); store.subscribe(handler); }`, + }); + expect(edges).toHaveLength(1); + expect(cg!.getNode(edges[0]!.target)?.filePath).toBe('real.ts'); + }); + + it.each(['this.triggerRender', 'triggerRender', 'other.triggerRender', 'this.triggerRender()'])('does not guess an unknown or non-value handler: %s', async arg => { + const edges = await index({ + 'a_decoy.ts': decoy, + 'real.ts': real.replace('this.triggerRender);', `${arg});`).replace(" triggerRender() { return 'real'; }", ''), + }); + expect(edges).toEqual([]); + }); +}); diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index dd2e735fb..fad6c0f01 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -168,7 +168,7 @@ async function fieldChannelEdges(queries: QueryBuilder, ctx: ResolutionContext, (d) => d.node.filePath === reg.node.filePath && d.field === reg.field ); if (chDispatchers.length === 0) continue; - const argRe = new RegExp(`${reg.node.name}\\s*\\(\\s*(?:this\\.)?(\\w+)`); + const argRe = new RegExp(`${reg.node.name}\\s*\\(\\s*(this\\.\\w+|\\w+)\\s*(?=[,)])`); let added = 0; for (const e of queries.getIncomingEdges(reg.node.id, ['calls'])) { if (added >= MAX_CALLBACKS_PER_CHANNEL) break; @@ -178,8 +178,15 @@ async function fieldChannelEdges(queries: QueryBuilder, ctx: ResolutionContext, const line = ctx.readFile(caller.filePath)?.split('\n')[e.line - 1]; const am = line?.match(argRe); if (!am) continue; - const fn = ctx.getNodesByName(am[1]!).find((n) => n.kind === 'method' || n.kind === 'function'); - if (!fn) continue; + // Reuse the resolved value at this registration site: it retains the + // receiver's class/inheritance and import binding, unlike a name lookup. + const refs = queries.getOutgoingEdges(caller.id, ['references']).filter( + (r) => r.line === e.line && r.metadata?.fnRef === true && r.metadata.refName === am[1] + ); + if (refs.length !== 1) continue; + const fn = queries.getNodeById(refs[0]!.target); + if (!fn || (fn.kind !== 'method' && fn.kind !== 'function')) continue; + if (!am[1]!.startsWith('this.') && fn.filePath !== caller.filePath && refs[0]!.metadata?.resolvedBy !== 'import') continue; for (const disp of chDispatchers) { if (disp.node.id === fn.id) continue; const key = `${disp.node.id}>${fn.id}`;