Phase 1: field-backed observer channels (registrar/dispatcher share a store).
(queries: QueryBuilder, ctx: ResolutionContext, onYield: MaybeYield)
| 177 | |
| 178 | /** Phase 1: field-backed observer channels (registrar/dispatcher share a store). */ |
| 179 | async function fieldChannelEdges(queries: QueryBuilder, ctx: ResolutionContext, onYield: MaybeYield): Promise<Edge[]> { |
| 180 | const registrars: Array<{ node: Node; field: string }> = []; |
| 181 | const dispatchers: Array<{ node: Node; field: string }> = []; |
| 182 | |
| 183 | let scanned = 0; |
| 184 | for (const m of methodAndFunctionNodes(queries)) { |
| 185 | if ((++scanned & 255) === 0) await onYield(); // #1091: yield mid-scan on huge graphs |
| 186 | const isReg = REGISTRAR_NAME.test(m.name); |
| 187 | const isDisp = DISPATCHER_NAME.test(m.name); |
| 188 | if (!isReg && !isDisp) continue; |
| 189 | const content = ctx.readFile(m.filePath); |
| 190 | const src = content && sliceLines(content, m.startLine, m.endLine); |
| 191 | if (!src) continue; |
| 192 | if (isReg) { const f = registrarField(src); if (f) registrars.push({ node: m, field: f }); } |
| 193 | if (isDisp) { const f = dispatcherField(src); if (f) dispatchers.push({ node: m, field: f }); } |
| 194 | } |
| 195 | |
| 196 | const edges: Edge[] = []; |
| 197 | const seen = new Set<string>(); |
| 198 | for (const reg of registrars) { |
| 199 | const chDispatchers = dispatchers.filter( |
| 200 | (d) => d.node.filePath === reg.node.filePath && d.field === reg.field |
| 201 | ); |
| 202 | if (chDispatchers.length === 0) continue; |
| 203 | const argRe = new RegExp(`${reg.node.name}\\s*\\(\\s*(?:this\\.)?(\\w+)`); |
| 204 | let added = 0; |
| 205 | for (const e of queries.getIncomingEdges(reg.node.id, ['calls'])) { |
| 206 | if (added >= MAX_CALLBACKS_PER_CHANNEL) break; |
| 207 | if (!e.line) continue; |
| 208 | const caller = queries.getNodeById(e.source); |
| 209 | if (!caller) continue; |
| 210 | const line = ctx.readFile(caller.filePath)?.split('\n')[e.line - 1]; |
| 211 | const am = line?.match(argRe); |
| 212 | if (!am) continue; |
| 213 | const fn = ctx.getNodesByName(am[1]!).find((n) => n.kind === 'method' || n.kind === 'function'); |
| 214 | if (!fn) continue; |
| 215 | for (const disp of chDispatchers) { |
| 216 | if (disp.node.id === fn.id) continue; |
| 217 | const key = `${disp.node.id}>${fn.id}`; |
| 218 | if (seen.has(key)) continue; |
| 219 | seen.add(key); |
| 220 | edges.push({ |
| 221 | source: disp.node.id, target: fn.id, kind: 'calls', line: disp.node.startLine, |
| 222 | provenance: 'heuristic', |
| 223 | metadata: { |
| 224 | synthesizedBy: 'callback', via: reg.node.name, field: reg.field, |
| 225 | // Where the callback was wired up (`scene.onUpdate(this.triggerRender)`). |
| 226 | // This is the #1 thing an agent reads/greps to explain the flow — surface |
| 227 | // it so node/trace/context can show it without a callers() + Read round-trip. |
| 228 | registeredAt: `${caller.filePath}:${e.line}`, |
| 229 | }, |
| 230 | }); |
| 231 | added++; |
| 232 | } |
| 233 | } |
| 234 | } |
| 235 | return edges; |
| 236 | } |
no test coverage detected