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