浏览代码

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>
Colby McHenry 1 天之前
父节点
当前提交
feb2f641de

+ 1 - 0
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<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 - 0
__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`, `<?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?.();
+  });
+});

文件差异内容过多而无法显示
+ 0 - 1
docs/design/dispatch-synthesizer-backlog.md


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

部分文件因为文件数量过多而无法显示