4 Коммиты 6e5c3a9336 ... 2c522c6254

Автор SHA1 Сообщение Дата
  Colby McHenry 2c522c6254 feat(resolution): bridge Sidekiq Worker.perform_async to #perform 6 дней назад
  Colby McHenry d1381e11f6 feat(resolution): bridge MediatR Send/Publish to its IRequestHandler.Handle 6 дней назад
  Colby McHenry 8591ea5993 chore: gitignore docs/business/ (confidential, keep out of public repo) 6 дней назад
  Colby McHenry 9b7ca2e394 feat(resolution): bridge Spring publishEvent() to its @EventListener handlers 6 дней назад

+ 4 - 0
.gitignore

@@ -45,6 +45,10 @@ npm-debug.log*
 # Parallels Windows VM SSH/connection config (local machine, see CLAUDE.md)
 .parallels
 
+# Confidential business / product / strategy docs — must NOT land in the
+# public engine repo (see the IP boundary in CLAUDE.md)
+docs/business/
+
 # CodeGraph data directories (in test projects)
 .codegraph/
 

+ 3 - 0
CHANGELOG.md

@@ -16,6 +16,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 - `codegraph_explore` now follows **Vuex** string dispatch. A `dispatch('user/login')` or `commit('SET_TOKEN')` call — namespaced `'module/action'` keys included — now links to the action or mutation it names, resolved to the correct store module even when several modules share an action name (and without being fooled by a same-named `api/` helper). So "what runs when this dispatches?" traces from the call into the store handler and on to the mutations it commits. Vuex's canonical `export default { namespaced, actions, mutations }` module shape is now indexed too, so those handlers are findable symbols.
 - `codegraph_explore` now connects React data-fetching flows built on **RTK Query** (Redux Toolkit's `createApi`). An endpoint defined inside `createApi({ endpoints })` and the `useGetXQuery` / `useUpdateYMutation` hook it generates were both invisible to analysis — so "what does this component fetch?" or "where does `useGetThingQuery` get its data?" dead-ended, because the hook, the endpoint, and the component had nothing linking them. CodeGraph now indexes each endpoint and each generated hook as real symbols and wires the path `component → useGetXQuery → getX → queryFn`, so the flow resolves in one explore call instead of reading the API slice by hand. Both the arrow (`endpoints: build => ({ … })`) and method (`endpoints(builder) { return { … } }`) styles are recognized, along with the `useLazyGetXQuery` variant; hand-written hooks of a similar name are left untouched.
 - `codegraph_explore` now follows **Celery** task dispatch in Python. A `send_email.delay(...)` or `send_email.apply_async(...)` call now links to the `@shared_task` / `@app.task` function it runs — typically defined in a different module (`tasks.py`) from where it's triggered (a view or service) — so "what actually happens when this is dispatched?" traces from the call site straight into the task body instead of stopping at the `.delay()` line. Both decorator dialects are recognized (bare `@shared_task` and the arg'd `@app.task(bind=True, …)` form), including the module-qualified `tasks.invalidate_cache.apply_async()` call style. It stays precise: a `.delay()` on something that isn't a Celery task is never mislinked, so a project that doesn't use Celery is unaffected.
