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:
co-authored by
Claude Opus 4.8
parent
2c522c6254
commit
feb2f641de
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user