feat(resolution): bridge Spring publishEvent() to its @EventListener handlers

Spring decouples an event publisher from its listener(s) through the
application event bus, linked by the event TYPE: publishEvent(new XEvent(...))
has no static edge to the @EventListener void on(XEvent e) that handles it
(usually a different class), so flows dead-end at the publish and the agent
reads to find the handlers. springEventEdges bridges each publishEvent(new X)
site -> every listener of X.

Two-pass, type-keyed (no name resolution, so precision is structural):
- Pass 1 builds Map<eventType, listenerMethod[]> from @EventListener /
  @TransactionalEventListener methods (event type = first param type off the
  node signature, or the @EventListener(X.class) value form) and the older
  `implements ApplicationListener<X>` onApplicationEvent methods.
- Pass 2 links each publishEvent(new XEvent(...))'s enclosing method to every
  listener of XEvent; multi-line `publishEvent(\n new X(...))` handled.

Key Java fact (probed): a method node's range INCLUDES its leading annotations
(startLine is the first @-line, not the `public void` decl), so the annotation
gate scans DOWNWARD from startLine bounded to consecutive @-lines, which can't
bleed into an adjacent method.

Surfaces as `dynamic: spring event` via the generic synth-edge fallback.

Validated 100% precision on two grep-confirmed repos exercising all listener
forms: halo (medium, 1254 java, 33 edges across 24 events, 0 publisher/listener
false positives, param-typed + (X.class) + ApplicationListener + fan-out) and
thombergs/code-examples (4 edges, adds @TransactionalEventListener); 0 on the
gson control (no Spring). Node-stable (pure edge synth). Suite 1617 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby McHenry
2026-06-20 22:00:43 -05:00
co-authored by Claude Opus 4.8
parent 6e5c3a9336
commit 9b7ca2e394
4 changed files with 254 additions and 2 deletions
+3 -1
View File
@@ -66,7 +66,8 @@ Status legend (matches the playbook): ✅ done+validated · 🟡 shipped but und
| **MediatR / CQRS** | .NET | `IRequest<T>``IRequestHandler<TReq,T>` by the generic request type; `_mediator.Send(new GetFooQuery())` → handler | S (generic-type-keyed) | 🔬 named a frontier in CLAUDE.md, but it's statically keyable via the generic — worth a real attempt |
| **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). |
| **Laravel / Spring events** | PHP / Java | `event(OrderShipped::class)` `EventServiceProvider` listener map; `@EventListener onX(EventT)` → publisher by event type | R (mapped) | ⬜ |
| **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). |
### Tier C — frontier, ⛔ do **not** build (no static anchor; would add noise)
@@ -86,6 +87,7 @@ Status legend (matches the playbook): ✅ done+validated · 🟡 shipped but und
| Pinia store | `pinia-store` | ✅ **shipped (2026-06-20)**`useStore().action()` instance dispatch → action; 100% precision Geeker (41) / MallChat (64), 0 on element-admin (Vuex) control. |
| Vuex dispatch | `vuex-dispatch` | ✅ **shipped (2026-06-20)** — string `dispatch('ns/action')`/`commit('M')` → handler; 100% precision element-admin (55) / vue-admin-template (12) / d2-admin (63), 0 on Redux controls. |
| 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. |
| (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)