+- `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 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.

+ 128 - 0
__tests__/mediatr-dispatch-synthesizer.test.ts

@@ -0,0 +1,128 @@
+/**
+ * MediatR request/notification dispatch bridge (C#/.NET).
+ *
+ * MediatR decouples a `_mediator.Send(x)` / `_mediator.Publish(x)` call from the `Handle`
+ * method that runs it, linked by the request/notification TYPE (the `IRequestHandler<T,…>`
+ * generic). This bridges each mediator dispatch → the `Handle` of the matching handler.
+ * The sent type is resolved from the argument three ways — inline `new X(...)`, a local
+ * `var v = new X(...)`, and a parameter/local declared `X v` — and precision rests on two
+ * gates proven here: the receiver must be mediator-ish (a `MessagingCenter.Send` is ignored),
+ * and the type must have a handler (an `IRequest` with no handler is never bridged). Covers
+ * `IRequest<T>`, void `IRequest` (single-arg `IRequestHandler<T>`), and `INotification`.
+ */
+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('mediatr-dispatch synthesizer', () => {
+  let dir: string;
+  beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mediatr-dispatch-')); });
+  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 Send/Publish to the matching Handle across inline, local, and param arg forms', async () => {
+    write('Requests.cs', `namespace Shop;
+using MediatR;
+public record GetThingsQuery : IRequest<ThingsVm>;
+public record CreateThingCommand(string Name) : IRequest<int>;
+public record DeleteThingCommand(int Id) : IRequest;
+public record ThingDeletedNotification(int Id) : INotification;
+public class UnhandledCommand : IRequest<int> { }
+`);
+    write('Handlers.cs', `namespace Shop;
+using MediatR;
+using System.Threading;
+using System.Threading.Tasks;
+public class GetThingsQueryHandler : IRequestHandler<GetThingsQuery, ThingsVm> {
+    public Task<ThingsVm> Handle(GetThingsQuery request, CancellationToken ct) => Task.FromResult(new ThingsVm());
+}
+public class CreateThingCommandHandler : IRequestHandler<CreateThingCommand, int> {
+    public Task<int> Handle(CreateThingCommand request, CancellationToken ct) => Task.FromResult(1);
+}
+public class DeleteThingCommandHandler : IRequestHandler<DeleteThingCommand> {
+    public Task Handle(DeleteThingCommand request, CancellationToken ct) => Task.CompletedTask;
+}
+public class ThingDeletedNotificationHandler : INotificationHandler<ThingDeletedNotification> {
+    public Task Handle(ThingDeletedNotification notification, CancellationToken ct) => Task.CompletedTask;
+}
+`);
+    write('ThingsController.cs', `namespace Shop;
+using MediatR;
+using System.Threading.Tasks;
+public class ThingsController {
+    private readonly ISender _mediator;
+    public ThingsController(ISender mediator) { _mediator = mediator; }
+
+    public async Task GetThings() {
+        var vm = await _mediator.Send(new GetThingsQuery());
+    }
+    public async Task Create(CreateThingCommand command) {
+        var id = await _mediator.Send(command);
+    }
+    public async Task Delete(int id) {
+        var command = new DeleteThingCommand(id);
+        await _mediator.Send(command);
+    }
+    public async Task Notify(int id) {
+        await _mediator.Publish(new ThingDeletedNotification(id));
+    }
+    public async Task Bogus() {
+        await _mediator.Send(new UnhandledCommand());
+    }
+    public void ViaMessagingCenter() {
+        MessagingCenter.Send(this, "evt", new CreateThingCommand("x"));
+    }
+}
+`);
+
+    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, 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') = 'mediatr-dispatch'`
+      )
+      .all();
+
+    // Four bridged dispatches: inline (GetThings, Notify), param-typed (Create), local var (Delete).
+    expect(edges.map((r: any) => r.source).sort()).toEqual(['Create', 'Delete', 'GetThings', 'Notify']);
+    expect([...new Set(edges.map((r: any) => r.via))].sort()).toEqual([
+      'CreateThingCommand', 'DeleteThingCommand', 'GetThingsQuery', 'ThingDeletedNotification',
+    ]);
+    // Every target is a Handle method.
+    expect(edges.every((r: any) => r.target === 'Handle')).toBe(true);
+    // PRECISION: an IRequest with no handler is never bridged; a non-mediator .Send is ignored.
+    expect(edges.some((r: any) => r.via === 'UnhandledCommand')).toBe(false);
+    expect(edges.some((r: any) => r.source === 'ViaMessagingCenter')).toBe(false);
+
+    cg.close?.();
+  });
+
+  it('produces no edges in a C# project with no MediatR (clean control)', async () => {
+    write('Service.cs', `namespace Shop;
+public class Service {
+    private readonly IRepo _repo;
+    public Service(IRepo repo) { _repo = repo; }
+    public string Find(string id) => _repo.Get(id);
+}
+`);
+    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') = 'mediatr-dispatch'`)
+      .get();
+    expect(count.c).toBe(0);
+    cg.close?.();
+  });
+});

+ 128 - 0
__tests__/sidekiq-dispatch-synthesizer.test.ts

