| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203 |
- /**
- * Callback / observer edge synthesis — Phase 1 + 2.
- *
- * Closes dynamic-dispatch holes where a dispatcher invokes callbacks registered
- * elsewhere. Two channel shapes:
- *
- * (1) Field-backed observer (Phase 1):
- * onUpdate(cb) { this.callbacks.add(cb); } // registrar
- * triggerUpdate() { for (cb of this.callbacks) cb(); } // dispatcher
- * scene.onUpdate(this.triggerRender) // registration
- * → synthesize triggerUpdate → triggerRender
- *
- * (2) String-keyed EventEmitter (Phase 2):
- * this.on('mount', function onmount(){...}) // registration
- * fn.emit('mount', this) // dispatch
- * → synthesize (method containing emit('mount')) → onmount
- *
- * Whole-graph pass after base resolution. High-precision/low-recall by design:
- * named callbacks only; field channels paired by file+field; EventEmitter
- * channels capped by event fan-out (generic names like 'error' skipped — they
- * need receiver-type matching, deferred to Phase 3). All synthesized edges are
- * tagged `provenance:'heuristic'`. See docs/design/callback-edge-synthesis.md.
- */
- import type { Edge, Node } from '../types';
- import type { QueryBuilder } from '../db/queries';
- import type { ResolutionContext } from './types';
- const REGISTRAR_NAME = /^(on[A-Z]\w*|subscribe|addListener|addEventListener|register|watch|listen|addCallback)$/;
- const DISPATCHER_NAME = /(emit|trigger|notify|dispatch|fire|publish|flush)/i;
- const MAX_CALLBACKS_PER_CHANNEL = 40;
- const EVENT_FANOUT_CAP = 6; // skip events with more handlers/dispatchers than this (too generic without type info)
- const ON_RE = /\.(?:on|once|addListener)\(\s*['"]([^'"]+)['"]\s*,\s*(?:function\s+(\w+)|(?:this\.)?(\w+))/g;
- const EMIT_RE = /\.(?:emit|fire|dispatchEvent)\(\s*['"]([^'"]+)['"]/g;
- function sliceLines(content: string, startLine?: number, endLine?: number): string | null {
- if (!startLine || !endLine) return null;
- return content.split('\n').slice(startLine - 1, endLine).join('\n');
- }
- function registrarField(src: string): string | null {
- const m = src.match(/this\.(\w+)\.(?:add|push|set)\(/);
- return m ? m[1]! : null;
- }
- function dispatcherField(src: string): string | null {
- const forOf = src.match(/\bof\s+(?:Array\.from\(\s*)?this\.(\w+)/);
- if (forOf && /\b\w+\s*\(/.test(src)) return forOf[1]!;
- const forEach = src.match(/this\.(\w+)\.forEach\(/);
- if (forEach) return forEach[1]!;
- return null;
- }
- const FN_KINDS = new Set(['method', 'function', 'component']);
- /** Innermost function/method node whose line range contains `line`. */
- function enclosingFn(nodesInFile: Node[], line: number): Node | null {
- let best: Node | null = null;
- for (const n of nodesInFile) {
- if (!FN_KINDS.has(n.kind)) continue;
- const end = n.endLine ?? n.startLine;
- if (n.startLine <= line && end >= line) {
- if (!best || n.startLine >= best.startLine) best = n; // prefer the tightest (latest-starting) encloser
- }
- }
- return best;
- }
- /** Phase 1: field-backed observer channels (registrar/dispatcher share a store). */
- function fieldChannelEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[] {
- const candidates = [...queries.getNodesByKind('method'), ...queries.getNodesByKind('function')];
- const registrars: Array<{ node: Node; field: string }> = [];
- const dispatchers: Array<{ node: Node; field: string }> = [];
- for (const m of candidates) {
- const isReg = REGISTRAR_NAME.test(m.name);
- const isDisp = DISPATCHER_NAME.test(m.name);
- if (!isReg && !isDisp) continue;
- const content = ctx.readFile(m.filePath);
- const src = content && sliceLines(content, m.startLine, m.endLine);
- if (!src) continue;
- if (isReg) { const f = registrarField(src); if (f) registrars.push({ node: m, field: f }); }
- if (isDisp) { const f = dispatcherField(src); if (f) dispatchers.push({ node: m, field: f }); }
- }
- const edges: Edge[] = [];
- const seen = new Set<string>();
- for (const reg of registrars) {
- const chDispatchers = dispatchers.filter(
- (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+)`);
- let added = 0;
- for (const e of queries.getIncomingEdges(reg.node.id, ['calls'])) {
- if (added >= MAX_CALLBACKS_PER_CHANNEL) break;
- if (!e.line) continue;
- const caller = queries.getNodeById(e.source);
- if (!caller) continue;
- 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;
- for (const disp of chDispatchers) {
- if (disp.node.id === fn.id) continue;
- const key = `${disp.node.id}>${fn.id}`;
- if (seen.has(key)) continue;
- seen.add(key);
- edges.push({
- source: disp.node.id, target: fn.id, kind: 'calls', line: disp.node.startLine,
- provenance: 'heuristic',
- metadata: {
- synthesizedBy: 'callback', via: reg.node.name, field: reg.field,
- // Where the callback was wired up (`scene.onUpdate(this.triggerRender)`).
- // This is the #1 thing an agent reads/greps to explain the flow — surface
- // it so node/trace/context can show it without a callers() + Read round-trip.
- registeredAt: `${caller.filePath}:${e.line}`,
- },
- });
- added++;
- }
- }
- }
- return edges;
- }
- /** Phase 2: string-keyed EventEmitter channels (on('e', fn) ↔ emit('e')). */
- function eventEmitterEdges(ctx: ResolutionContext): Edge[] {
- const emitsByEvent = new Map<string, Set<string>>(); // event → dispatcher node ids
- const handlersByEvent = new Map<string, Map<string, string>>(); // event → handler id → registration site (file:line)
- for (const file of ctx.getAllFiles()) {
- const content = ctx.readFile(file);
- if (!content) continue;
- const hasEmit = content.includes('.emit(') || content.includes('.fire(') || content.includes('.dispatchEvent(');
- const hasOn = content.includes('.on(') || content.includes('.once(') || content.includes('.addListener(');
- if (!hasEmit && !hasOn) continue;
- const nodesInFile = ctx.getNodesInFile(file);
- const lineOf = (idx: number) => content.slice(0, idx).split('\n').length;
- if (hasEmit) {
- EMIT_RE.lastIndex = 0;
- let m: RegExpExecArray | null;
- while ((m = EMIT_RE.exec(content))) {
- const disp = enclosingFn(nodesInFile, lineOf(m.index));
- if (!disp) continue;
- const set = emitsByEvent.get(m[1]!) ?? new Set<string>();
- set.add(disp.id); emitsByEvent.set(m[1]!, set);
- }
- }
- if (hasOn) {
- ON_RE.lastIndex = 0;
- let m: RegExpExecArray | null;
- while ((m = ON_RE.exec(content))) {
- const handlerName = m[2] || m[3];
- if (!handlerName) continue;
- const handler = ctx.getNodesByName(handlerName).find((n) => n.kind === 'function' || n.kind === 'method');
- if (!handler) continue;
- const map = handlersByEvent.get(m[1]!) ?? new Map<string, string>();
- map.set(handler.id, `${file}:${lineOf(m.index)}`); handlersByEvent.set(m[1]!, map);
- }
- }
- }
- const edges: Edge[] = [];
- const seen = new Set<string>();
- for (const [event, dispatchers] of emitsByEvent) {
- const handlers = handlersByEvent.get(event);
- if (!handlers) continue;
- // Precision guard: a generic event name with many handlers/dispatchers can't
- // be matched without receiver-type info (Phase 3) — skip rather than over-link.
- if (dispatchers.size > EVENT_FANOUT_CAP || handlers.size > EVENT_FANOUT_CAP) continue;
- for (const d of dispatchers) for (const [h, registeredAt] of handlers) {
- if (d === h) continue;
- const key = `${d}>${h}`;
- if (seen.has(key)) continue;
- seen.add(key);
- edges.push({ source: d, target: h, kind: 'calls', provenance: 'heuristic', metadata: { synthesizedBy: 'event-emitter', event, registeredAt } });
- }
- }
- return edges;
- }
- /**
- * Synthesize dispatcher→callback edges (field observers + EventEmitters).
- * Returns the count added. Never throws into indexing — callers wrap in try/catch.
- */
- export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionContext): number {
- const fieldEdges = fieldChannelEdges(queries, ctx);
- const emitterEdges = eventEmitterEdges(ctx);
- const merged: Edge[] = [];
- const seen = new Set<string>();
- for (const e of [...fieldEdges, ...emitterEdges]) {
- const key = `${e.source}>${e.target}`;
- if (seen.has(key)) continue;
- seen.add(key);
- merged.push(e);
- }
- if (merged.length > 0) queries.insertEdges(merged);
- return merged.length;
- }
|