|
|
@@ -0,0 +1,203 @@
|
|
|
+/**
|
|
|
+ * 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;
|
|
|
+}
|