@@ -0,0 +1,128 @@
+/**
+ * Sidekiq job-dispatch bridge (Ruby).
+ *
+ * Sidekiq decouples a job enqueue from the worker's `perform`, linked by the WORKER CLASS
+ * NAME: `DestroyUserWorker.perform_async(id)` has no static edge to `DestroyUserWorker#perform`
+ * (usually a different file). This bridges each `Worker.perform_async`/`.perform_in`/`.perform_at`
+ * site to that worker's instance `perform`, gated on the class including `Sidekiq::Job`/`Worker`.
+ * Covers both include aliases, the scheduled forms, namespace disambiguation (two `NotifyWorker`s
+ * in different modules resolve to the right one by qualified name), and the precision boundary: a
+ * non-worker class with a `perform`, and an ActiveJob `perform_later`, both produce no edge.
+ */
+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('sidekiq-dispatch synthesizer', () => {
+  let dir: string;
+  beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sidekiq-dispatch-')); });
+  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 perform_async/_in to #perform, disambiguates namespaces, ignores non-workers and ActiveJob', async () => {
+    write('app/workers/destroy_user_worker.rb', `class DestroyUserWorker
+  include Sidekiq::Worker
+  def perform(user_id)
+    User.find(user_id).destroy!
+  end
+end
+`);
+    // Modern Sidekiq::Job alias + the scheduled form.
+    write('app/workers/send_email_worker.rb', `class SendEmailWorker
+  include Sidekiq::Job
+  def perform(addr)
+  end
+end
+`);
+    // Namespace collision: two NotifyWorkers, same simple name, different modules.
+    write('app/workers/comments/notify_worker.rb', `module Comments
+  class NotifyWorker
+    include Sidekiq::Job
+    def perform(id)
+    end
+  end
+end
+`);
+    write('app/workers/articles/notify_worker.rb', `module Articles
+  class NotifyWorker
+    include Sidekiq::Job
+    def perform(id)
+    end
+  end
+end
+`);
+    // A non-worker class that happens to have a `perform` method — never a target.
+    write('app/services/report.rb', `class Report
+  def perform(x)
+  end
+end
+`);
+    // An ActiveJob — dispatched via perform_later, a different shape, not matched.
+    write('app/jobs/cleanup_job.rb', `class CleanupJob < ApplicationJob
+  def perform
+  end
+end
+`);
+    write('app/services/user_service.rb', `class UserService
+  def deactivate(user)
+    DestroyUserWorker.perform_async(user.id)
+    SendEmailWorker.perform_in(5, user.email)
+    Comments::NotifyWorker.perform_async(1)
+    Articles::NotifyWorker.perform_async(2)
+    Report.perform_async(3)
+    CleanupJob.perform_later
+  end
+end
+`);
+
+    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') = 'sidekiq-dispatch'`
+      )
+      .all();
+
+    // Four enqueues bridge: both aliases, perform_async + perform_in, two namespaced.
+    expect(edges.map((r: any) => r.via).sort()).toEqual([
+      'Articles::NotifyWorker', 'Comments::NotifyWorker', 'DestroyUserWorker', 'SendEmailWorker',
+    ]);
+    expect(edges.every((r: any) => r.target === 'perform' && r.source === 'deactivate')).toBe(true);
+    // Namespace disambiguation: each NotifyWorker hits its OWN module's file, not the other.
+    expect(edges.find((r: any) => r.via === 'Comments::NotifyWorker').tf).toMatch(/comments[\\/]notify_worker\.rb$/);
+    expect(edges.find((r: any) => r.via === 'Articles::NotifyWorker').tf).toMatch(/articles[\\/]notify_worker\.rb$/);
+    // PRECISION: a non-worker `perform`, and ActiveJob `perform_later`, contribute nothing.
+    expect(edges.some((r: any) => r.via === 'Report')).toBe(false);
+    expect(edges.some((r: any) => /Cleanup/.test(r.via))).toBe(false);
+
+    cg.close?.();
+  });
+
+  it('produces no edges in a Ruby project with no Sidekiq (clean control)', async () => {
+    write('lib/calc.rb', `class Calc
+  def add(a, b)
+    a + b
+  end
+end
+`);
+    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') = 'sidekiq-dispatch'`)
+      .get();
+    expect(count.c).toBe(0);
+    cg.close?.();
+  });
+});

+ 132 - 0
__tests__/spring-event-synthesizer.test.ts

