sourcecode 1.35.1__py3-none-any.whl → 1.35.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sourcecode/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """sourcecode — Deterministic codebase context maps for AI coding agents."""
2
2
 
3
- __version__ = "1.35.1"
3
+ __version__ = "1.35.3"
@@ -20,8 +20,11 @@ from dataclasses import dataclass, field
20
20
  from pathlib import Path
21
21
  from typing import Any, Optional
22
22
 
23
+ from sourcecode.cir_graphs import ImplementationGraph, InjectionGraph
23
24
  from sourcecode.repository_ir import (
24
25
  build_repo_ir,
26
+ )
27
+ from sourcecode.repository_ir import (
25
28
  compute_blast_radius as _compute_blast_radius,
26
29
  )
27
30
 
@@ -78,7 +81,7 @@ class CanonicalSecurity:
78
81
  @classmethod
79
82
  def from_policy_dict(
80
83
  cls, d: dict, *, source_scope: str = "method"
81
- ) -> "CanonicalSecurity":
84
+ ) -> CanonicalSecurity:
82
85
  """Build from the policy dict emitted by _route_security_from_sym."""
83
86
  return cls(
84
87
  policy=d.get("policy", ""),
@@ -165,6 +168,15 @@ class CanonicalRepositoryIR:
165
168
  endpoints: list[CanonicalEndpoint] # canonical endpoint list
166
169
  security_index: dict[str, CanonicalSecurity] # handler_symbol → security
167
170
  metadata: dict[str, Any] # stats, gaps, subsystems, etc.
171
+ # Derived graph indices — built from dependencies at CIR construction time.
172
+ # CH-001: interface → implementation(s) lookup
173
+ implementation_graph: ImplementationGraph = field(
174
+ default_factory=ImplementationGraph, repr=False, compare=False
175
+ )
176
+ # CH-002: DI injection dependency → dependents + field/constructor lifting
177
+ injection_graph: InjectionGraph = field(
178
+ default_factory=InjectionGraph, repr=False, compare=False
179
+ )
168
180
  # Raw IR dict retained for projections that need full IR fields
169
181
  # (e.g. project_blast_radius delegates to compute_blast_radius)
170
182
  _raw_ir: dict = field(default_factory=dict, repr=False, compare=False)
@@ -339,6 +351,11 @@ def ir_dict_to_canonical(
339
351
  IR_SCHEMA_VERSION, files, symbols, endpoints, call_graph
340
352
  )
341
353
 
354
+ # Derived graph indices built from dependency edges
355
+ known_symbols: set[str] = set(symbols)
356
+ impl_graph = ImplementationGraph.build(dependencies, known_symbols)
357
+ inj_graph = InjectionGraph.build(dependencies)
358
+
342
359
  return CanonicalRepositoryIR(
343
360
  schema_version=IR_SCHEMA_VERSION,
344
361
  cir_hash=cir_hash,
@@ -350,6 +367,8 @@ def ir_dict_to_canonical(
350
367
  endpoints=endpoints,
351
368
  security_index=security_index,
352
369
  metadata=metadata,
370
+ implementation_graph=impl_graph,
371
+ injection_graph=inj_graph,
353
372
  _raw_ir=ir,
354
373
  )
355
374
 
@@ -0,0 +1,186 @@
1
+ """cir_graphs.py — Derived graph indices built from CanonicalRepositoryIR.
2
+
3
+ ImplementationGraph (CH-001): interface → implementation(s) lookup.
4
+ InjectionGraph (CH-002): DI dependency → dependents lookup, with field/constructor lifting.
5
+
6
+ Both are built from cir.dependencies (implements + injects edges) and are keyed to
7
+ known CIR symbols only. External interfaces (java.io.Serializable, etc.) are excluded.
8
+
9
+ Architecture constraint: these classes depend only on CIR data. They must never import
10
+ from spring_model, spring_impact, or any semantic layer.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # ImplementationGraph — CH-001
18
+ # ---------------------------------------------------------------------------
19
+
20
+ @dataclass
21
+ class ImplementationGraph:
22
+ """Maps interface FQNs to their in-repo implementing classes, and vice-versa.
23
+
24
+ Built from implements edges where BOTH ends are known CIR symbols (internal
25
+ interface/class pairs). External framework interfaces are excluded.
26
+ """
27
+ _impl_of: dict[str, list[str]] = field(default_factory=dict)
28
+ _ifaces_of: dict[str, list[str]] = field(default_factory=dict)
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Queries
32
+ # ---------------------------------------------------------------------------
33
+
34
+ def implementations_of(self, interface_fqn: str) -> list[str]:
35
+ """Return FQNs of classes that implement interface_fqn (in-repo only)."""
36
+ return self._impl_of.get(interface_fqn, [])
37
+
38
+ def interfaces_of(self, class_fqn: str) -> list[str]:
39
+ """Return FQNs of in-repo interfaces implemented by class_fqn."""
40
+ return self._ifaces_of.get(class_fqn, [])
41
+
42
+ def primary_implementation(self, interface_fqn: str) -> str | None:
43
+ """Return the single implementation if unambiguous, else None.
44
+
45
+ A single implementation is unambiguous by definition.
46
+ Multiple implementations are ambiguous — callers must decide.
47
+ @Primary detection is not yet implemented (requires annotation data in CIR).
48
+ """
49
+ impls = self._impl_of.get(interface_fqn, [])
50
+ return impls[0] if len(impls) == 1 else None
51
+
52
+ def has_implementations(self, interface_fqn: str) -> bool:
53
+ return bool(self._impl_of.get(interface_fqn))
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Builder
57
+ # ---------------------------------------------------------------------------
58
+
59
+ @classmethod
60
+ def build(
61
+ cls,
62
+ dependencies: list[dict],
63
+ known_symbols: set[str],
64
+ ) -> ImplementationGraph:
65
+ """Build from CIR dependencies list, restricting to known in-repo symbols.
66
+
67
+ Args:
68
+ dependencies: cir.dependencies — list of edge dicts with 'from'/'to'/'type'
69
+ known_symbols: set(cir.symbols) — only in-repo FQNs
70
+
71
+ Excludes implements edges where the interface (to_fqn) is NOT in known_symbols
72
+ (e.g. java.io.Serializable, org.springframework.* framework interfaces).
73
+ Includes edges where the implementing class (from_fqn) is NOT in known_symbols
74
+ only when the interface IS known — this handles partial-parse edge cases.
75
+ """
76
+ impl_of: dict[str, list[str]] = {}
77
+ ifaces_of: dict[str, list[str]] = {}
78
+
79
+ for edge in dependencies:
80
+ if edge.get("type") != "implements":
81
+ continue
82
+ from_fqn = (edge.get("from") or "").strip()
83
+ to_fqn = (edge.get("to") or "").strip()
84
+ if not from_fqn or not to_fqn:
85
+ continue
86
+ # Only track when the interface is an in-repo symbol
87
+ if to_fqn not in known_symbols:
88
+ continue
89
+ # Ignore malformed FQNs (e.g. generic type fragments like "Long>")
90
+ if ">" in to_fqn or "<" in to_fqn:
91
+ continue
92
+ if ">" in from_fqn or "<" in from_fqn:
93
+ continue
94
+
95
+ if from_fqn not in impl_of.get(to_fqn, []):
96
+ impl_of.setdefault(to_fqn, []).append(from_fqn)
97
+ if to_fqn not in ifaces_of.get(from_fqn, []):
98
+ ifaces_of.setdefault(from_fqn, []).append(to_fqn)
99
+
100
+ return cls(_impl_of=impl_of, _ifaces_of=ifaces_of)
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # InjectionGraph — CH-002
105
+ # ---------------------------------------------------------------------------
106
+
107
+ @dataclass
108
+ class InjectionGraph:
109
+ """Maps DI injection edges to class-level dependency relationships.
110
+
111
+ Resolves field FQN and constructor FQN injectors to their enclosing class,
112
+ enabling BFS traversal to continue past injection boundaries.
113
+
114
+ Injects edge forms:
115
+ constructor: ClassName#<init> → DependencyFQN
116
+ field: ClassName#fieldName → DependencyFQN
117
+ lombok: ClassName → DependencyFQN (already class-level)
118
+ """
119
+ _deps_of: dict[str, list[str]] = field(default_factory=dict)
120
+ _dependents_of: dict[str, list[str]] = field(default_factory=dict)
121
+ # Maps field/constructor FQN → enclosing class FQN
122
+ _injector_to_class: dict[str, str] = field(default_factory=dict)
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # Queries
126
+ # ---------------------------------------------------------------------------
127
+
128
+ def dependencies_of(self, class_fqn: str) -> list[str]:
129
+ """Return service FQNs injected into class_fqn (de-duplicated, sorted)."""
130
+ return self._deps_of.get(class_fqn, [])
131
+
132
+ def dependents_of(self, service_fqn: str) -> list[str]:
133
+ """Return class FQNs that inject service_fqn (class-level, de-duplicated)."""
134
+ return self._dependents_of.get(service_fqn, [])
135
+
136
+ def class_of_injector(self, injector_fqn: str) -> str | None:
137
+ """Resolve a field/constructor FQN to its enclosing class.
138
+
139
+ Returns None if injector_fqn is not a known injection site.
140
+ """
141
+ return self._injector_to_class.get(injector_fqn)
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # Builder
145
+ # ---------------------------------------------------------------------------
146
+
147
+ @classmethod
148
+ def build(cls, dependencies: list[dict]) -> InjectionGraph:
149
+ """Build from CIR dependencies list.
150
+
151
+ Args:
152
+ dependencies: cir.dependencies — list of edge dicts with 'from'/'to'/'type'
153
+ """
154
+ deps_of: dict[str, list[str]] = {}
155
+ dependents_of: dict[str, list[str]] = {}
156
+ injector_to_class: dict[str, str] = {}
157
+
158
+ for edge in dependencies:
159
+ if edge.get("type") != "injects":
160
+ continue
161
+ from_fqn = (edge.get("from") or "").strip()
162
+ to_fqn = (edge.get("to") or "").strip()
163
+ if not from_fqn or not to_fqn:
164
+ continue
165
+
166
+ # Resolve injector to class level
167
+ if "#" in from_fqn:
168
+ class_fqn = from_fqn.rsplit("#", 1)[0]
169
+ injector_to_class[from_fqn] = class_fqn
170
+ else:
171
+ class_fqn = from_fqn
172
+
173
+ # Build class → [dep, ...] and service → [class, ...] indices
174
+ deps = deps_of.setdefault(class_fqn, [])
175
+ if to_fqn not in deps:
176
+ deps.append(to_fqn)
177
+
178
+ dependents = dependents_of.setdefault(to_fqn, [])
179
+ if class_fqn not in dependents:
180
+ dependents.append(class_fqn)
181
+
182
+ return cls(
183
+ _deps_of=deps_of,
184
+ _dependents_of=dependents_of,
185
+ _injector_to_class=injector_to_class,
186
+ )
sourcecode/cli.py CHANGED
@@ -225,6 +225,8 @@ _SUBCOMMANDS: frozenset[str] = frozenset(
225
225
  "cold-start",
226
226
  # Spring semantic audit
227
227
  "spring-audit",
228
+ # Spring impact chain
229
+ "impact-chain",
228
230
  }
229
231
  )
230
232
 
@@ -3879,6 +3881,189 @@ def spring_audit_cmd(
3879
3881
  typer.echo("✓ copied to clipboard", err=True)
3880
3882
 
3881
3883
 
3884
+ # ── Spring Impact Chain ───────────────────────────────────────────────────────
3885
+
3886
+
3887
+ @app.command("impact-chain")
3888
+ def impact_chain_cmd(
3889
+ symbol: str = typer.Argument(
3890
+ ...,
3891
+ help=(
3892
+ "Symbol to query: FQN, class name, or Class#method. "
3893
+ "Examples: OrderService, com.example.OrderService#placeOrder"
3894
+ ),
3895
+ ),
3896
+ path: Path = typer.Argument(
3897
+ Path("."),
3898
+ help="Repository root (default: current directory)",
3899
+ ),
3900
+ depth: int = typer.Option(
3901
+ 4,
3902
+ "--depth",
3903
+ help="Indirect caller BFS depth (1–8, default: 4).",
3904
+ min=1,
3905
+ max=8,
3906
+ ),
3907
+ output_path: Optional[Path] = typer.Option(
3908
+ None, "--output", "-o",
3909
+ help="Write output to a file instead of stdout.",
3910
+ ),
3911
+ format: str = typer.Option(
3912
+ "json", "--format", "-f",
3913
+ help="Output format: json (default) or yaml.",
3914
+ show_default=True,
3915
+ ),
3916
+ copy: bool = typer.Option(
3917
+ False, "--copy", "-c",
3918
+ help="Copy output to clipboard after a successful run.",
3919
+ ),
3920
+ query_type: str = typer.Option(
3921
+ "impact", "--type", "-t",
3922
+ help="Query type: impact (default) or events.",
3923
+ show_default=True,
3924
+ ),
3925
+ ) -> None:
3926
+ """Spring impact-chain: systemic blast radius of a symbol with TX/SEC enrichment.
3927
+
3928
+ \b
3929
+ Given a symbol (class or method), returns:
3930
+ - direct_callers — symbols that directly call the target
3931
+ - indirect_callers — transitive callers (BFS up to --depth hops)
3932
+ - endpoints_affected — HTTP endpoints reachable through the call chain
3933
+ - transaction_boundary — @Transactional semantics on the target (if any)
3934
+ - security_surfaces — per-endpoint security policy + SEC findings
3935
+ - impact_findings — TX/SEC audit findings touching the call chain
3936
+ - risk_level — critical | high | medium | low
3937
+
3938
+ \b
3939
+ With --type events, returns event topology:
3940
+ - publishers — FQNs that publish the event class
3941
+ - consumers — listeners with TX phase metadata
3942
+ - event_graph — publisher → event → consumer edges (BFS ≤ 2)
3943
+ - transaction_context — AFTER_COMMIT consumers, BEFORE_COMMIT risks
3944
+ - risk_level — high | medium | low
3945
+
3946
+ \b
3947
+ Consumes SpringSemanticModel — zero duplicate CIR traversals.
3948
+ JAVA/SPRING ONLY.
3949
+
3950
+ \b
3951
+ Examples:
3952
+ sourcecode impact-chain OrderService .
3953
+ sourcecode impact-chain com.example.OrderService#placeOrder /path/to/repo
3954
+ sourcecode impact-chain PaymentService . --depth 6 --output impact.json
3955
+ """
3956
+ import json as _json
3957
+
3958
+ from sourcecode.repository_ir import find_java_files
3959
+ from sourcecode.canonical_ir import build_canonical_ir
3960
+ from sourcecode.spring_model import SpringSemanticModel
3961
+ from sourcecode.spring_impact import run_impact_chain
3962
+ from sourcecode.spring_findings import SpringAuditResult
3963
+
3964
+ _VALID_TYPES = ("impact", "events")
3965
+ if query_type not in _VALID_TYPES:
3966
+ _emit_error_json(
3967
+ INVALID_INPUT_CODE,
3968
+ f"Invalid --type '{query_type}'. Valid values: {', '.join(_VALID_TYPES)}",
3969
+ flag="--type",
3970
+ value=query_type,
3971
+ valid_values=list(_VALID_TYPES),
3972
+ hint="Use --type impact (default) or --type events.",
3973
+ expected="impact | events",
3974
+ )
3975
+ raise typer.Exit(code=1)
3976
+
3977
+ target = path.resolve()
3978
+ if not target.exists() or not target.is_dir():
3979
+ _emit_error_json(
3980
+ INVALID_INPUT_CODE,
3981
+ f"'{target}' is not a valid directory.",
3982
+ path=str(target),
3983
+ hint="Pass an existing repository directory.",
3984
+ expected="A directory path.",
3985
+ )
3986
+ raise typer.Exit(code=1)
3987
+
3988
+ if format not in ("json", "yaml"):
3989
+ _emit_error_json(
3990
+ INVALID_INPUT_CODE,
3991
+ f"Invalid format '{format}'.",
3992
+ hint="format must be: json or yaml.",
3993
+ expected="json | yaml",
3994
+ )
3995
+ raise typer.Exit(code=1)
3996
+
3997
+ file_list = find_java_files(target)
3998
+ if not file_list:
3999
+ data: dict = {
4000
+ "schema_version": "1.0",
4001
+ "symbol": symbol,
4002
+ "resolution": "not_found",
4003
+ "analysis_warnings": ["No Java files found in repository — Spring analysis requires Java source."],
4004
+ "risk_level": "unknown",
4005
+ "confidence": "low",
4006
+ "metadata": {},
4007
+ }
4008
+ output = _serialize_dict(data, format)
4009
+ if output_path is not None:
4010
+ output_path.write_text(output, encoding="utf-8")
4011
+ typer.echo("Impact chain written to " + str(output_path), err=True)
4012
+ else:
4013
+ sys.stdout.buffer.write(output.encode("utf-8"))
4014
+ sys.stdout.buffer.write(b"\n")
4015
+ sys.stdout.buffer.flush()
4016
+ return
4017
+
4018
+ cir = build_canonical_ir(file_list, target)
4019
+ _model = SpringSemanticModel.build(cir)
4020
+
4021
+ if query_type == "events":
4022
+ from sourcecode.spring_event_topology import run_event_topology
4023
+ evt_result = run_event_topology(cir, symbol, model=_model)
4024
+ data = evt_result.to_dict()
4025
+ output = _serialize_dict(data, format)
4026
+ if output_path is not None:
4027
+ output_path.write_text(output, encoding="utf-8")
4028
+ typer.echo(
4029
+ f"Event topology written to {output_path} "
4030
+ f"(risk: {evt_result.risk_level}, "
4031
+ f"{evt_result.metadata.get('publisher_count', 0)} publishers, "
4032
+ f"{evt_result.metadata.get('consumer_count', 0)} consumers)",
4033
+ err=True,
4034
+ )
4035
+ else:
4036
+ sys.stdout.buffer.write(output.encode("utf-8"))
4037
+ sys.stdout.buffer.write(b"\n")
4038
+ sys.stdout.buffer.flush()
4039
+ if copy:
4040
+ if _copy_to_clipboard(output):
4041
+ typer.echo("✓ copied to clipboard", err=True)
4042
+ return
4043
+
4044
+ result = run_impact_chain(cir, symbol, depth=depth, root=target, model=_model)
4045
+
4046
+ data = result.to_dict()
4047
+ output = _serialize_dict(data, format)
4048
+
4049
+ if output_path is not None:
4050
+ output_path.write_text(output, encoding="utf-8")
4051
+ typer.echo(
4052
+ f"Impact chain written to {output_path} "
4053
+ f"(risk: {result.risk_level}, "
4054
+ f"{len(result.direct_callers)} direct callers, "
4055
+ f"{len(result.endpoints_affected)} endpoints)",
4056
+ err=True,
4057
+ )
4058
+ else:
4059
+ sys.stdout.buffer.write(output.encode("utf-8"))
4060
+ sys.stdout.buffer.write(b"\n")
4061
+ sys.stdout.buffer.flush()
4062
+ if copy:
4063
+ if _copy_to_clipboard(output):
4064
+ typer.echo("✓ copied to clipboard", err=True)
4065
+
4066
+
3882
4067
  # ── Enterprise Workflow Commands ──────────────────────────────────────────────
3883
4068
  #
3884
4069
  # These are the five canonical enterprise workflows. Each is a thin wrapper
sourcecode/mcp/server.py CHANGED
@@ -649,6 +649,46 @@ def get_spring_audit(repo_path: str = ".", scope: str = "all") -> dict:
649
649
  )
650
650
 
651
651
 
652
+ @mcp.tool()
653
+ def get_impact_chain(repo_path: str = ".", symbol: str = "", depth: int = 4) -> dict:
654
+ """Spring impact-chain: systemic blast radius of a symbol with TX/SEC semantic enrichment. JAVA/SPRING ONLY.
655
+
656
+ Do NOT call this on non-Java repositories — it will return resolution=not_found.
657
+
658
+ Maps to: sourcecode impact-chain <symbol> <repo_path> [--depth <depth>]
659
+ Returns: ImpactChainResult with schema_version, symbol, resolution,
660
+ direct_callers, indirect_callers, endpoints_affected,
661
+ transaction_boundary (propagation/isolation/read_only),
662
+ security_surfaces (per-endpoint policy + finding IDs),
663
+ impact_findings (TX-001..005 + SEC-001..003 findings in call chain),
664
+ analysis_warnings, risk_level, confidence, metadata.
665
+
666
+ symbol: FQN, class name, or Class#method. Examples:
667
+ "OrderService", "com.example.OrderService#placeOrder"
668
+ repo_path: absolute path to the Java repository (default: current working directory).
669
+ depth: BFS depth for indirect caller traversal (1–8, default: 4).
670
+ """
671
+ _raw = repo_path
672
+ try:
673
+ if not isinstance(repo_path, str):
674
+ return _err("repo_path must be a string", "INVALID_ARGUMENT")
675
+ if not isinstance(symbol, str) or not symbol.strip():
676
+ return _err("symbol must be a non-empty string", "INVALID_ARGUMENT")
677
+ if not isinstance(depth, int) or depth < 1 or depth > 8:
678
+ return _err("depth must be an integer between 1 and 8", "INVALID_ARGUMENT")
679
+ repo_path = _normalize_repo_path(repo_path)
680
+ _path_err = _check_repo_path(repo_path)
681
+ if _path_err is not None:
682
+ return _path_err
683
+ args = ["impact-chain", symbol.strip(), repo_path, "--depth", str(depth)]
684
+ return _execute(args)
685
+ except Exception as exc:
686
+ return _err(
687
+ f"Internal error: {type(exc).__name__}: {exc} — repo_path recibido: {_raw}",
688
+ "INTERNAL_ERROR",
689
+ )
690
+
691
+
652
692
  @mcp.tool()
653
693
  def get_module_context(repo_path: str = ".", module: str = "") -> dict:
654
694
  """Compact analysis of a specific module or subdirectory within a repository.
@@ -239,7 +239,7 @@ _LOMBOK_CTOR_ANNOTATIONS: frozenset[str] = frozenset({
239
239
  })
240
240
 
241
241
  # Transaction annotations whose args must be captured for semantic analysis.
242
- _TX_ANNOTATIONS: frozenset[str] = frozenset({"@Transactional"})
242
+ _TX_ANNOTATIONS: frozenset[str] = frozenset({"@Transactional", "@TransactionalEventListener"})
243
243
 
244
244
  # Combined set used in _extract_symbols annotation-value capture.
245
245
  _CAPTURE_ANN_ARGS: frozenset[str] = (
@@ -307,7 +307,9 @@ _SPRING_OTHER: frozenset[str] = frozenset({
307
307
  "@PutMapping", "@DeleteMapping", "@PatchMapping", "@Autowired",
308
308
  "@Inject", "@Value", "@Qualifier", "@EnableWebSecurity",
309
309
  "@SpringBootApplication", "@EnableAutoConfiguration",
310
- "@EventListener", "@Async", "@Scheduled", "@Cacheable", "@CacheEvict",
310
+ "@EventListener", "@TransactionalEventListener",
311
+ "@KafkaListener", "@RabbitListener",
312
+ "@Async", "@Scheduled", "@Cacheable", "@CacheEvict",
311
313
  # CDI / Jakarta EE
312
314
  "@ApplicationScoped", "@RequestScoped", "@SessionScoped", "@Dependent",
313
315
  "@Named", "@Produces", "@Consumes",
@@ -1101,16 +1103,21 @@ def _build_relations(
1101
1103
  ))
1102
1104
 
1103
1105
  # Event flow edges — listens_to_event and publishes_event.
1104
- # Spring: method with @EventListener → resolved event parameter type(s).
1106
+ # Spring: method with @EventListener or @TransactionalEventListener → resolved event type(s).
1107
+ _LISTENER_ANNOTATIONS: frozenset[str] = frozenset({
1108
+ "@EventListener", "@TransactionalEventListener",
1109
+ })
1105
1110
  for sym in symbols:
1106
- if sym.type == "method" and "@EventListener" in sym.annotations:
1111
+ if sym.type == "method" and (sym.annotations and
1112
+ any(a in _LISTENER_ANNOTATIONS for a in sym.annotations)):
1113
+ ann = next(a for a in sym.annotations if a in _LISTENER_ANNOTATIONS)
1107
1114
  for imp_fqn in sym.imports_used:
1108
1115
  edges.append(RelationEdge(
1109
1116
  from_symbol=sym.symbol,
1110
1117
  to_symbol=imp_fqn,
1111
1118
  type="listens_to_event",
1112
1119
  confidence="high",
1113
- evidence={"type": "annotation", "value": "@EventListener"},
1120
+ evidence={"type": "annotation", "value": ann},
1114
1121
  ))
1115
1122
 
1116
1123
  _class_syms = [s for s in symbols if s.type in ("class", "interface") and "#" not in s.symbol]