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:
co-authored by
Claude Opus 4.8
parent
6e5c3a9336
commit
9b7ca2e394
@@ -16,6 +16,7 @@ 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 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 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 **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 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.
|
- `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.
|
- 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,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?.();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 |
|
| **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()`). |
|
| **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 | `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)
|
### 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. |
|
| 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. |
|
| 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`. |
|
| 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) | | |
|
| (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)
|
### redux-thunk follow-ups (found by the n>1 validation — this is exactly what it's for)
|
||||||
|
|||||||
@@ -2186,13 +2186,128 @@ function celeryDispatchEdges(ctx: ResolutionContext): Edge[] {
|
|||||||
return edges;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Synthesize dispatcher→callback edges (field observers + EventEmitters +
|
* Synthesize dispatcher→callback edges (field observers + EventEmitters +
|
||||||
* React re-render + JSX children + Vue templates + SvelteKit load + RN event
|
* React re-render + JSX children + Vue templates + SvelteKit load + RN event
|
||||||
* channel + Fabric native-impl + MyBatis Java↔XML + Gin middleware chain +
|
* channel + Fabric native-impl + MyBatis Java↔XML + Gin middleware chain +
|
||||||
* Redux-thunk dispatch chain + object-literal registry dispatch + RTK Query
|
* Redux-thunk dispatch chain + object-literal registry dispatch + RTK Query
|
||||||
* generated-hook → endpoint + Pinia useStore().action() + Vuex string dispatch +
|
* 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).
|
||||||
* Returns the count added. Never throws into indexing — callers wrap in try/catch.
|
* Returns the count added. Never throws into indexing — callers wrap in try/catch.
|
||||||
*/
|
*/
|
||||||
export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionContext): number {
|
export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionContext): number {
|
||||||
@@ -2236,6 +2351,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
|
|||||||
const piniaEdges = piniaStoreEdges(ctx);
|
const piniaEdges = piniaStoreEdges(ctx);
|
||||||
const vuexEdges = vuexDispatchEdges(ctx);
|
const vuexEdges = vuexDispatchEdges(ctx);
|
||||||
const celeryEdges = celeryDispatchEdges(ctx);
|
const celeryEdges = celeryDispatchEdges(ctx);
|
||||||
|
const springEdges = springEventEdges(ctx);
|
||||||
|
|
||||||
const merged: Edge[] = [];
|
const merged: Edge[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -2265,6 +2381,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
|
|||||||
...piniaEdges,
|
...piniaEdges,
|
||||||
...vuexEdges,
|
...vuexEdges,
|
||||||
...celeryEdges,
|
...celeryEdges,
|
||||||
|
...springEdges,
|
||||||
]) {
|
]) {
|
||||||
const key = `${e.source}>${e.target}`;
|
const key = `${e.source}>${e.target}`;
|
||||||
if (seen.has(key)) continue;
|
if (seen.has(key)) continue;
|
||||||
|
|||||||
Reference in New Issue
Block a user