@@ -0,0 +1,132 @@
+/**
+ * Spring application-event bridge (Java).
+ *
+ * Spring decouples an event publisher from its listener(s) through the application
+ * event bus, linked by the EVENT TYPE: `eventPublisher.publishEvent(new XEvent(...))`
+ * has no static edge to the `@EventListener void on(XEvent e)` that handles it (usually
+ * in a different file). This bridges each `publishEvent(new XEvent(...))` site to every
+ * listener of XEvent. Covers all four listener forms — param-typed `@EventListener`,
+ * annotation-typed `@EventListener(XEvent.class)`, `@TransactionalEventListener`, and the
+ * older `implements ApplicationListener<XEvent>` / `onApplicationEvent` — fans out to
+ * multiple listeners of the same event, and proves precision: a published event with no
+ * listener, and a same-file non-annotated method, both produce no edge.
+ */
+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('spring-event synthesizer', () => {
+  let dir: string;
+  beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'spring-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 publishEvent(new X) to every listener form of X, ignoring unheard events and non-listeners', async () => {
+    write('shop/OrderEvents.java', `package shop;
+class OrderShippedEvent { }
+class OrderCancelledEvent { }
+class UnheardEvent { }
+`);
+    // Publisher — two events, one of them (UnheardEvent) has no listener.
+    write('shop/OrderService.java', `package shop;
+import org.springframework.context.ApplicationEventPublisher;
+class OrderService {
+    private ApplicationEventPublisher publisher;
+    void ship() {
+        publisher.publishEvent(new OrderShippedEvent());
+        publisher.publishEvent(new UnheardEvent());
+    }
+    void cancel() {
+        publisher.publishEvent(new OrderCancelledEvent());
+    }
+}
+`);
+    // Form 1: param-typed @EventListener — plus a same-file NON-listener (no annotation).
+    write('shop/ShippingListener.java', `package shop;
+import org.springframework.context.event.EventListener;
+class ShippingListener {
+    @EventListener
+    public void onShipped(OrderShippedEvent event) { }
+
+    public void helper(OrderShippedEvent event) { }
+}
+`);
+    // Form 2: annotation-typed @EventListener(X.class) — fan-out, a 2nd OrderShipped listener.
+    write('shop/AuditListener.java', `package shop;
+import org.springframework.context.event.EventListener;
+class AuditListener {
+    @EventListener(OrderShippedEvent.class)
+    public void audit(OrderShippedEvent event) { }
+}
+`);
+    // Form 3: @TransactionalEventListener — a 3rd OrderShipped listener.
+    write('shop/TxListener.java', `package shop;
+import org.springframework.transaction.event.TransactionalEventListener;
+class TxListener {
+    @TransactionalEventListener
+    public void afterShipped(OrderShippedEvent event) { }
+}
+`);
+    // Form 4: older implements ApplicationListener<X> / onApplicationEvent.
+    write('shop/LegacyListener.java', `package shop;
+import org.springframework.context.ApplicationListener;
+class LegacyListener implements ApplicationListener<OrderCancelledEvent> {
+    @Override
+    public void onApplicationEvent(OrderCancelledEvent event) { }
+}
+`);
+
+    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, 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') = 'spring-event'`
+      )
+      .all();
+
+    const targets = (src: string) =>
+      edges.filter((r: any) => r.source === src).map((r: any) => r.target).sort();
+    // ship() → all three OrderShippedEvent listeners (param-typed, annotation-typed, transactional).
+    expect(targets('ship')).toEqual(['afterShipped', 'audit', 'onShipped']);
+    // cancel() → the ApplicationListener<X> form.
+    expect(targets('cancel')).toEqual(['onApplicationEvent']);
+    // Every shipped edge is keyed by the event type.
+    expect(edges.filter((r: any) => r.source === 'ship').every((r: any) => r.via === 'OrderShippedEvent')).toBe(true);
+    // PRECISION: UnheardEvent has no listener → no edge; the non-annotated helper is never a target.
+    expect(edges.some((r: any) => r.via === 'UnheardEvent')).toBe(false);
+    expect(edges.some((r: any) => r.target === 'helper')).toBe(false);
+
+    cg.close?.();
+  });
+
+  it('produces no edges in a Spring app with no event bus (clean control)', async () => {
+    write('shop/PlainService.java', `package shop;
+import org.springframework.stereotype.Service;
+@Service
+class PlainService {
+    private final Repo repo;
+    PlainService(Repo repo) { this.repo = repo; }
+    String find(String id) { return repo.get(id); }
+}
+`);
+    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') = 'spring-event'`)
+      .get();
+    expect(count.c).toBe(0);
+    cg.close?.();
+  });
+});

Разница между файлами не показана из-за своего большого размера
+ 0 - 1
docs/design/dispatch-synthesizer-backlog.md


+ 327 - 1
src/resolution/callback-synthesizer.ts

@@ -2186,13 +2186,333 @@ function celeryDispatchEdges(ctx: ResolutionContext): Edge[] {
   return edges;
 }
 
