mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-18 22:02:49 +08:00
Field-channel synthesis discarded receiver and binding identity when selecting handlers globally by name. Reuse resolved function-reference edges at the registration site, retaining class, inheritance, and import resolution without global fallback. Add regression coverage for target identity, unknown handlers, and explore registration metadata. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, string>) {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-field-scope-'));
|
||||
files['store.ts'] = `export class Store {
|
||||
handlers = new Set<Function>();
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
|
||||
Reference in New Issue
Block a user