feat(resolution): bridge Sidekiq Worker.perform_async to #perform

Sidekiq decouples a job's enqueue site from the worker's perform method,
linked by the worker class NAME: DestroyUserWorker.perform_async(id) has no
static edge to DestroyUserWorker#perform (usually in app/workers/, away from
the controller/model that enqueues it). sidekiqDispatchEdges bridges 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 (the mixin is an
external gem module that forms no resolvable edge). ActiveJob's perform_later/
_now is a different shape and deliberately not matched.

Namespace disambiguation was the n>1 validation payoff: loomio's flat workers
hid a collision bug that forem exposed (four SendEmailNotificationWorker classes
across modules; simple-name resolution mis-targeted 7/143 edges to the wrong
namespace). Fixed by resolving a namespaced receiver via exact qualified-name
lookup first, falling back to the simple name only for a unique worker — an
ambiguous unqualified collision bails (precision over recall).

Surfaces as `dynamic: sidekiq dispatch` via the generic synth-edge fallback.

Validated 100% precision on two grep-confirmed repos: loomio (medium,
Sidekiq::Worker, 47 edges) and forem (large, both include aliases — 131
Sidekiq::Job + 11 Sidekiq::Worker, 142 edges, 0 worker/source false positives,
0 namespace mismatch); 0 on the jekyll control. Node-stable (pure edge synth).
Suite 1621 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby McHenry
2026-06-21 01:32:33 -05:00
co-authored by Claude Opus 4.8
parent d1381e11f6
commit 2c522c6254
4 changed files with 224 additions and 2 deletions
+1
View File
@@ -18,6 +18,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- `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.
@@ -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?.();
});
});
+2 -1
View File
@@ -65,7 +65,7 @@ Status legend (matches the playbook): ✅ done+validated · 🟡 shipped but und
|---|---|---|---|---|
| **MediatR / CQRS** | .NET | `_mediator.Send(x)`/`.Publish(x)` → the `Handle` of `IRequestHandler<X,…>`/`INotificationHandler<X>` by request type | S (type-keyed, 2-pass + arg resolution) | ✅ **SHIPPED (2026-06-20)**`synthesizedBy:'mediatr-dispatch'` (`mediatrDispatchEdges`). Same 2-pass type-keyed shape as Spring, with a twist: **C# method nodes have NO `signature`** (csharp.ts defines no `getSignature`), so Pass 1 reads the request type from the handler **class base-list source** (`: IRequestHandler<X,…>` — first generic arg), not a param signature, and binds the class's `Handle` method. **The dominant .NET idiom is VARIABLE-passed, not inline** — eShop had **0** genuine inline `Send(new X)` (every send is `mediator.Send(command)`), so Pass 2 RESOLVES the sent type from the argument three ways within the enclosing method: inline `new X(…)`, a local `var v = new X(…)` (backward scan, wins), or a parameter/local declared `X v`. **Two precision gates:** (1) receiver must be mediator-ish (`/mediator|sender|publisher/i` — excludes MAUI `MessagingCenter.Send`, `HttpClient.Send`), (2) resolved type must be in the handler map (so eShop's same-named `CancelOrderCommand` DTO in ClientApp, which has no handler, is never bridged). Handles the `IdentifiedCommand<T,R>` wrapper (sent + handled at that layer) and void single-arg `IRequestHandler<T>`. **100% precision: jasontaylordev/CleanArchitecture (small, 9 edges, inline + param forms) + dotnet/eShop (medium, 9 edges, 0 FP, variable-passed + IdentifiedCommand + DTO-collision avoided); 0 on the Newtonsoft.Json control.** Node-stable (pure edge synth). Surfaces `dynamic: mediatr dispatch`. `+ mediatr-dispatch-synthesizer.test.ts`. **Deferred (recall):** generic `_mediator.Publish(domainEvent)` over a collection (concrete type erased at the publish site — eShop's DDD AddDomainEvent fan-out), `record`-positional or factory-built args whose type isn't a `new X`/param, the `ICommandHandler<T>` facade indirection (modular-monolith). |
| **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 | `class W; include Sidekiq::Job; def perform; end` + `W.perform_async(...)``perform` | S (class→perform) | ⬜ — the Ruby sibling of Celery; build next-to-it (grep-confirm ≥2 `perform_async` repos). |
| **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). |
@@ -89,6 +89,7 @@ Status legend (matches the playbook): ✅ done+validated · 🟡 shipped but und
| Celery | `celery-dispatch` | ✅ **shipped (2026-06-20)**`.delay()`/`.apply_async()``@shared_task`/`@app.task` body; 100% precision paperless-ngx (31) / pretix (63 across 21 tasks), 0 on httpie control. Decorator-gated via source above the `def`. |
| 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. |
| (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)
+93 -1
View File
@@ -2415,6 +2415,95 @@ function mediatrDispatchEdges(ctx: ResolutionContext): Edge[] {
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
@@ -2422,7 +2511,8 @@ function mediatrDispatchEdges(ctx: ResolutionContext): Edge[] {
* 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 + Spring publishEvent → @EventListener +
* MediatR Send/Publish → IRequestHandler/INotificationHandler).
* 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 {
@@ -2468,6 +2558,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
const celeryEdges = celeryDispatchEdges(ctx);
const springEdges = springEventEdges(ctx);
const mediatrEdges = mediatrDispatchEdges(ctx);
const sidekiqEdges = sidekiqDispatchEdges(ctx);
const merged: Edge[] = [];
const seen = new Set<string>();
@@ -2499,6 +2590,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
...celeryEdges,
...springEdges,
...mediatrEdges,
...sidekiqEdges,
]) {
const key = `${e.source}>${e.target}`;
if (seen.has(key)) continue;