+// ── Spring application events (Java) ──────────────────────────────────────────
+// Spring decouples an event PUBLISHER from its LISTENER(s) through the application
+// event bus, linked by the EVENT TYPE (not a name):
+//   // SomeService.java
+//   eventPublisher.publishEvent(new PasswordChangedEvent(this, username));   // publish
+//   // RememberMeTokenRevoker.java — a DIFFERENT file
+//   @EventListener(PasswordChangedEvent.class)                              // listen
+//   public void onPasswordChanged(PasswordChangedEvent event) { ... }
+// Bridge it: link the enclosing method at each `publishEvent(new XEvent(...))` site →
+// every listener method of XEvent. Listeners are `@EventListener` / `@TransactionalEventListener`
+// methods (event type = the first param type, or the `@EventListener(X.class)` value form) and
+// the older `class … implements ApplicationListener<X> { void onApplicationEvent(X e) }`. Keyed
+// by exact type name, usually cross-file. A repo with no `@EventListener`/`publishEvent` yields 0.
+// (Java method nodes INCLUDE their leading annotations in the range — startLine is the first
+// `@…` line — so the annotation block is scanned DOWNWARD from startLine, bounded to consecutive
+// `@`-lines so it can't bleed into an adjacent method.)
+const SPRING_PUBLISH_RE = /\.publishEvent\s*\(\s*new\s+([A-Z][A-Za-z0-9_]*)/g;
+const SPRING_LISTENER_ANNO_RE = /@(?:EventListener|TransactionalEventListener)\b/;
+const SPRING_ANNO_TYPE_RE = /@(?:EventListener|TransactionalEventListener)\s*\(\s*([A-Z][A-Za-z0-9_]*)\.class/;
+const SPRING_APP_LISTENER_RE = /\bApplicationListener\s*</;
+const SPRING_JAVA_EXT = /\.java$/;
+const SPRING_FANOUT_CAP = 80;
+
+/** The first parameter's type from a Java method `signature` (`"void (XEvent e)"` → `XEvent`).
+ *  Skips a leading `final`/`@Anno`, strips generics, and requires a PascalCase class name (event
+ *  types are classes) — so a no-arg or primitive-param method yields null. */
+function springFirstParamType(sig: string | undefined): string | null {
+  if (!sig) return null;
+  const open = sig.indexOf('(');
+  if (open < 0) return null;
+  const close = sig.indexOf(')', open);
+  const inner = sig.slice(open + 1, close < 0 ? sig.length : close).trim();
+  if (!inner) return null;
+  const first = inner.split(',')[0]!.trim();
+  const toks = first.split(/\s+/).filter((t) => t && t !== 'final' && !t.startsWith('@'));
+  if (toks.length < 2) return null; // need `Type name`
+  const type = toks[toks.length - 2]!.replace(/<.*$/, ''); // drop generic args
+  return /^[A-Z][A-Za-z0-9_]*$/.test(type) ? type : null;
+}
+
+function springEventEdges(ctx: ResolutionContext): Edge[] {
+  // Pass 1 — event-type → listener methods, scanning only event-relevant files.
+  const listeners = new Map<string, Node[]>();
+  for (const file of ctx.getAllFiles()) {
+    if (!SPRING_JAVA_EXT.test(file)) continue;
+    const content = ctx.readFile(file);
+    if (!content) continue;
+    const hasAnno = content.includes('@EventListener') || content.includes('@TransactionalEventListener');
+    const hasAppListener = SPRING_APP_LISTENER_RE.test(content);
+    if (!hasAnno && !hasAppListener) continue;
+    const lines = content.split('\n');
+    for (const node of ctx.getNodesInFile(file)) {
+      if (node.kind !== 'method') continue;
+      // Collect this method's own leading annotation block (consecutive `@`-lines from startLine).
+      const annoLines: string[] = [];
+      for (let i = node.startLine - 1; i < lines.length && i < node.startLine + 7; i++) {
+        const t = (lines[i] ?? '').trim();
+        if (!t.startsWith('@')) break; // reached the declaration → stop (no bleed into next method)
+        annoLines.push(t);
+      }
+      const head = annoLines.join('\n');
+      const annotated = hasAnno && SPRING_LISTENER_ANNO_RE.test(head);
+      const isAppListener = hasAppListener && node.name === 'onApplicationEvent';
+      if (!annotated && !isAppListener) continue;
+      let type = springFirstParamType(node.signature);
+      if (!type && annotated) {
+        const m = SPRING_ANNO_TYPE_RE.exec(head);
+        if (m) type = m[1]!;
+      }
+      if (!type) continue;
+      let arr = listeners.get(type);
+      if (!arr) { arr = []; listeners.set(type, arr); }
+      arr.push(node);
+    }
+  }
+  if (!listeners.size) return [];
+
+  // Pass 2 — link each publishEvent(new XEvent(...)) site → every listener of XEvent.
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+  for (const file of ctx.getAllFiles()) {
+    if (!SPRING_JAVA_EXT.test(file)) continue;
+    const content = ctx.readFile(file);
+    if (!content || !content.includes('.publishEvent(')) continue;
+    const safe = stripCommentsForRegex(content, 'java');
+    const nodesInFile = ctx.getNodesInFile(file);
+    SPRING_PUBLISH_RE.lastIndex = 0;
+    let m: RegExpExecArray | null;
+    let added = 0;
+    while ((m = SPRING_PUBLISH_RE.exec(safe)) && added < SPRING_FANOUT_CAP) {
+      const targets = listeners.get(m[1]!);
+      if (!targets || !targets.length) continue;
+      const line = safe.slice(0, m.index).split('\n').length;
+      const disp = enclosingFn(nodesInFile, line);
+      if (!disp) continue;
+      for (const target of targets) {
+        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: 'spring-event', via: m[1]!, registeredAt: `${file}:${line}` },
+        });
+        added++;
+      }
+    }
+  }
+  return edges;
+}
+
+// ── MediatR request/notification dispatch (C#/.NET) ───────────────────────────
+// MediatR decouples a Send/Publish call site from its Handle method through a mediator,
+// linked by the request/notification TYPE (the IRequestHandler<T,…> generic):
+//   // CancelOrderCommandHandler.cs — the handler
+//   public class CancelOrderCommandHandler : IRequestHandler<CancelOrderCommand, bool> {
+//       public async Task<bool> Handle(CancelOrderCommand request, CancellationToken ct) { … }
+//   // some controller — the dispatch (usually a DIFFERENT file)
+//   var command = new CancelOrderCommand(orderId);   await _mediator.Send(command);
+// Bridge it: link the enclosing method at each mediator `.Send(x)`/`.Publish(x)` site → the
+// `Handle` method of the handler for x's type. The sent type is resolved from the argument —
+// inline `new X(…)`, a local `var v = new X(…)`, or a parameter/local declared `X v` — bounded
+// to the enclosing method. Precision rests on TWO gates: the receiver must be mediator-ish
+// (`mediator`/`sender`/`publisher`, so MAUI `MessagingCenter.Send` is ignored) AND the resolved
+// type must be a known handler request type (so a same-named non-request DTO is never bridged).
+// C# has no `signature` on method nodes, so the handler's request type is read from the class
+// base-list source (`: IRequestHandler<X,…>`), not a param signature.
+const MEDIATR_HANDLER_BASE_RE = /(?:IRequestHandler|INotificationHandler)\s*<\s*([A-Za-z_]\w*)/;
+const MEDIATR_DISPATCH_RE = /([A-Za-z_][\w.]*)\s*\.\s*(?:Send|Publish)\s*\(\s*(new\s+[A-Z]\w*|[A-Za-z_]\w*)/g;
+const MEDIATR_RECEIVER_RE = /(?:mediator|sender|publisher)/i;
+const MEDIATR_CS_EXT = /\.cs$/;
+const MEDIATR_FANOUT_CAP = 80;
+const MEDIATR_HANDLER_DECL_LOOKAHEAD = 4; // lines from a class startLine to find a wrapped base list
+
+/** The type sent at a MediatR `.Send(arg)`/`.Publish(arg)` site: an inline `new X(…)`, else
+ *  `arg` as an identifier resolved within the enclosing method — a `… arg = new X(…)` assignment
+ *  (wins), or a parameter/local declared `X arg`. Returns null when the type can't be seen. */
+function resolveMediatrArgType(arg: string, lines: string[], methodStart: number, dispatchLine: number): string | null {
+  const inl = /^new\s+([A-Z]\w*)/.exec(arg);
+  if (inl) return inl[1]!;
+  if (!/^[A-Za-z_]\w*$/.test(arg)) return null;
+  const assignRe = new RegExp(`\\b${arg}\\b\\s*=\\s*new\\s+([A-Z]\\w*)`);
+  const declRe = new RegExp(`\\b([A-Z]\\w*)\\b\\s+${arg}\\b`);
+  let declType: string | null = null;
+  for (let i = Math.max(0, methodStart - 1); i < dispatchLine && i < lines.length; i++) {
+    const ln = lines[i] ?? '';
+    const a = assignRe.exec(ln);
+    if (a) return a[1]!; // an explicit `arg = new X` is the most specific — take it
+    if (!declType) {
+      const d = declRe.exec(ln);
+      if (d) declType = d[1]!; // a `X arg` declaration — remember, but keep scanning for an assignment
+    }
+  }
+  return declType;
+}
+
+function mediatrDispatchEdges(ctx: ResolutionContext): Edge[] {
+  // Pass 1 — request/notification type → the Handle method of each handler class.
+  const handlers = new Map<string, Node[]>();
+  for (const file of ctx.getAllFiles()) {
+    if (!MEDIATR_CS_EXT.test(file)) continue;
+    const content = ctx.readFile(file);
+    if (!content || (!content.includes('IRequestHandler<') && !content.includes('INotificationHandler<'))) continue;
+    const lines = content.split('\n');
+    const nodesInFile = ctx.getNodesInFile(file);
+    for (const cls of nodesInFile) {
+      if (cls.kind !== 'class') continue;
+      const decl = lines.slice(cls.startLine - 1, cls.startLine - 1 + MEDIATR_HANDLER_DECL_LOOKAHEAD).join('\n');
+      const m = MEDIATR_HANDLER_BASE_RE.exec(decl);
+      if (!m) continue;
+      const type = m[1]!;
+      const end = cls.endLine ?? cls.startLine;
+      const handle = nodesInFile.find(
+        (n) => n.kind === 'method' && n.name === 'Handle' && n.startLine >= cls.startLine && n.startLine <= end
+      );
+      if (!handle) continue;
+      let arr = handlers.get(type);
+      if (!arr) { arr = []; handlers.set(type, arr); }
+      arr.push(handle);
+    }
+  }
+  if (!handlers.size) return [];
+
+  // Pass 2 — link each mediator-ish .Send(x)/.Publish(x) → the handler of x's type.
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+  for (const file of ctx.getAllFiles()) {
+    if (!MEDIATR_CS_EXT.test(file)) continue;
+    const content = ctx.readFile(file);
+    if (!content || (!content.includes('.Send(') && !content.includes('.Publish('))) continue;
+    const safe = stripCommentsForRegex(content, 'csharp');
+    const safeLines = safe.split('\n');
+    const nodesInFile = ctx.getNodesInFile(file);
+    MEDIATR_DISPATCH_RE.lastIndex = 0;
+    let m: RegExpExecArray | null;
+    let added = 0;
+    while ((m = MEDIATR_DISPATCH_RE.exec(safe)) && added < MEDIATR_FANOUT_CAP) {
+      if (!MEDIATR_RECEIVER_RE.test(m[1]!)) continue; // not a mediator (MessagingCenter, HttpClient, …)
+      const line = safe.slice(0, m.index).split('\n').length;
+      const disp = enclosingFn(nodesInFile, line);
+      if (!disp) continue;
+      const type = resolveMediatrArgType(m[2]!, safeLines, disp.startLine, line);
+      if (!type) continue;
+      const targets = handlers.get(type);
+      if (!targets) continue;
+      for (const target of targets) {
+        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: 'mediatr-dispatch', via: type, registeredAt: `${file}:${line}` },
+        });
+        added++;
+      }
+    }
+  }
+  return edges;
+}
+
+// ── Sidekiq job dispatch (Ruby) ───────────────────────────────────────────────
+// Sidekiq decouples a job's enqueue site from the worker's `perform`, linked by the WORKER
+// CLASS NAME:
+//   # app/workers/destroy_user_worker.rb
+//   class DestroyUserWorker
+//     include Sidekiq::Worker          # or Sidekiq::Job (the modern alias)
+//     def perform(user_id) … end
+//   # app/services/… — a DIFFERENT file
+//   DestroyUserWorker.perform_async(user.id)   # also .perform_in(t, …) / .perform_at(t, …)
+// Bridge it: link the enclosing method at each `Worker.perform_async/_in/_at(…)` site → that
+// worker's instance `perform`. Name-keyed (like Celery): the receiver class must be a Sidekiq
+// worker — gated by reading `include Sidekiq::Job|Worker` from the class body, since that mixin
+// is an external gem module that forms no resolvable edge. ActiveJob's `perform_later`/`_now` is
+// a different shape and deliberately not matched, so an ActiveJob-only app yields 0.
+const SIDEKIQ_DISPATCH_RE = /([A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*)\s*\.\s*perform_(?:async|in|at)\b/g;
+const SIDEKIQ_WORKER_RE = /\binclude\s+Sidekiq::(?:Job|Worker)\b/;
+const SIDEKIQ_RB_EXT = /\.rb$/;
+const SIDEKIQ_FANOUT_CAP = 80;
+
+function sidekiqDispatchEdges(ctx: ResolutionContext): Edge[] {
+  // class node id → its instance `perform` method (null if the class isn't a Sidekiq worker),
+  // memoized. Reads the class body for the mixin; only consulted for actual dispatch receivers.
+  const performCache = new Map<string, Node | null>();
+  const performOf = (cls: Node): Node | null => {
+    let v = performCache.get(cls.id);
+    if (v !== undefined) return v;
+    v = null;
+    const content = ctx.readFile(cls.filePath);
+    if (content) {
+      const end = cls.endLine ?? cls.startLine;
+      const body = content.split('\n').slice(cls.startLine - 1, end).join('\n');
+      if (SIDEKIQ_WORKER_RE.test(body)) {
+        v = ctx.getNodesInFile(cls.filePath).find(
+          (n) => n.kind === 'method' && n.name === 'perform' && n.startLine >= cls.startLine && n.startLine <= end
+        ) ?? null;
+      }
+    }
+    performCache.set(cls.id, v);
+    return v;
+  };
+
+  // Resolve a (possibly namespaced) worker reference to its `perform`. A namespaced ref is
+  // matched by EXACT qualified name first, so same-named workers in different namespaces
+  // (forem has four `SendEmailNotificationWorker`s) resolve to the right one; an unqualified
+  // ref falls back to the simple name and links only when a single worker bears it — an
+  // ambiguous collision bails (precision over recall).
+  const resolve = (ref: string): Node | null => {
+    if (ref.includes('::')) {
+      const q = ctx.getNodesByQualifiedName(ref).find((n) => n.kind === 'class' && performOf(n));
+      if (q) return performOf(q);
+    }
+    const workers = ctx.getNodesByName(ref.split('::').pop()!).filter((n) => n.kind === 'class' && performOf(n));
+    return workers.length === 1 ? performOf(workers[0]!) : null;
+  };
+
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+  for (const file of ctx.getAllFiles()) {
+    if (!SIDEKIQ_RB_EXT.test(file)) continue;
+    const content = ctx.readFile(file);
+    if (!content || !/\.perform_(?:async|in|at)\b/.test(content)) continue;
+    const safe = stripCommentsForRegex(content, 'ruby');
+    const nodesInFile = ctx.getNodesInFile(file);
+    SIDEKIQ_DISPATCH_RE.lastIndex = 0;
+    let m: RegExpExecArray | null;
+    let added = 0;
+    while ((m = SIDEKIQ_DISPATCH_RE.exec(safe)) && added < SIDEKIQ_FANOUT_CAP) {
+      const line = safe.slice(0, m.index).split('\n').length;
+      const disp = enclosingFn(nodesInFile, line);
+      if (!disp) continue;
+      const target = resolve(m[1]!);
+      if (!target || 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: 'sidekiq-dispatch', via: 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
  * channel + Fabric native-impl + MyBatis Java↔XML + Gin middleware chain +
  * Redux-thunk dispatch chain + object-literal registry dispatch + RTK Query
  * generated-hook → endpoint + Pinia useStore().action() + Vuex string dispatch +
- * Celery task .delay()/.apply_async() → task body).
+ * Celery task .delay()/.apply_async() → task body + Spring publishEvent → @EventListener +
+ * MediatR Send/Publish → IRequestHandler/INotificationHandler +
+ * Sidekiq Worker.perform_async → #perform).
  * Returns the count added. Never throws into indexing — callers wrap in try/catch.
  */
 export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionContext): number {
@@ -2236,6 +2556,9 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
   const piniaEdges = piniaStoreEdges(ctx);
   const vuexEdges = vuexDispatchEdges(ctx);
   const celeryEdges = celeryDispatchEdges(ctx);
+  const springEdges = springEventEdges(ctx);
+  const mediatrEdges = mediatrDispatchEdges(ctx);
+  const sidekiqEdges = sidekiqDispatchEdges(ctx);
 
   const merged: Edge[] = [];
   const seen = new Set<string>();
@@ -2265,6 +2588,9 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
     ...piniaEdges,
     ...vuexEdges,
     ...celeryEdges,
+    ...springEdges,
+    ...mediatrEdges,
+    ...sidekiqEdges,
   ]) {
     const key = `${e.source}>${e.target}`;
     if (seen.has(key)) continue;

Некоторые файлы не были показаны из-за большого количества измененных файлов