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) <noreply@anthropic.com>
This commit is contained in:
Colby McHenry
2026-06-21 09:45:24 -05:00
co-authored by Claude Opus 4.8
parent 2c522c6254
commit feb2f641de
4 changed files with 316 additions and 2 deletions
+1
View File
@@ -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<SomeEvent>`. 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.
+169
View File
@@ -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`, `<?php\nnamespace App\\Events;\nclass ${name} {\n ${body}\n}\n`);
}
// (A) typed-handle listener — auto-discovery, no $listen entry needed.
write('app/Listeners/LoveTrack.php', `<?php
namespace App\\Listeners;
use App\\Events\\SongLiked;
class LoveTrack {
public function handle(SongLiked $event): void {}
}
`);
// (B) UNTYPED handle — linkable only through the $listen map.
write('app/Listeners/PruneLibrary.php', `<?php
namespace App\\Listeners;
class PruneLibrary {
public function handle(): void {}
}
`);
// Fan-out: two listeners for ScanDone.
write('app/Listeners/WriteScanLog.php', `<?php
namespace App\\Listeners;
use App\\Events\\ScanDone;
class WriteScanLog {
public function handle(ScanDone $event): void {}
}
`);
write('app/Listeners/DeleteStale.php', `<?php
namespace App\\Listeners;
use App\\Events\\ScanDone;
class DeleteStale {
public function handle(ScanDone $event): void {}
}
`);
// Union-typed handle — one listener, two events.
write('app/Listeners/SendsTestNotification.php', `<?php
namespace App\\Listeners;
use App\\Events\\OwnerTest;
use App\\Events\\UserTest;
class SendsTestNotification {
public function handle(OwnerTest|UserTest $event): void {}
}
`);
// A queued JOB — handle takes a service, dispatched via ::dispatch()/dispatch(). Never an edge.
write('app/Jobs/ProcessAudio.php', `<?php
namespace App\\Jobs;
use App\\Services\\AudioService;
class ProcessAudio implements ShouldQueue {
public function handle(AudioService $svc): void {}
}
`);
// The $listen map — registers the untyped PruneLibrary for LibraryChanged.
write('app/Providers/EventServiceProvider.php', `<?php
namespace App\\Providers;
use App\\Events\\LibraryChanged;
use App\\Listeners\\PruneLibrary;
class EventServiceProvider {
protected $listen = [
LibraryChanged::class => [
PruneLibrary::class,
],
];
}
`);
write('app/Services/SongService.php', `<?php
namespace App\\Services;
use App\\Events\\SongLiked;
use App\\Events\\LibraryChanged;
use App\\Events\\ScanDone;
use App\\Events\\OwnerTest;
use App\\Events\\UserTest;
use App\\Jobs\\ProcessAudio;
class SongService {
public function like(int $id): void { event(new SongLiked($id)); }
public function deleteSongs(): void { event(new LibraryChanged()); }
public function scan(): void { event(new ScanDone()); }
public function ownerTest(): void { event(new OwnerTest()); }
public function userTest(): void { event(new UserTest()); }
public function process(): void {
ProcessAudio::dispatch();
dispatch(new ProcessAudio());
}
}
`);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const edges = db
.prepare(
`SELECT s.name source, t.name target, t.file_path tf, json_extract(e.metadata,'$.via') via
FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
WHERE json_extract(e.metadata,'$.synthesizedBy') = 'laravel-event'`
)
.all();
const bySrc = (s: string) => 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', `<?php
namespace Acme;
class Client {
public function send(string $url): string { return $url; }
}
`);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const count = db
.prepare(`SELECT count(*) c FROM edges WHERE json_extract(metadata,'$.synthesizedBy') = 'laravel-event'`)
.get();
expect(count.c).toBe(0);
cg.close?.();
});
});
+2 -1
View File
@@ -67,7 +67,7 @@ Status legend (matches the playbook): ✅ done+validated · 🟡 shipped but und
| **Celery** | Python | `@shared_task`/`@app.task`/`@<app>.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<X>` by event type | S (type-keyed, 2-pass) | ✅ **SHIPPED (2026-06-20)**`synthesizedBy:'spring-event'` (`springEventEdges`). Pass 1 builds `Map<eventType, listenerMethod[]>` — 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<X>` `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<X>` 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<eventName, handle[]>` 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<X>` 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<X>`/`INotificationHandler<X>` 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)
+144 -1
View File
@@ -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<string, Map<string, Node>>();
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<string>();
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 dispatchercallback 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<string>();
@@ -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;