sourcecode 1.35.2__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 +1 -1
- sourcecode/cli.py +51 -1
- sourcecode/repository_ir.py +12 -5
- sourcecode/spring_event_topology.py +427 -0
- {sourcecode-1.35.2.dist-info → sourcecode-1.35.3.dist-info}/METADATA +2 -2
- {sourcecode-1.35.2.dist-info → sourcecode-1.35.3.dist-info}/RECORD +9 -8
- {sourcecode-1.35.2.dist-info → sourcecode-1.35.3.dist-info}/WHEEL +0 -0
- {sourcecode-1.35.2.dist-info → sourcecode-1.35.3.dist-info}/entry_points.txt +0 -0
- {sourcecode-1.35.2.dist-info → sourcecode-1.35.3.dist-info}/licenses/LICENSE +0 -0
sourcecode/__init__.py
CHANGED
sourcecode/cli.py
CHANGED
|
@@ -3917,6 +3917,11 @@ def impact_chain_cmd(
|
|
|
3917
3917
|
False, "--copy", "-c",
|
|
3918
3918
|
help="Copy output to clipboard after a successful run.",
|
|
3919
3919
|
),
|
|
3920
|
+
query_type: str = typer.Option(
|
|
3921
|
+
"impact", "--type", "-t",
|
|
3922
|
+
help="Query type: impact (default) or events.",
|
|
3923
|
+
show_default=True,
|
|
3924
|
+
),
|
|
3920
3925
|
) -> None:
|
|
3921
3926
|
"""Spring impact-chain: systemic blast radius of a symbol with TX/SEC enrichment.
|
|
3922
3927
|
|
|
@@ -3930,6 +3935,14 @@ def impact_chain_cmd(
|
|
|
3930
3935
|
- impact_findings — TX/SEC audit findings touching the call chain
|
|
3931
3936
|
- risk_level — critical | high | medium | low
|
|
3932
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
|
+
|
|
3933
3946
|
\b
|
|
3934
3947
|
Consumes SpringSemanticModel — zero duplicate CIR traversals.
|
|
3935
3948
|
JAVA/SPRING ONLY.
|
|
@@ -3948,6 +3961,19 @@ def impact_chain_cmd(
|
|
|
3948
3961
|
from sourcecode.spring_impact import run_impact_chain
|
|
3949
3962
|
from sourcecode.spring_findings import SpringAuditResult
|
|
3950
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
|
+
|
|
3951
3977
|
target = path.resolve()
|
|
3952
3978
|
if not target.exists() or not target.is_dir():
|
|
3953
3979
|
_emit_error_json(
|
|
@@ -3970,7 +3996,7 @@ def impact_chain_cmd(
|
|
|
3970
3996
|
|
|
3971
3997
|
file_list = find_java_files(target)
|
|
3972
3998
|
if not file_list:
|
|
3973
|
-
data = {
|
|
3999
|
+
data: dict = {
|
|
3974
4000
|
"schema_version": "1.0",
|
|
3975
4001
|
"symbol": symbol,
|
|
3976
4002
|
"resolution": "not_found",
|
|
@@ -3991,6 +4017,30 @@ def impact_chain_cmd(
|
|
|
3991
4017
|
|
|
3992
4018
|
cir = build_canonical_ir(file_list, target)
|
|
3993
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
|
+
|
|
3994
4044
|
result = run_impact_chain(cir, symbol, depth=depth, root=target, model=_model)
|
|
3995
4045
|
|
|
3996
4046
|
data = result.to_dict()
|
sourcecode/repository_ir.py
CHANGED
|
@@ -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", "@
|
|
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
|
|
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
|
|
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":
|
|
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]
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
"""spring_event_topology.py — Event Topology: event_class → publishers, consumers, propagation graph.
|
|
2
|
+
|
|
3
|
+
Pipeline:
|
|
4
|
+
1. Resolve event symbol from CIR
|
|
5
|
+
2. Find publishers via model.event_graph
|
|
6
|
+
3. Find Spring consumers via model.event_graph (EventListener + TransactionalEventListener)
|
|
7
|
+
4. Enrich consumers with TX phase from raw IR annotation_values
|
|
8
|
+
5. Build event propagation graph (BFS depth ≤ 2)
|
|
9
|
+
6. Attach TX semantics: AFTER_COMMIT consumers, BEFORE_COMMIT risks
|
|
10
|
+
7. Compute risk level + confidence
|
|
11
|
+
|
|
12
|
+
Hard constraints:
|
|
13
|
+
- NO guessing / LLM inference
|
|
14
|
+
- ONLY CIR + AST-derived annotation data
|
|
15
|
+
- Deterministic: identical CIR → identical result
|
|
16
|
+
- Reuses model.event_graph + model.tx_index — no duplicate CIR traversal
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
model = SpringSemanticModel.build(cir)
|
|
20
|
+
result = run_event_topology(cir, "com.example.OrderCreatedEvent", model=model)
|
|
21
|
+
output = json.dumps(result.to_dict(), indent=2)
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import re
|
|
26
|
+
import time
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import TYPE_CHECKING, Optional
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from sourcecode.canonical_ir import CanonicalRepositoryIR
|
|
32
|
+
from sourcecode.spring_model import SpringSemanticModel
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Constants
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
_SCHEMA_VERSION = "1.0"
|
|
39
|
+
|
|
40
|
+
# Phase annotation value parser: phase=TransactionPhase.AFTER_COMMIT
|
|
41
|
+
_TX_PHASE_RE = re.compile(r'phase\s*=\s*(?:TransactionPhase\.)?(\w+)')
|
|
42
|
+
|
|
43
|
+
# TransactionalEventListener default phase (Spring default)
|
|
44
|
+
_DEFAULT_TX_PHASE = "AFTER_COMMIT"
|
|
45
|
+
|
|
46
|
+
_RISK_FANOUT_HIGH = 5
|
|
47
|
+
_RISK_FANOUT_MEDIUM = 2
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# Data model
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class EventConsumer:
|
|
56
|
+
"""Single consumer of a Spring application event."""
|
|
57
|
+
fqn: str
|
|
58
|
+
type: str # "spring_event" | "transactional"
|
|
59
|
+
transactional_phase: str # "" | "BEFORE_COMMIT" | "AFTER_COMMIT" | "AFTER_ROLLBACK" | "AFTER_COMPLETION"
|
|
60
|
+
source_file: str
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> dict:
|
|
63
|
+
d: dict = {
|
|
64
|
+
"fqn": self.fqn,
|
|
65
|
+
"type": self.type,
|
|
66
|
+
"source_file": self.source_file,
|
|
67
|
+
}
|
|
68
|
+
if self.transactional_phase:
|
|
69
|
+
d["transactional_phase"] = self.transactional_phase
|
|
70
|
+
return d
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class EventTopologyResult:
|
|
75
|
+
"""Output contract for event topology query.
|
|
76
|
+
|
|
77
|
+
Stable contract — do not remove or rename fields.
|
|
78
|
+
"""
|
|
79
|
+
schema_version: str = _SCHEMA_VERSION
|
|
80
|
+
event_class: str = ""
|
|
81
|
+
resolution: str = "not_found" # "exact" | "class_expanded" | "partial" | "not_found"
|
|
82
|
+
publishers: list[str] = field(default_factory=list)
|
|
83
|
+
consumers: list[dict] = field(default_factory=list)
|
|
84
|
+
event_graph: dict = field(default_factory=dict)
|
|
85
|
+
transaction_context: dict = field(default_factory=dict)
|
|
86
|
+
risk_level: str = "low"
|
|
87
|
+
confidence: str = "high"
|
|
88
|
+
limitations: list[str] = field(default_factory=list)
|
|
89
|
+
metadata: dict = field(default_factory=dict)
|
|
90
|
+
|
|
91
|
+
def to_dict(self) -> dict:
|
|
92
|
+
return {
|
|
93
|
+
"schema_version": self.schema_version,
|
|
94
|
+
"event_class": self.event_class,
|
|
95
|
+
"resolution": self.resolution,
|
|
96
|
+
"publishers": self.publishers,
|
|
97
|
+
"consumers": self.consumers,
|
|
98
|
+
"event_graph": self.event_graph,
|
|
99
|
+
"transaction_context": self.transaction_context,
|
|
100
|
+
"risk_level": self.risk_level,
|
|
101
|
+
"confidence": self.confidence,
|
|
102
|
+
"limitations": self.limitations,
|
|
103
|
+
"metadata": self.metadata,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
# Internal helpers
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def _class_of(fqn: str) -> str:
|
|
112
|
+
if "#" in fqn:
|
|
113
|
+
return fqn.split("#")[0]
|
|
114
|
+
return fqn
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _build_fqn_index(cir: "CanonicalRepositoryIR") -> dict[str, dict]:
|
|
118
|
+
"""Build FQN → raw IR node dict for fast annotation lookup.
|
|
119
|
+
|
|
120
|
+
Reads once from cir._raw_ir. Returns {} if raw IR not available.
|
|
121
|
+
"""
|
|
122
|
+
raw = getattr(cir, "_raw_ir", {}) or {}
|
|
123
|
+
nodes = (raw.get("graph") or {}).get("nodes") or []
|
|
124
|
+
return {n["fqn"]: n for n in nodes if "fqn" in n}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _extract_tx_phase(annotation_values: dict, annotations: list) -> str:
|
|
128
|
+
"""Extract @TransactionalEventListener phase from annotation_values.
|
|
129
|
+
|
|
130
|
+
Returns empty string for plain @EventListener (no TX constraint).
|
|
131
|
+
Returns _DEFAULT_TX_PHASE when annotation present but phase not specified.
|
|
132
|
+
"""
|
|
133
|
+
if "@TransactionalEventListener" not in annotations:
|
|
134
|
+
return ""
|
|
135
|
+
raw_args = annotation_values.get("@TransactionalEventListener", "")
|
|
136
|
+
if not raw_args:
|
|
137
|
+
return _DEFAULT_TX_PHASE
|
|
138
|
+
m = _TX_PHASE_RE.search(raw_args)
|
|
139
|
+
if m:
|
|
140
|
+
return m.group(1).upper()
|
|
141
|
+
return _DEFAULT_TX_PHASE
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _resolve_event_symbol(
|
|
145
|
+
event_class: str,
|
|
146
|
+
cir: "CanonicalRepositoryIR",
|
|
147
|
+
) -> tuple[str, str, list[str]]:
|
|
148
|
+
"""Resolve event_class to CIR FQN.
|
|
149
|
+
|
|
150
|
+
Returns (resolved_fqn, resolution, warnings).
|
|
151
|
+
resolution: "exact" | "class_expanded" | "not_found"
|
|
152
|
+
"""
|
|
153
|
+
symbols = cir.symbols
|
|
154
|
+
warnings: list[str] = []
|
|
155
|
+
|
|
156
|
+
# 1. Exact FQN match
|
|
157
|
+
if event_class in symbols:
|
|
158
|
+
return event_class, "exact", []
|
|
159
|
+
|
|
160
|
+
# 2. Class-part suffix match (handles simple names like "OrderCreatedEvent")
|
|
161
|
+
candidates = [
|
|
162
|
+
s for s in symbols
|
|
163
|
+
if _class_of(s) == event_class or _class_of(s).endswith("." + event_class)
|
|
164
|
+
]
|
|
165
|
+
unique_classes = {_class_of(s) for s in candidates}
|
|
166
|
+
if len(unique_classes) == 1:
|
|
167
|
+
return next(iter(unique_classes)), "class_expanded", []
|
|
168
|
+
if len(unique_classes) > 1:
|
|
169
|
+
# Multiple matches — take first alphabetically, warn
|
|
170
|
+
chosen = sorted(unique_classes)[0]
|
|
171
|
+
warnings.append(
|
|
172
|
+
f"Ambiguous event class '{event_class}': matched {sorted(unique_classes)}. "
|
|
173
|
+
f"Using '{chosen}'."
|
|
174
|
+
)
|
|
175
|
+
return chosen, "partial", warnings
|
|
176
|
+
|
|
177
|
+
return "", "not_found", [f"Event class '{event_class}' not found in CIR."]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _find_kafka_rabbit_counts(
|
|
181
|
+
fqn_index: dict[str, dict],
|
|
182
|
+
) -> tuple[int, int]:
|
|
183
|
+
"""Count @KafkaListener and @RabbitListener methods in raw IR."""
|
|
184
|
+
kafka_count = 0
|
|
185
|
+
rabbit_count = 0
|
|
186
|
+
for node in fqn_index.values():
|
|
187
|
+
anns = node.get("annotations") or []
|
|
188
|
+
if "@KafkaListener" in anns:
|
|
189
|
+
kafka_count += 1
|
|
190
|
+
if "@RabbitListener" in anns:
|
|
191
|
+
rabbit_count += 1
|
|
192
|
+
return kafka_count, rabbit_count
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _compute_event_risk(
|
|
196
|
+
publisher_count: int,
|
|
197
|
+
consumer_count: int,
|
|
198
|
+
before_commit_count: int,
|
|
199
|
+
cross_module: bool,
|
|
200
|
+
) -> str:
|
|
201
|
+
"""Deterministic risk scoring per spec.
|
|
202
|
+
|
|
203
|
+
high: fanout > 5 OR cross-module propagation OR BEFORE_COMMIT consumers
|
|
204
|
+
medium: 2–5 consumers
|
|
205
|
+
low: ≤1 consumer
|
|
206
|
+
"""
|
|
207
|
+
if consumer_count > _RISK_FANOUT_HIGH or cross_module or before_commit_count > 0:
|
|
208
|
+
return "high"
|
|
209
|
+
if consumer_count >= _RISK_FANOUT_MEDIUM:
|
|
210
|
+
return "medium"
|
|
211
|
+
return "low"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
# Orchestrator
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
class EventTopologyOrchestrator:
|
|
219
|
+
"""Stateless query engine: event_class → EventTopologyResult.
|
|
220
|
+
|
|
221
|
+
Consumes pre-built CIR + SpringSemanticModel. Never re-derives data
|
|
222
|
+
already present in the model.
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
def query(
|
|
226
|
+
self,
|
|
227
|
+
cir: "CanonicalRepositoryIR",
|
|
228
|
+
model: "SpringSemanticModel",
|
|
229
|
+
event_class: str,
|
|
230
|
+
) -> EventTopologyResult:
|
|
231
|
+
"""Execute event topology query.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
cir: CanonicalRepositoryIR from build_canonical_ir().
|
|
235
|
+
model: Pre-built SpringSemanticModel.
|
|
236
|
+
event_class: Event class FQN or simple name.
|
|
237
|
+
"""
|
|
238
|
+
t0 = time.monotonic()
|
|
239
|
+
warnings: list[str] = []
|
|
240
|
+
limitations: list[str] = [
|
|
241
|
+
"Only Spring annotations detected (@EventListener, @TransactionalEventListener).",
|
|
242
|
+
"No runtime dynamic event routing — static analysis only.",
|
|
243
|
+
]
|
|
244
|
+
|
|
245
|
+
# ── 1. Resolve event symbol ────────────────────────────────────────
|
|
246
|
+
resolved_fqn, resolution, sym_warnings = _resolve_event_symbol(event_class, cir)
|
|
247
|
+
warnings.extend(sym_warnings)
|
|
248
|
+
|
|
249
|
+
if resolution == "not_found" or not resolved_fqn:
|
|
250
|
+
elapsed = round((time.monotonic() - t0) * 1000, 2)
|
|
251
|
+
return EventTopologyResult(
|
|
252
|
+
event_class=event_class,
|
|
253
|
+
resolution="not_found",
|
|
254
|
+
limitations=limitations + [f"Event class '{event_class}' not found in CIR."],
|
|
255
|
+
confidence="low",
|
|
256
|
+
metadata={"query_time_ms": elapsed},
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# ── 2. Build FQN index for annotation lookups ──────────────────────
|
|
260
|
+
fqn_index = _build_fqn_index(cir)
|
|
261
|
+
|
|
262
|
+
# ── 3. Find publishers ─────────────────────────────────────────────
|
|
263
|
+
publishers = sorted(model.event_graph.publishers_of(resolved_fqn))
|
|
264
|
+
|
|
265
|
+
# ── 4. Find Spring consumers, enrich with TX phase ─────────────────
|
|
266
|
+
raw_consumer_fqns = model.event_graph.listeners_of(resolved_fqn)
|
|
267
|
+
consumers: list[EventConsumer] = []
|
|
268
|
+
for fqn in sorted(raw_consumer_fqns):
|
|
269
|
+
node = fqn_index.get(fqn) or {}
|
|
270
|
+
anns = node.get("annotations") or []
|
|
271
|
+
ann_vals = node.get("annotation_values") or {}
|
|
272
|
+
source_file = node.get("source_file") or ""
|
|
273
|
+
tx_phase = _extract_tx_phase(ann_vals, anns)
|
|
274
|
+
consumer_type = "transactional" if tx_phase else "spring_event"
|
|
275
|
+
consumers.append(EventConsumer(
|
|
276
|
+
fqn=fqn,
|
|
277
|
+
type=consumer_type,
|
|
278
|
+
transactional_phase=tx_phase,
|
|
279
|
+
source_file=source_file,
|
|
280
|
+
))
|
|
281
|
+
|
|
282
|
+
# ── 5. Build event propagation graph (BFS ≤ 2) ────────────────────
|
|
283
|
+
graph_edges: list[dict] = []
|
|
284
|
+
|
|
285
|
+
# Level 0 → 1: publisher → event_class, event_class → consumer
|
|
286
|
+
for pub in publishers:
|
|
287
|
+
graph_edges.append({"from": pub, "to": resolved_fqn, "type": "publishes"})
|
|
288
|
+
for c in consumers:
|
|
289
|
+
graph_edges.append({"from": resolved_fqn, "to": c.fqn, "type": "consumes"})
|
|
290
|
+
|
|
291
|
+
# Level 1 → 2: consumers that also publish other events
|
|
292
|
+
level2_events: dict[str, list[str]] = {} # secondary_event → [l2_consumers]
|
|
293
|
+
for c in consumers:
|
|
294
|
+
# Check if this consumer FQN also publishes events
|
|
295
|
+
# The event_graph publishers dict is indexed by event_type → [publisher_fqns]
|
|
296
|
+
for evt_type, pub_fqns in model.event_graph.publishers.items():
|
|
297
|
+
if evt_type == resolved_fqn:
|
|
298
|
+
continue # same event, skip
|
|
299
|
+
consumer_class = _class_of(c.fqn)
|
|
300
|
+
matching = [p for p in pub_fqns
|
|
301
|
+
if p == c.fqn or _class_of(p) == consumer_class]
|
|
302
|
+
if matching:
|
|
303
|
+
graph_edges.append({
|
|
304
|
+
"from": c.fqn,
|
|
305
|
+
"to": evt_type,
|
|
306
|
+
"type": "re_publishes",
|
|
307
|
+
})
|
|
308
|
+
l2_listeners = model.event_graph.listeners_of(evt_type)
|
|
309
|
+
level2_events[evt_type] = sorted(l2_listeners)
|
|
310
|
+
for l2 in sorted(l2_listeners):
|
|
311
|
+
graph_edges.append({
|
|
312
|
+
"from": evt_type,
|
|
313
|
+
"to": l2,
|
|
314
|
+
"type": "consumes",
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
# ── 6. Kafka/Rabbit counts (metadata only — not linkable to event_class) ──
|
|
318
|
+
kafka_count, rabbit_count = _find_kafka_rabbit_counts(fqn_index)
|
|
319
|
+
if kafka_count > 0 or rabbit_count > 0:
|
|
320
|
+
limitations.append(
|
|
321
|
+
f"Kafka/RabbitMQ consumer binding not supported in v1 — "
|
|
322
|
+
f"{kafka_count} @KafkaListener and {rabbit_count} @RabbitListener "
|
|
323
|
+
f"method(s) found in repo; cannot link to event class without "
|
|
324
|
+
f"explicit topic-to-event mapping."
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# ── 7. TX context ──────────────────────────────────────────────────
|
|
328
|
+
after_commit = [c.fqn for c in consumers if c.transactional_phase == "AFTER_COMMIT"]
|
|
329
|
+
before_commit_risks = [c.fqn for c in consumers if c.transactional_phase == "BEFORE_COMMIT"]
|
|
330
|
+
tx_context = {
|
|
331
|
+
"after_commit_consumers": after_commit,
|
|
332
|
+
"before_commit_risks": before_commit_risks,
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
# ── 8. Cross-module detection ──────────────────────────────────────
|
|
336
|
+
# Simple heuristic: publisher and consumer in different top-level packages
|
|
337
|
+
pub_packages = {_class_of(p).rsplit(".", 2)[0] for p in publishers if "." in _class_of(p)}
|
|
338
|
+
con_packages = {_class_of(c.fqn).rsplit(".", 2)[0] for c in consumers if "." in _class_of(c.fqn)}
|
|
339
|
+
cross_module = bool(pub_packages and con_packages and not pub_packages.isdisjoint(con_packages) is False)
|
|
340
|
+
# More precise: cross-module if top-2-segment packages differ
|
|
341
|
+
def _top2(fqn: str) -> str:
|
|
342
|
+
parts = _class_of(fqn).split(".")
|
|
343
|
+
return ".".join(parts[:2]) if len(parts) >= 2 else fqn
|
|
344
|
+
|
|
345
|
+
pub_top2 = {_top2(p) for p in publishers}
|
|
346
|
+
con_top2 = {_top2(c.fqn) for c in consumers}
|
|
347
|
+
cross_module = bool(pub_top2 and con_top2 and pub_top2 != con_top2)
|
|
348
|
+
|
|
349
|
+
# ── 9. Risk ────────────────────────────────────────────────────────
|
|
350
|
+
risk_level = _compute_event_risk(
|
|
351
|
+
publisher_count=len(publishers),
|
|
352
|
+
consumer_count=len(consumers),
|
|
353
|
+
before_commit_count=len(before_commit_risks),
|
|
354
|
+
cross_module=cross_module,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
# ── 10. Confidence ─────────────────────────────────────────────────
|
|
358
|
+
if resolution == "partial" or warnings:
|
|
359
|
+
confidence = "medium"
|
|
360
|
+
elif not publishers and not consumers:
|
|
361
|
+
confidence = "low"
|
|
362
|
+
else:
|
|
363
|
+
confidence = "high"
|
|
364
|
+
|
|
365
|
+
elapsed_ms = round((time.monotonic() - t0) * 1000, 2)
|
|
366
|
+
|
|
367
|
+
return EventTopologyResult(
|
|
368
|
+
schema_version=_SCHEMA_VERSION,
|
|
369
|
+
event_class=resolved_fqn,
|
|
370
|
+
resolution=resolution,
|
|
371
|
+
publishers=publishers,
|
|
372
|
+
consumers=[c.to_dict() for c in consumers],
|
|
373
|
+
event_graph={
|
|
374
|
+
"edges": graph_edges,
|
|
375
|
+
"level2_events": level2_events,
|
|
376
|
+
},
|
|
377
|
+
transaction_context=tx_context,
|
|
378
|
+
risk_level=risk_level,
|
|
379
|
+
confidence=confidence,
|
|
380
|
+
limitations=limitations,
|
|
381
|
+
metadata={
|
|
382
|
+
"query_time_ms": elapsed_ms,
|
|
383
|
+
"publisher_count": len(publishers),
|
|
384
|
+
"consumer_count": len(consumers),
|
|
385
|
+
"kafka_listeners_in_repo": kafka_count,
|
|
386
|
+
"rabbit_listeners_in_repo": rabbit_count,
|
|
387
|
+
"before_commit_risk_count": len(before_commit_risks),
|
|
388
|
+
"level2_events": list(level2_events.keys()),
|
|
389
|
+
"cross_module": cross_module,
|
|
390
|
+
"model_build_time_ms": model.build_time_ms,
|
|
391
|
+
},
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# ---------------------------------------------------------------------------
|
|
396
|
+
# Convenience entry point
|
|
397
|
+
# ---------------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
def run_event_topology(
|
|
400
|
+
cir: "CanonicalRepositoryIR",
|
|
401
|
+
event_class: str,
|
|
402
|
+
*,
|
|
403
|
+
model: "Optional[SpringSemanticModel]" = None,
|
|
404
|
+
) -> EventTopologyResult:
|
|
405
|
+
"""Run event topology query from a CIR.
|
|
406
|
+
|
|
407
|
+
Args:
|
|
408
|
+
cir: CanonicalRepositoryIR from build_canonical_ir().
|
|
409
|
+
event_class: Event class FQN or simple name.
|
|
410
|
+
model: Pre-built SpringSemanticModel. Built internally if None.
|
|
411
|
+
|
|
412
|
+
Returns EventTopologyResult — always JSON-serializable, never raises.
|
|
413
|
+
"""
|
|
414
|
+
from sourcecode.spring_model import SpringSemanticModel as _SSM
|
|
415
|
+
|
|
416
|
+
try:
|
|
417
|
+
if model is None:
|
|
418
|
+
model = _SSM.build(cir)
|
|
419
|
+
orchestrator = EventTopologyOrchestrator()
|
|
420
|
+
return orchestrator.query(cir, model, event_class)
|
|
421
|
+
except Exception as exc:
|
|
422
|
+
return EventTopologyResult(
|
|
423
|
+
event_class=event_class,
|
|
424
|
+
resolution="not_found",
|
|
425
|
+
limitations=[f"Internal error: {type(exc).__name__}: {exc}"],
|
|
426
|
+
confidence="low",
|
|
427
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sourcecode
|
|
3
|
-
Version: 1.35.
|
|
3
|
+
Version: 1.35.3
|
|
4
4
|
Summary: Persistent structural context and ultra-fast repeated analysis for AI coding agents
|
|
5
5
|
License-File: LICENSE
|
|
6
6
|
Keywords: agents,ai,codebase,context,developer-tools,llm
|
|
@@ -39,7 +39,7 @@ Description-Content-Type: text/markdown
|
|
|
39
39
|
|
|
40
40
|
**Persistent structural context and ultra-fast repeated analysis for AI coding agents.**
|
|
41
41
|
|
|
42
|
-

|
|
43
43
|

|
|
44
44
|
|
|
45
45
|
---
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
sourcecode/__init__.py,sha256=
|
|
1
|
+
sourcecode/__init__.py,sha256=l4fbYaAaQU-7wY0KjeGVwAwIoc3EKyw8SNnqZYuRfP8,103
|
|
2
2
|
sourcecode/adaptive_scanner.py,sha256=XffluXKzJUXrMtjEiAOnSNPZnztdIcts17T9ouHeID0,10521
|
|
3
3
|
sourcecode/architecture_analyzer.py,sha256=qh749a7ykPtGmQI1MR9y6j8TtL_jBdVYFx9YRsLqOMw,44121
|
|
4
4
|
sourcecode/architecture_summary.py,sha256=z34_6v7cSwy98cof2UVciGho7SCrZ93tiqMmq5WNzRQ,20405
|
|
@@ -7,7 +7,7 @@ sourcecode/cache.py,sha256=wAyPrXN5DqiGivnMpeEuun2xHDKfBer2_oBsh6kj_vc,30447
|
|
|
7
7
|
sourcecode/canonical_ir.py,sha256=uwpwCnJxMh_eiIVg4cOLv7-aZthvmDFcG4azCOycLkw,24281
|
|
8
8
|
sourcecode/cir_graphs.py,sha256=6CBwPoFCbHrqW8Bq_9fZUMi2IgcQBmoaiXiJjlOY9QE,7740
|
|
9
9
|
sourcecode/classifier.py,sha256=2lYoSH3vOTkXZYPU7Go2WIet1-IuNzTWVhc-ULnXtgw,8024
|
|
10
|
-
sourcecode/cli.py,sha256=
|
|
10
|
+
sourcecode/cli.py,sha256=UI80E2S1g6Ui300-vICvn4B7NXIuGYWE4a0FXrBa-8E,216875
|
|
11
11
|
sourcecode/code_notes_analyzer.py,sha256=EJemNCNc9Dn-1RZYu-aNbK0ELzmsyC4s6FdHi3XyNEI,9392
|
|
12
12
|
sourcecode/confidence_analyzer.py,sha256=_jckZSxksV-OU38vbkxfVNBnWCtlCq8Vwfg23x1uspA,19054
|
|
13
13
|
sourcecode/context_scorer.py,sha256=QpChSpsmaAYz91rXA4Ue5xzQmNz_ZboZN09YOHScq1U,14679
|
|
@@ -36,13 +36,14 @@ sourcecode/ranking_engine.py,sha256=ZAucq_YX2KkWUuAZf4P0lhtQ_38vEFnUhuGtSZd1S0E,
|
|
|
36
36
|
sourcecode/redactor.py,sha256=SB4hwIvg8h-hvcqKcDWaZvA-aSyn-at-BIRwa0tUv5E,3227
|
|
37
37
|
sourcecode/relevance_scorer.py,sha256=0AgEt4KrV73nioMqBgjhGjtY7L2C7L7cSyKtj3IKcrw,9408
|
|
38
38
|
sourcecode/repo_classifier.py,sha256=FG1vaWKdWXsWdl-S8hjVMiTqcwgaRXkDyvK4rPcOGtQ,22681
|
|
39
|
-
sourcecode/repository_ir.py,sha256=
|
|
39
|
+
sourcecode/repository_ir.py,sha256=LI_kJzdz-iQOb0e0LuHnEzXRjUdoUnoRlicEk46rwgA,162975
|
|
40
40
|
sourcecode/ris.py,sha256=RcqLVwC-doFcKKViYDkCjZLBqf_wzLES7-F6vHEeWzE,20419
|
|
41
41
|
sourcecode/runtime_classifier.py,sha256=uTAD6BDCiBLUZEDRfqk718kM4RTT_vAbfkcOI2_Xx58,18432
|
|
42
42
|
sourcecode/scanner.py,sha256=WdOQ78mMzjR1NjmKTlbxdgwinnCTfAhxCVLBEFQiFHU,8899
|
|
43
43
|
sourcecode/schema.py,sha256=aHNXDf8LGyUC8ZDE_VS9kiskC2-Oswhi_WnpdGy6HDw,24897
|
|
44
44
|
sourcecode/semantic_analyzer.py,sha256=TDuC3wzZR2DPm1mgrAg1YSLk2QzJoueS3TZAmyGGpCU,89417
|
|
45
45
|
sourcecode/serializer.py,sha256=ooNZW2_fqx__BXII25eAWq-BomodvqQ6opUT_niQYCA,123403
|
|
46
|
+
sourcecode/spring_event_topology.py,sha256=LvGv5RXtU_O-fVB_OO9eDD2UmZM72Jn2oUHgOo50Qm0,17157
|
|
46
47
|
sourcecode/spring_findings.py,sha256=8V91iHOg9hFgg6tLLl4FSsgrF-dBqOcO2s-K5sD_goA,5417
|
|
47
48
|
sourcecode/spring_impact.py,sha256=ACmkbQMWgoJqZ9QLdHJLB1qNuFLgjEVv2r624X9Y6Y4,29004
|
|
48
49
|
sourcecode/spring_model.py,sha256=IzMcM5ftw1_EHG3FGUDT7qdAMpo3eqbAE1LRuasfr_4,14739
|
|
@@ -89,8 +90,8 @@ sourcecode/telemetry/consent.py,sha256=wLMvGNJeSSyZoNkQXpoUioY6mMv4Qdvuw7S9jAEWn
|
|
|
89
90
|
sourcecode/telemetry/events.py,sha256=oEvvulfsv5GIDWG2174gSS6tNB95w38AIYiYeifGKlE,2294
|
|
90
91
|
sourcecode/telemetry/filters.py,sha256=Asa71oRl7q3Wt_FMwuufIZJFzSYdgRNKS8LHCIyFeYE,4805
|
|
91
92
|
sourcecode/telemetry/transport.py,sha256=KJeIPCPWMdmbCP3ySGs2iUlia34U6vWne2dZsUezesw,1560
|
|
92
|
-
sourcecode-1.35.
|
|
93
|
-
sourcecode-1.35.
|
|
94
|
-
sourcecode-1.35.
|
|
95
|
-
sourcecode-1.35.
|
|
96
|
-
sourcecode-1.35.
|
|
93
|
+
sourcecode-1.35.3.dist-info/METADATA,sha256=3tFt-FeyMB5EsxTzF2Jw2DY8Up3A0iHat59uNWsylT8,16440
|
|
94
|
+
sourcecode-1.35.3.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
95
|
+
sourcecode-1.35.3.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
|
|
96
|
+
sourcecode-1.35.3.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
|
|
97
|
+
sourcecode-1.35.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|