From feb2f641de6be9e8547098cffa7aa6312ac895a5 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sun, 21 Jun 2026 09:45:24 -0500 Subject: [PATCH] feat(resolution): bridge Laravel event(new X) to its listener handles Laravel decouples an event dispatch from its listener(s), linked by the event class: event(new OrderShipped($order)) has no static edge to the handle(OrderShipped $event) that runs it (usually a separate app/Listeners/ class). laravelEventEdges bridges each event(new X(...)) site -> every listener's handle for X. Two registration mechanisms, both real and both needed (built together): - (A) auto-discovery: a typed handle(EventType $e) first param, read from the method declaration source (PHP method nodes carry no signature, like C#); a handle(A|B $e) union is split into two events. - (B) the `protected $listen = [XEvent::class => [Listener::class, ...]]` map in an EventServiceProvider, parsed from comment-stripped source (so a fully-commented map on an auto-discovery app contributes nothing). This is the only way to link a listener whose handle() is untyped. Job exclusion is free: queued jobs dispatch via ::dispatch()/dispatch() (not matched) and their handle() takes an injected service, never an event type, so matching only event(new X) excludes them by construction. `use Dispatchable` is not keyed on (unreliable in real apps). Surfaces as `dynamic: laravel event` via the generic synth-edge fallback. Validated 100% precision on two grep-confirmed repos exercising both mechanisms: koel (small, populated $listen map, 9 edges incl. the untyped-handle case and a fan-out) and firefly-iii (large, pure auto-discovery / empty $listen, 141 edges, 0 source/target false positives, 0 namespace mismatch, union split verified); 0 on the guzzle control. Namespace-agnostic (FireflyIII\ not hardcoded). Node-stable (pure edge synth). Suite 1623 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + __tests__/laravel-event-synthesizer.test.ts | 169 ++++++++++++++++++++ docs/design/dispatch-synthesizer-backlog.md | 3 +- src/resolution/callback-synthesizer.ts | 145 ++++++++++++++++- 4 files changed, 316 insertions(+), 2 deletions(-) create mode 100644 __tests__/laravel-event-synthesizer.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f053689..c9d2099 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `codegraph_explore` now follows **Spring application events** in Java. A `publishEvent(new OrderShippedEvent(...))` call now links to every `@EventListener` that handles that event — usually in a different class — so "what reacts when this is published?" traces from the publisher straight into each listener method instead of dead-ending at `publishEvent(...)`. The link is by event type, and all the common listener styles are recognized: a `@EventListener` typed on its parameter, the `@EventListener(SomeEvent.class)` form, `@TransactionalEventListener`, and the older `implements ApplicationListener`. One event fans out to all its listeners, and a plain Spring app with no event bus is unaffected. - `codegraph_explore` now follows **MediatR** request and notification dispatch in C#/.NET. A `_mediator.Send(command)` or `_mediator.Publish(notification)` call now links to the `Handle` method of the matching `IRequestHandler<>` / `INotificationHandler<>` — usually in a different file in a Clean Architecture layout — so "what handles this command?" traces from the controller straight into the handler instead of stopping at the mediator call. The sent type is recognized whether it's constructed inline (`Send(new GetFooQuery())`), built into a local first (`var cmd = new …; Send(cmd)`), or passed in as a parameter, and it's matched by type — so a `MessagingCenter.Send(...)` or a same-named DTO that isn't a request is never mislinked, and a project without MediatR is unaffected. - `codegraph_explore` now follows **Sidekiq** background-job dispatch in Ruby. A `DestroyUserWorker.perform_async(id)` (or `.perform_in` / `.perform_at`) call now links to that worker's `perform` method — usually in `app/workers/` away from the controller or model that enqueues it — so "what runs in the background here?" traces from the enqueue straight into the job body. Both the modern `include Sidekiq::Job` and the older `Sidekiq::Worker` are recognized, namespaced workers resolve to the right class even when several share a name (e.g. `Comments::NotifyWorker` vs `Articles::NotifyWorker`), and Rails ActiveJob's `perform_later` — a different mechanism — is intentionally left alone. +- `codegraph_explore` now follows **Laravel events** in PHP. An `event(new OrderShipped($order))` call now links to every listener that handles it — each listener's `handle()` method, usually a separate `app/Listeners/` class — so "what reacts to this event?" traces from the dispatch straight into the listener bodies. Listeners are found both ways Laravel registers them: by a typed `handle(OrderShipped $event)` (auto-discovery, including a `handle(A|B $event)` union that listens for two events) and by the `protected $listen` map in your `EventServiceProvider` (which also catches a listener whose `handle()` has no type-hint). One event fans out to all its listeners, and queued jobs — dispatched via `::dispatch()` rather than `event()` — are correctly left out. - `codegraph_explore` now surfaces the right code in large multi-layer projects. When you ask a backend-flow question in a repo that pairs an API server with a big frontend that mirrors the same domain words — say an `app/` admin UI sitting over an `api/` server — the server-side file that genuinely matches several of your query's terms is no longer pushed out of the results by the larger, more interconnected frontend layer. A file corroborated by two or more distinct query terms is now kept in the answer even when a denser unrelated layer would otherwise crowd it out, so "how does X read items / handle the request" returns the service or handler that does the work instead of a wall of frontend views. Single-layer projects are unaffected; set `CODEGRAPH_RANK_NO_MULTITERM=1` to revert to the previous ranking. - Impact and blast-radius analysis for TypeScript, JavaScript, Go, Python, Rust, Ruby, C, Java, C#, PHP, Scala, Kotlin, Swift, Dart, and Pascal/Delphi now understands the readers of a constant. When you change a file-scope, package-level, module-level, or class-level constant — a config object, a lookup table, a shared constant — the other symbols in that file that read it now show up as affected, where before they were invisible (impact only followed calls, imports, and inheritance, so a constant's consumers looked like "nothing depends on this"). This makes `codegraph impact`, and the impact trail in `codegraph_explore`/`codegraph_node`, catch the "change this table, break its readers" class of change. It's on by default and adds no nodes to your graph; bundled/minified files and ambiguously-shadowed names are skipped to keep results precise. Set `CODEGRAPH_VALUE_REFS=0` to turn it off. diff --git a/__tests__/laravel-event-synthesizer.test.ts b/__tests__/laravel-event-synthesizer.test.ts new file mode 100644 index 0000000..220af85 --- /dev/null +++ b/__tests__/laravel-event-synthesizer.test.ts @@ -0,0 +1,169 @@ +/** + * Laravel event-dispatch bridge (PHP). + * + * Laravel decouples an event dispatch from its listener(s), linked by the event class: + * `event(new SongLiked($id))` has no static edge to the `handle(SongLiked $e)` that runs it + * (usually a separate `app/Listeners/` file). This bridges each `event(new X(...))` site to every + * listener's `handle` for X, via TWO registration mechanisms: (A) a typed `handle(EventType $e)` + * (auto-discovery, union-split for `A|B`) and (B) the `protected $listen` map in an + * EventServiceProvider (which also covers a listener whose `handle()` is untyped). Queued JOBS + * dispatch via `::dispatch()`/`dispatch()` and their `handle()` takes a service — so only + * `event(new X)` is matched and jobs are excluded. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { CodeGraph } from '../src'; + +describe('laravel-event synthesizer', () => { + let dir: string; + beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'laravel-event-')); }); + afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); }); + + const write = (rel: string, body: string) => { + const p = path.join(dir, rel); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, body); + }; + + it('bridges event(new X) to listener handles via typed handles, the $listen map, unions, and fan-out; excludes jobs', async () => { + for (const [name, body] of [ + ['SongLiked', 'public int $id; public function __construct(int $id) { $this->id = $id; }'], + ['LibraryChanged', ''], + ['ScanDone', ''], + ['OwnerTest', ''], + ['UserTest', ''], + ] as const) { + write(`app/Events/${name}.php`, ` [ + PruneLibrary::class, + ], + ]; +} +`); + write('app/Services/SongService.php', ` edges.filter((r: any) => r.source === s); + const file = (r: any) => /(\w+)\.php$/.exec(r.tf)![1]; + + expect(edges.length).toBe(6); + expect(edges.every((r: any) => r.target === 'handle')).toBe(true); + // (A) typed handle. + expect(bySrc('like').map((r: any) => [r.via, file(r)])).toEqual([['SongLiked', 'LoveTrack']]); + // (B) untyped handle via the $listen map. + expect(bySrc('deleteSongs').map((r: any) => [r.via, file(r)])).toEqual([['LibraryChanged', 'PruneLibrary']]); + // Fan-out: ScanDone → both listeners. + expect(new Set(bySrc('scan').map(file))).toEqual(new Set(['WriteScanLog', 'DeleteStale'])); + // Union split: OwnerTest and UserTest each reach the one listener (separate dispatchers, + // so they aren't deduped to a single source→target edge). + expect(bySrc('ownerTest').map((r: any) => [r.via, file(r)])).toEqual([['OwnerTest', 'SendsTestNotification']]); + expect(bySrc('userTest').map((r: any) => [r.via, file(r)])).toEqual([['UserTest', 'SendsTestNotification']]); + // PRECISION: a queued job (::dispatch / dispatch()) produces nothing. + expect(edges.some((r: any) => r.source === 'process')).toBe(false); + + cg.close?.(); + }); + + it('produces no edges in a PHP project with no Laravel events (clean control)', async () => { + write('src/Client.php', `.task`/`@task` def + `.delay()`/`.apply_async()` call → task body | S (decorator-gated name) | ✅ **SHIPPED (2026-06-20)** — `synthesizedBy:'celery-dispatch'` (`celeryDispatchEdges`). Link the enclosing fn at each `.delay(`/`.apply_async(` site → the task fn. Precision rests on the DECORATOR gate: the dispatched name must resolve to a Python `function` carrying a task decorator, read from the source lines ABOVE its `def` (the def's own startLine excludes the decorator; no `decorates` edge exists — `@shared_task` is an unresolved external import). `kind==='function'` filter drops the same-named test-method collision (`consume_file`). Canvas forms (`group(t).delay()`, `t.s()`/`.si()`) have no single identifier before `.delay` → skipped, not mis-bridged. Cross-module name collision → same-file preference else bail. **100% precision: paperless-ngx (small, `@shared_task`, 31 edges, 31/31 real), pretix (medium, `@app.task`, 63 edges across 21 tasks, 0/21 FP); 0 on the httpie control (no Celery).** Node-stable (pure edge synth, no extraction change). Surfaces as `dynamic: celery dispatch @site` via the generic fallback. `+ celery-dispatch-synthesizer.test.ts`. **Deferred (recall):** canvas dispatch, class-based `Task` subclasses, `app.send_task('dotted.name')` string dispatch, aliased imports (`import send_email as s; s.delay()`). | | **Sidekiq** | Ruby | `W.perform_async(...)`/`.perform_in`/`.perform_at` → `W#perform`, gated on `include Sidekiq::Job`/`Worker` | S (name-keyed class→perform) | ✅ **SHIPPED (2026-06-20)** — `synthesizedBy:'sidekiq-dispatch'` (`sidekiqDispatchEdges`). Name-keyed (like Celery): link the enclosing method at each `Worker.perform_async/_in/_at(…)` site → the worker's instance `perform`. The receiver class must be a Sidekiq worker — gated by reading `include Sidekiq::Job|Worker` from the class BODY source (the mixin is an external gem module → no resolvable edge, like Celery's decorator / Spring's annotation). **Namespace disambiguation (the n>1 fix):** loomio's flat workers hid a collision bug forem exposed — 4 `SendEmailNotificationWorker`s across modules; simple-name resolution mis-targeted 7/143 edges to the wrong namespace. Fixed by resolving a namespaced ref (`Comments::SendEmailNotificationWorker`) via EXACT `getNodesByQualifiedName` first, falling back to simple-name only for a unique worker (ambiguous unqualified collision bails). ActiveJob's `perform_later`/`_now` deliberately NOT matched (different shape → ActiveJob-only app yields 0). **100% precision: loomio (medium, `Sidekiq::Worker`, 47 edges) + forem (large, both aliases — 131 `Sidekiq::Job` + 11 `Sidekiq::Worker`, 142 edges, 0 worker-FP, 0 source-FP, 0 namespace-mismatch); 0 on the jekyll control.** Node-stable. Surfaces `dynamic: sidekiq dispatch`. `+ sidekiq-dispatch-synthesizer.test.ts`. **Deferred (recall):** the superclass-chain variant (diaspora: `class Foo < Base` where only `Base` has the include — worker detection must follow `< Base`), the `Jobs.enqueue(:sym)` facade (Discourse), dispatch from non-method contexts (admin DSL blocks → no enclosing method). | | **Spring events** | Java | `publishEvent(new XEvent(…))` → `@EventListener`/`@TransactionalEventListener`/`ApplicationListener` by event type | S (type-keyed, 2-pass) | ✅ **SHIPPED (2026-06-20)** — `synthesizedBy:'spring-event'` (`springEventEdges`). Pass 1 builds `Map` — listeners are `@EventListener`/`@TransactionalEventListener` methods (event type = the first param type off the node `signature`, or the `@EventListener(X.class)` value form) + `class … implements ApplicationListener` `onApplicationEvent` methods (name + file `ApplicationListener<` gate). Pass 2 links each `publishEvent(new XEvent(…))` site's enclosing method → every listener of XEvent. **KEY Java fact:** a method node's range INCLUDES its leading annotations (`startLine` = first `@…` line, NOT the `public void` decl), so the annotation gate scans DOWNWARD from startLine, bounded to consecutive `@`-lines (no bleed into an adjacent method). Keyed by EXACT type name, no name resolution — precision is structural (param type ↔ `new X` type). Multi-line `publishEvent(\n new X(…))` handled (`\s*` spans newlines). **100% precision: halo (medium, 1254 java, 33 edges across 24 events, 0 publisher/listener FP, all 3 listener forms + fan-out) + thombergs/code-examples (4 edges incl. the `@TransactionalEventListener` form halo lacks); 0 on the gson control (no Spring).** Node-stable (pure edge synth). Surfaces `dynamic: spring event @site`. `+ spring-event-synthesizer.test.ts`. **Deferred (recall):** `publishEvent(bareVar)` (needs the var's declared type), Spring's listener-return-value re-publish, `@DomainEvents`/`AbstractAggregateRoot.registerEvent`, generic `PayloadApplicationEvent` params. | -| **Laravel events** | PHP | `event(OrderShipped::class)` → `EventServiceProvider` `$listen` map; `Event::dispatch(...)` → listener | R (mapped) | ⬜ — the PHP sibling; build next-to-it (grep-confirm ≥2 repos with a populated `$listen` array). | +| **Laravel events** | PHP | `event(new XEvent(...))` → each listener's `handle`, via a typed `handle(XEvent $e)` AND the `$listen` map | X+S (two registration sources) | ✅ **SHIPPED (2026-06-21)** — `synthesizedBy:'laravel-event'` (`laravelEventEdges`). Pass 1 builds `Map` from BOTH mechanisms (both real, both needed): **(A)** a typed listener `handle(EventType $e)` first param (read from the method decl source — PHP has no `signature`, like C#; splits a `handle(A|B $e)` UNION into two events); **(B)** the `protected $listen = [ XEvent::class => [Listener::class, …] ]` map in an EventServiceProvider — parsed from comment-stripped source (so firefly's fully-commented map on auto-discovery contributes nothing), keys/values as `::class` or string literals — which is the ONLY way to link a listener whose `handle()` is UNTYPED (koel's `PruneLibrary`). Pass 2 links each `event(new XEvent(...))` site → every handle of XEvent. **Job exclusion is free:** queued jobs dispatch via `::dispatch()`/`dispatch()` (not matched) and their `handle()` takes an injected service (never an event type) — so matching ONLY `event(new X)` excludes them by construction; no job-vs-event ambiguity. `use Dispatchable` is NOT keyed on (unreliable — koel 1/9, firefly 5/50 events use it). **100% precision: koel (small, populated `$listen` map, 9 edges incl. the untyped-handle case + a fan-out) + firefly-iii (large, pure auto-discovery / empty `$listen`, 141 edges, 0 source/target FP, 0 namespace-mismatch via use-import check, union split verified); 0 on the guzzle control.** Namespace-agnostic (`FireflyIII\` not hardcoded). Node-stable. Surfaces `dynamic: laravel event`. `+ laravel-event-synthesizer.test.ts`. **Deferred (recall):** `XEvent::dispatch()` static-trait dispatch (neither repo uses it for events — would reintroduce job ambiguity), `Event::listen(closure)`, string-literal `$listen` keys for framework events (parsed but never `event(new)`-dispatched), event simple-name collisions across namespaces (none in the corpus — add qualified disambiguation like Sidekiq if a repo needs it). | ### Tier C — frontier, ⛔ do **not** build (no static anchor; would add noise) @@ -90,6 +90,7 @@ Status legend (matches the playbook): ✅ done+validated · 🟡 shipped but und | Spring events | `spring-event` | ✅ **shipped (2026-06-20)** — `publishEvent(new XEvent)` → `@EventListener`/`@TransactionalEventListener`/`ApplicationListener` by event type; 100% precision halo (33 across 24 events) / code-examples (4), 0 on gson control. Type-keyed 2-pass, no name resolution. | | MediatR | `mediatr-dispatch` | ✅ **shipped (2026-06-20)** — `_mediator.Send(x)`/`.Publish(x)` → the `Handle` of `IRequestHandler`/`INotificationHandler` by request type; 100% precision jasontaylor (9) / eShop (9, variable-passed), 0 on Newtonsoft control. Type from class base-list (C# has no signature) + arg resolved inline/local/param; receiver + handler-map gates. | | Sidekiq | `sidekiq-dispatch` | ✅ **shipped (2026-06-20)** — `W.perform_async/_in/_at(…)` → `W#perform`, gated on `include Sidekiq::Job`/`Worker`; 100% precision loomio (47) / forem (142, both aliases), 0 on jekyll control. Name-keyed; namespaced collisions disambiguated by qualified name; ActiveJob `perform_later` excluded. | +| Laravel events | `laravel-event` | ✅ **shipped (2026-06-21)** — `event(new XEvent)` → each listener's `handle`, via typed `handle(XEvent $e)` (auto-discovery, union-split) AND the `$listen` map (covers untyped handles); 100% precision koel (9, `$listen`) / firefly (141, auto-discovery), 0 on guzzle control. Jobs excluded (they use `::dispatch`). | | (see playbook §6 / `callback-synthesizer.ts` for the other ~20 channels) | | | ### redux-thunk follow-ups (found by the n>1 validation — this is exactly what it's for) diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index e5e0eb2..382aecd 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -2504,6 +2504,147 @@ function sidekiqDispatchEdges(ctx: ResolutionContext): Edge[] { return edges; } +// ── Laravel events (PHP) ────────────────────────────────────────────────────── +// Laravel decouples an event dispatch from its listener(s), linked by the EVENT CLASS: +// // app/Events/PlaybackStarted.php + app/Listeners/UpdateLastfmNowPlaying.php +// class UpdateLastfmNowPlaying { public function handle(PlaybackStarted $event) { … } } +// // a controller / service — a DIFFERENT file +// event(new PlaybackStarted($song, $user)); +// Bridge it: link the enclosing method at each `event(new XEvent(...))` site → every listener's +// `handle` for XEvent. Listeners come from TWO registration mechanisms (both real, both needed): +// (A) auto-discovery — a `handle(EventType $e)` typed first param (also splits a union A|B); +// (B) the `protected $listen = [ XEvent::class => [Listener::class, …] ]` map in an +// EventServiceProvider, which also covers a listener whose `handle()` is UNTYPED. +// Only `event(new X)` is matched — queued JOBS dispatch via `::dispatch()` and their `handle()` +// takes an injected service, never an event type, so jobs are excluded by construction. +const LARAVEL_DISPATCH_RE = /\bevent\s*\(\s*new\s+\\?([A-Za-z_][\w\\]*)/g; +const LARAVEL_PHP_EXT = /\.php$/; +const LARAVEL_FANOUT_CAP = 200; +// A `$listen` entry: `Event::class => [Listener::class, …]`, key/values as `::class` or strings. +const LISTEN_ENTRY_RE = /(?:([A-Za-z_\\][\w\\]*)::class|'([^']+)'|"([^"]+)")\s*=>\s*\[([^\]]*)\]/g; +const LISTEN_CLASS_RE = /(?:([A-Za-z_\\][\w\\]*)::class|'([^']+)'|"([^"]+)")/g; + +/** Short class name from a PHP reference: `\App\Events\Foo` / `App\Events::Foo` → `Foo`. */ +function phpSimpleName(s: string): string { + return s.replace(/^\\/, '').split('\\').pop()!.split('::').pop()!.trim(); +} + +/** The first-parameter class type(s) of a `handle(...)` declaration — union-split, short-named, + * primitives dropped. `handle(A|B $e)` → [A, B]; `handle(string $x)` / `handle()` → []. */ +function laravelHandleEventTypes(decl: string): string[] { + const m = /function\s+handle\s*\(\s*(?:\.\.\.\s*)?(\??[A-Za-z_\\][\w\\|]*)\s+&?\s*(?:\.\.\.\s*)?\$/.exec(decl); + if (!m) return []; + return m[1]! + .replace(/^\?/, '') + .split('|') + .map((t) => phpSimpleName(t)) + .filter((t) => /^[A-Z]\w*$/.test(t)); +} + +/** From an opening `[`, the bracket-balanced body up to its matching `]`. */ +function phpArrayBody(src: string, openIdx: number): string | null { + let depth = 0; + for (let i = openIdx; i < src.length; i++) { + if (src[i] === '[') depth++; + else if (src[i] === ']' && --depth === 0) return src.slice(openIdx + 1, i); + } + return null; +} + +function laravelEventEdges(ctx: ResolutionContext): Edge[] { + // event short name → its listener `handle` methods (deduped by node id). + const listeners = new Map>(); + const add = (event: string, handle: Node) => { + let m = listeners.get(event); + if (!m) { m = new Map(); listeners.set(event, m); } + m.set(handle.id, handle); + }; + const handleOf = (cls: Node): Node | null => + ctx + .getNodesInFile(cls.filePath) + .find( + (n) => n.kind === 'method' && n.name === 'handle' + && n.startLine >= cls.startLine && n.startLine <= (cls.endLine ?? cls.startLine) + ) ?? null; + + // Pass 1 — build the event→handle map from both registration mechanisms. + for (const file of ctx.getAllFiles()) { + if (!LARAVEL_PHP_EXT.test(file)) continue; + const content = ctx.readFile(file); + if (!content) continue; + + // (A) typed listener handles — node-driven, so a commented-out method can't leak in. + if (content.includes('function handle')) { + const lines = content.split('\n'); + for (const node of ctx.getNodesInFile(file)) { + if (node.kind !== 'method' || node.name !== 'handle') continue; + const decl = lines.slice(node.startLine - 1, node.startLine + 2).join('\n'); + for (const ev of laravelHandleEventTypes(decl)) add(ev, node); + } + } + + // (B) the EventServiceProvider `$listen` map — parsed from comment-stripped source so a + // fully-commented map (firefly's, on auto-discovery) contributes nothing. + if (content.includes('$listen')) { + const safe = stripCommentsForRegex(content, 'php'); + const decl = safe.search(/\$listen\s*=\s*\[/); + const body = decl >= 0 ? phpArrayBody(safe, safe.indexOf('[', decl)) : null; + if (body) { + LISTEN_ENTRY_RE.lastIndex = 0; + let em: RegExpExecArray | null; + while ((em = LISTEN_ENTRY_RE.exec(body))) { + const event = phpSimpleName(em[1] ?? em[2] ?? em[3] ?? ''); + LISTEN_CLASS_RE.lastIndex = 0; + let lm: RegExpExecArray | null; + while ((lm = LISTEN_CLASS_RE.exec(em[4]!))) { + const ln = phpSimpleName(lm[1] ?? lm[2] ?? lm[3] ?? ''); + const cls = ctx.getNodesByName(ln).find((n) => n.kind === 'class' && handleOf(n)); + if (cls) add(event, handleOf(cls)!); + } + } + } + } + } + if (!listeners.size) return []; + + // Pass 2 — link each event(new X(...)) site → every listener of X. + const edges: Edge[] = []; + const seen = new Set(); + for (const file of ctx.getAllFiles()) { + if (!LARAVEL_PHP_EXT.test(file)) continue; + const content = ctx.readFile(file); + if (!content || !content.includes('event(')) continue; + const safe = stripCommentsForRegex(content, 'php'); + const nodesInFile = ctx.getNodesInFile(file); + LARAVEL_DISPATCH_RE.lastIndex = 0; + let m: RegExpExecArray | null; + let added = 0; + while ((m = LARAVEL_DISPATCH_RE.exec(safe)) && added < LARAVEL_FANOUT_CAP) { + const targets = listeners.get(phpSimpleName(m[1]!)); + if (!targets) continue; + const line = safe.slice(0, m.index).split('\n').length; + const disp = enclosingFn(nodesInFile, line); + if (!disp) continue; + for (const target of targets.values()) { + if (target.id === disp.id) continue; + const key = `${disp.id}>${target.id}`; + if (seen.has(key)) continue; + seen.add(key); + edges.push({ + source: disp.id, + target: target.id, + kind: 'calls', + line, + provenance: 'heuristic', + metadata: { synthesizedBy: 'laravel-event', via: phpSimpleName(m[1]!), registeredAt: `${file}:${line}` }, + }); + added++; + } + } + } + return edges; +} + /** * Synthesize dispatcher→callback edges (field observers + EventEmitters + * React re-render + JSX children + Vue templates + SvelteKit load + RN event @@ -2512,7 +2653,7 @@ function sidekiqDispatchEdges(ctx: ResolutionContext): Edge[] { * generated-hook → endpoint + Pinia useStore().action() + Vuex string dispatch + * Celery task .delay()/.apply_async() → task body + Spring publishEvent → @EventListener + * MediatR Send/Publish → IRequestHandler/INotificationHandler + - * Sidekiq Worker.perform_async → #perform). + * Sidekiq Worker.perform_async → #perform + Laravel event(new X) → listener handle). * Returns the count added. Never throws into indexing — callers wrap in try/catch. */ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionContext): number { @@ -2559,6 +2700,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo const springEdges = springEventEdges(ctx); const mediatrEdges = mediatrDispatchEdges(ctx); const sidekiqEdges = sidekiqDispatchEdges(ctx); + const laravelEdges = laravelEventEdges(ctx); const merged: Edge[] = []; const seen = new Set(); @@ -2591,6 +2733,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo ...springEdges, ...mediatrEdges, ...sidekiqEdges, + ...laravelEdges, ]) { const key = `${e.source}>${e.target}`; if (seen.has(key)) continue;