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
+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 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<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;