sourcecode 1.35.2__py3-none-any.whl → 1.35.4__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.2"
3
+ __version__ = "1.35.4"
sourcecode/cir_graphs.py CHANGED
@@ -68,11 +68,23 @@ class ImplementationGraph:
68
68
  dependencies: cir.dependencies — list of edge dicts with 'from'/'to'/'type'
69
69
  known_symbols: set(cir.symbols) — only in-repo FQNs
70
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).
71
+ The Java parser stores 'implements' edges with the simple class name in the 'to'
72
+ field (e.g. 'OrderService') rather than the FQN. We resolve these via a
73
+ precomputed simple-name → FQN map built from known_symbols. Only unambiguous
74
+ resolutions are accepted; external framework interfaces and ambiguous names are
75
+ excluded.
76
+
73
77
  Includes edges where the implementing class (from_fqn) is NOT in known_symbols
74
78
  only when the interface IS known — this handles partial-parse edge cases.
75
79
  """
80
+ # Pre-build simple-name → [FQN] lookup for class-level symbols only (no '#').
81
+ # Used to resolve unqualified interface names (BUG-IC-001).
82
+ _simple_to_fqn: dict[str, list[str]] = {}
83
+ for sym in known_symbols:
84
+ if "#" not in sym and "." in sym:
85
+ simple = sym.rsplit(".", 1)[1]
86
+ _simple_to_fqn.setdefault(simple, []).append(sym)
87
+
76
88
  impl_of: dict[str, list[str]] = {}
77
89
  ifaces_of: dict[str, list[str]] = {}
78
90
 
@@ -83,9 +95,13 @@ class ImplementationGraph:
83
95
  to_fqn = (edge.get("to") or "").strip()
84
96
  if not from_fqn or not to_fqn:
85
97
  continue
86
- # Only track when the interface is an in-repo symbol
98
+ # Resolve to_fqn: prefer exact known-symbol match, then try simple-name lookup.
99
+ # Rejects external interfaces (java.*, org.springframework.*) and ambiguous names.
87
100
  if to_fqn not in known_symbols:
88
- continue
101
+ candidates = _simple_to_fqn.get(to_fqn, [])
102
+ if len(candidates) != 1:
103
+ continue
104
+ to_fqn = candidates[0]
89
105
  # Ignore malformed FQNs (e.g. generic type fragments like "Long>")
90
106
  if ">" in to_fqn or "<" in to_fqn:
91
107
  continue
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()
@@ -202,10 +202,13 @@ _FILTER_SECURITY_ANNOTATIONS: frozenset[str] = frozenset({
202
202
  })
203
203
 
204
204
  # Programmatic security: method-call patterns that indicate runtime auth enforcement.
205
+ # Requires method-call or field-access context — bare class name mentions (imports,
206
+ # type declarations) must NOT match or IAM/auth-domain repos generate false positives.
205
207
  _PROGRAMMATIC_SECURITY_RE = re.compile(
206
208
  r"\b(?:hasRole|hasAuthority|isAuthenticated|requirePermission|checkPermission"
207
209
  r"|assertAuthorized|authenticate)\s*\("
208
- r"|(?:Authentication|SecurityContext|Principal|AuthorizationManager|AccessDecisionManager)\b"
210
+ r"|SecurityContextHolder\."
211
+ r"|\.(?:getAuthentication|getSecurityContext|getPrincipal|isAuthorized|checkAccess)\s*\("
209
212
  r"|throw\s+new\s+(?:AccessDeniedException|UnauthorizedException|ForbiddenException|AuthenticationException)\b",
210
213
  re.MULTILINE,
211
214
  )
@@ -239,7 +242,7 @@ _LOMBOK_CTOR_ANNOTATIONS: frozenset[str] = frozenset({
239
242
  })
240
243
 
241
244
  # Transaction annotations whose args must be captured for semantic analysis.
242
- _TX_ANNOTATIONS: frozenset[str] = frozenset({"@Transactional"})
245
+ _TX_ANNOTATIONS: frozenset[str] = frozenset({"@Transactional", "@TransactionalEventListener"})
243
246
 
244
247
  # Combined set used in _extract_symbols annotation-value capture.
245
248
  _CAPTURE_ANN_ARGS: frozenset[str] = (
@@ -307,7 +310,9 @@ _SPRING_OTHER: frozenset[str] = frozenset({
307
310
  "@PutMapping", "@DeleteMapping", "@PatchMapping", "@Autowired",
308
311
  "@Inject", "@Value", "@Qualifier", "@EnableWebSecurity",
309
312
  "@SpringBootApplication", "@EnableAutoConfiguration",
310
- "@EventListener", "@Async", "@Scheduled", "@Cacheable", "@CacheEvict",
313
+ "@EventListener", "@TransactionalEventListener",
314
+ "@KafkaListener", "@RabbitListener",
315
+ "@Async", "@Scheduled", "@Cacheable", "@CacheEvict",
311
316
  # CDI / Jakarta EE
312
317
  "@ApplicationScoped", "@RequestScoped", "@SessionScoped", "@Dependent",
313
318
  "@Named", "@Produces", "@Consumes",
@@ -321,6 +326,30 @@ _PUBLISH_EVENT_RE = re.compile(r'\.publishEvent\s*\(\s*new\s+(\w+)\s*[(\{]')
321
326
  # Keycloak SPI event fire pattern: XxxEvent.fire(session, ...)
322
327
  _FIRE_EVENT_RE = re.compile(r'\b(\w+Event)\.fire\s*\(')
323
328
 
329
+ # Class-level consumer detection from class signature (not annotations).
330
+ # Pattern 1: implements [Prefix]ApplicationListener<EventType>
331
+ # Matches both the standard Spring interface (ApplicationListener<E>) and
332
+ # framework-specific subinterfaces (BroadleafApplicationListener<E>,
333
+ # SmartApplicationListener<E>, etc.). Uses \w* prefix instead of \b so
334
+ # that "Broadleaf" prefix does not break the word boundary. (BUG-EVT-001)
335
+ _APP_LISTENER_RE = re.compile(r'\w*ApplicationListener\s*<\s*(\w+)\s*>')
336
+ # Pattern 2: extends AbstractXxxEventListener<EventType> — abstract base class pattern
337
+ # (Broadleaf's AbstractBroadleafApplicationEventListener and similar).
338
+ # Matches any parent class name that contains "EventListener".
339
+ _ABSTRACT_LISTENER_RE = re.compile(r'\bextends\s+\w+EventListener\w*\s*<\s*(\w+)\s*>')
340
+
341
+ # Block comment stripper — removes /* ... */ (including Javadoc) to prevent
342
+ # _PUBLISH_EVENT_RE / _FIRE_EVENT_RE from matching example code in comments.
343
+ _BLOCK_COMMENT_RE = re.compile(r'/\*.*?\*/', re.DOTALL)
344
+ _LINE_COMMENT_RE = re.compile(r'//[^\n]*')
345
+
346
+
347
+ def _strip_java_comments(source: str) -> str:
348
+ """Remove // line comments and /* */ block comments from Java source."""
349
+ source = _BLOCK_COMMENT_RE.sub(' ', source)
350
+ source = _LINE_COMMENT_RE.sub(' ', source)
351
+ return source
352
+
324
353
  # Edge types used for subsystem grouping — semantic hierarchy only, not imports
325
354
  _SUBSYSTEM_STRUCTURAL_EDGES: frozenset[str] = frozenset({
326
355
  "extends", "implements", "injects", "contained_in",
@@ -544,9 +573,37 @@ def _extract_symbols(source: str, rel_path: str) -> tuple[str, list[SymbolRecord
544
573
  pending_ann_values: dict[str, str] = {}
545
574
  in_block_comment = False
546
575
 
576
+ # BUG-PARSER-001: normalize multi-line class declarations where the opening brace
577
+ # appears on a continuation line (e.g. "implements A,\n B, C {").
578
+ # _CLASS_DECL_RE requires '{' on the same line as 'class' — joining the continuation
579
+ # here makes the regex work without changing the per-line brace-depth counter.
580
+ _raw_lines = source.splitlines()
581
+ _joined: list[str] = []
582
+ _i = 0
583
+ _CLASS_KW_RE = re.compile(r'\b(?:class|interface|enum)\s+[A-Z]')
584
+ while _i < len(_raw_lines):
585
+ _line = _raw_lines[_i]
586
+ _stripped = _line.strip()
587
+ if (_CLASS_KW_RE.search(_stripped) and '{' not in _stripped
588
+ and not _stripped.startswith('//')
589
+ and not _stripped.startswith('*')):
590
+ # Continuation: join until we hit a line containing '{'
591
+ _buf = _line
592
+ _i += 1
593
+ while _i < len(_raw_lines):
594
+ _cont = _raw_lines[_i]
595
+ _buf = _buf.rstrip() + ' ' + _cont.strip()
596
+ _i += 1
597
+ if '{' in _cont:
598
+ break
599
+ _joined.append(_buf)
600
+ else:
601
+ _joined.append(_line)
602
+ _i += 1
603
+
547
604
  # P1 fix: normalize multiline annotations (e.g. @RequestMapping(\n value="..."\n))
548
605
  # into single lines so the per-line regex can capture annotation args correctly.
549
- _normalized_lines = _normalize_multiline_annotations(source.splitlines())
606
+ _normalized_lines = _normalize_multiline_annotations(_joined)
550
607
 
551
608
  for line in _normalized_lines:
552
609
  stripped = line.strip()
@@ -1101,24 +1158,34 @@ def _build_relations(
1101
1158
  ))
1102
1159
 
1103
1160
  # Event flow edges — listens_to_event and publishes_event.
1104
- # Spring: method with @EventListener → resolved event parameter type(s).
1161
+ # Spring: method with @EventListener or @TransactionalEventListener → resolved event type(s).
1162
+ _LISTENER_ANNOTATIONS: frozenset[str] = frozenset({
1163
+ "@EventListener", "@TransactionalEventListener",
1164
+ })
1105
1165
  for sym in symbols:
1106
- if sym.type == "method" and "@EventListener" in sym.annotations:
1166
+ if sym.type == "method" and (sym.annotations and
1167
+ any(a in _LISTENER_ANNOTATIONS for a in sym.annotations)):
1168
+ ann = next(a for a in sym.annotations if a in _LISTENER_ANNOTATIONS)
1107
1169
  for imp_fqn in sym.imports_used:
1108
1170
  edges.append(RelationEdge(
1109
1171
  from_symbol=sym.symbol,
1110
1172
  to_symbol=imp_fqn,
1111
1173
  type="listens_to_event",
1112
1174
  confidence="high",
1113
- evidence={"type": "annotation", "value": "@EventListener"},
1175
+ evidence={"type": "annotation", "value": ann},
1114
1176
  ))
1115
1177
 
1116
1178
  _class_syms = [s for s in symbols if s.type in ("class", "interface") and "#" not in s.symbol]
1117
1179
 
1180
+ # Strip comments before event scanning to prevent Javadoc examples from
1181
+ # generating false publisher edges (BUG-003).
1182
+ _source_no_comments = _strip_java_comments(source)
1183
+
1118
1184
  # Spring: class that calls publishEvent(new XxxEvent(...)) → event type FQN.
1119
- for m in _PUBLISH_EVENT_RE.finditer(source):
1185
+ for m in _PUBLISH_EVENT_RE.finditer(_source_no_comments):
1120
1186
  event_simple = m.group(1)
1121
- event_fqn = import_map.get(event_simple, event_simple)
1187
+ # BUG-004: try import_map first, then same-package map, then keep simple name.
1188
+ event_fqn = import_map.get(event_simple) or _same_pkg.get(event_simple) or event_simple
1122
1189
  for cls_sym in _class_syms:
1123
1190
  edges.append(RelationEdge(
1124
1191
  from_symbol=cls_sym.symbol,
@@ -1129,9 +1196,9 @@ def _build_relations(
1129
1196
  ))
1130
1197
 
1131
1198
  # Keycloak SPI: XxxEvent.fire(...) static dispatch → publishes_event.
1132
- for m in _FIRE_EVENT_RE.finditer(source):
1199
+ for m in _FIRE_EVENT_RE.finditer(_source_no_comments):
1133
1200
  event_simple = m.group(1)
1134
- event_fqn = import_map.get(event_simple, event_simple)
1201
+ event_fqn = import_map.get(event_simple) or _same_pkg.get(event_simple) or event_simple
1135
1202
  for cls_sym in _class_syms:
1136
1203
  edges.append(RelationEdge(
1137
1204
  from_symbol=cls_sym.symbol,
@@ -1154,6 +1221,34 @@ def _build_relations(
1154
1221
  evidence={"type": "signature", "value": f"implements {_ELP_IFACE}"},
1155
1222
  ))
1156
1223
 
1224
+ # Class-level consumer detection via class signature (EVT-003 / EVT-004).
1225
+ # Pattern A: class Foo implements ApplicationListener<XxxEvent>
1226
+ # → standard Spring interface, event type = generic param.
1227
+ # Pattern B: class Foo extends AbstractXxxEventListener<XxxEvent>
1228
+ # → abstract base class pattern (Broadleaf and similar frameworks),
1229
+ # event type = generic param of the parent class.
1230
+ for sym in _class_syms:
1231
+ sig = sym.signature or ""
1232
+ for pattern, ev_label in (
1233
+ (_APP_LISTENER_RE, "implements ApplicationListener"),
1234
+ (_ABSTRACT_LISTENER_RE, "extends *EventListener"),
1235
+ ):
1236
+ m = pattern.search(sig)
1237
+ if m:
1238
+ event_simple = m.group(1)
1239
+ event_fqn = (
1240
+ import_map.get(event_simple)
1241
+ or _same_pkg.get(event_simple)
1242
+ or event_simple
1243
+ )
1244
+ edges.append(RelationEdge(
1245
+ from_symbol=sym.symbol,
1246
+ to_symbol=event_fqn,
1247
+ type="listens_to_event",
1248
+ confidence="high",
1249
+ evidence={"type": "signature", "value": f"{ev_label}<{event_simple}>"},
1250
+ ))
1251
+
1157
1252
  seen: set[tuple[str, str, str]] = set()
1158
1253
  unique: list[RelationEdge] = []
1159
1254
  for e in edges:
@@ -2314,8 +2409,12 @@ def _assemble(
2314
2409
  for sym in _class_syms_asm
2315
2410
  )
2316
2411
  )
2412
+ # Only real annotation-based policies count (not "programmatic" fallback).
2413
+ # Programmatic security does not mean every unannotated endpoint is unsecured.
2317
2414
  _has_ann_sec_asm = any(
2318
- r.get("security_annotations") for r in _route_surface
2415
+ isinstance(r.get("security_annotations"), dict)
2416
+ and r["security_annotations"].get("policy") not in (None, "programmatic", "none_detected")
2417
+ for r in _route_surface
2319
2418
  if isinstance(r, dict)
2320
2419
  )
2321
2420
  if _filter_based_asm and _has_ann_sec_asm:
@@ -3165,7 +3264,7 @@ def extract_java_endpoints(root: Path) -> "dict[str, Any]":
3165
3264
  )
3166
3265
  )
3167
3266
  _has_annotation_security = any(
3168
- e.get("security", {}).get("policy") != "none_detected"
3267
+ e.get("security", {}).get("policy") not in (None, "none_detected", "programmatic")
3169
3268
  for e in endpoints
3170
3269
  )
3171
3270
  if _filter_based and _has_annotation_security:
@@ -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
+ )
@@ -289,9 +289,16 @@ def _bfs_callers(
289
289
  _add_caller(caller, depth)
290
290
  # CH-002: injects edge to a field/constructor node → also traverse
291
291
  # the containing class, bypassing the skipped contained_in edge.
292
- if etype == "injects" and "#" in caller:
293
- class_fqn = caller.rsplit("#", 1)[0]
294
- _add_caller(class_fqn, depth)
292
+ # Two formats emitted by the CIR parser:
293
+ # Constructor injection: pkg.Class#<init> (hash separator)
294
+ # Field injection: pkg.Class.field (dot, lowercase last segment)
295
+ if etype == "injects":
296
+ if "#" in caller:
297
+ _add_caller(caller.rsplit("#", 1)[0], depth)
298
+ elif "." in caller:
299
+ last_seg = caller.rsplit(".", 1)[1]
300
+ if last_seg and last_seg[0].islower():
301
+ _add_caller(caller.rsplit(".", 1)[0], depth)
295
302
 
296
303
  return direct, indirect, was_truncated
297
304
 
@@ -316,10 +323,16 @@ def _collect_endpoints(
316
323
  """
317
324
  all_fqns = set(seed_fqns) | set(all_callers)
318
325
 
319
- # Class-level seeds: controller class nodes (no '#') that are controllers.
320
- # These arise when the user queries an entire class, not a specific method.
326
+ # Class-level controller FQNs (no '#') that appear anywhere in the chain.
327
+ # Two cases produce a class-level controller node in the chain:
328
+ # 1. Seed is the controller class itself (user queried the whole controller).
329
+ # 2. Caller is the controller class node — happens when a service/repository
330
+ # is injected into a controller: the BFS reverse edge lands on the class
331
+ # node (e.g. "OwnerController" with no '#'), not a specific method.
332
+ # All endpoints of that controller are affected because any change to the
333
+ # injected dependency impacts every handler that uses it.
321
334
  class_level_controllers: set[str] = {
322
- fqn for fqn in seed_fqns
335
+ fqn for fqn in all_fqns
323
336
  if "#" not in fqn and fqn in model.endpoint_index.controller_fqns
324
337
  }
325
338
 
@@ -327,7 +340,7 @@ def _collect_endpoints(
327
340
  seen_ep_ids: set[str] = set()
328
341
 
329
342
  # Collect candidate controllers: those whose handler_symbol is in the chain
330
- # OR whose class node was a seed (class-level query).
343
+ # OR whose class node appears in the chain (class-level).
331
344
  candidate_controllers: set[str] = set(class_level_controllers)
332
345
  for fqn in all_fqns:
333
346
  cls = _class_of(fqn)
@@ -339,8 +352,8 @@ def _collect_endpoints(
339
352
  handler = getattr(ep, "handler_symbol", "") or ""
340
353
  ep_id = getattr(ep, "id", "") or ""
341
354
 
342
- # Include if: handler is directly in call chain OR controller is a
343
- # class-level seed (whole-class query, all its endpoints in scope).
355
+ # Include if: handler method is in call chain OR the controller's class
356
+ # node appears at class-level in the chain (seed or DI-injected class).
344
357
  if handler not in all_fqns and controller not in class_level_controllers:
345
358
  continue
346
359
 
@@ -561,6 +574,32 @@ class ImpactOrchestrator:
561
574
  f"added {n_syms} symbol(s) from {n_classes} implementation(s)."
562
575
  )
563
576
 
577
+ # CH-001b: expand impl seeds to include their interfaces for BFS (BUG-IC-002).
578
+ # Callers typically inject the interface type, so reverse-graph edges live on
579
+ # the interface node, not on the implementation node. Without this expansion,
580
+ # querying 'OrderServiceImpl' finds 0 callers even though 36 classes inject it.
581
+ if impl_graph is not None:
582
+ current_seed_classes = {_class_of(s) for s in seed_fqns}
583
+ iface_seeds: list[str] = []
584
+ iface_classes_added: set[str] = set()
585
+ for seed_class in sorted(current_seed_classes):
586
+ ifaces = impl_graph.interfaces_of(seed_class)
587
+ for iface_class in ifaces:
588
+ if iface_class in iface_classes_added or iface_class in current_seed_classes:
589
+ continue
590
+ iface_classes_added.add(iface_class)
591
+ for sym in cir.symbols:
592
+ if _class_of(sym) == iface_class and sym not in set(seed_fqns):
593
+ iface_seeds.append(sym)
594
+ if iface_seeds:
595
+ seed_fqns = list(dict.fromkeys(seed_fqns + iface_seeds))
596
+ n_classes = len(iface_classes_added)
597
+ n_syms = len(iface_seeds)
598
+ warnings.append(
599
+ f"Implementation-to-interface expansion (CH-001b): "
600
+ f"added {n_syms} symbol(s) from {n_classes} interface(s) for caller BFS."
601
+ )
602
+
564
603
  # ── 2. BFS through reverse graph ─────────────────────────────────
565
604
  direct_callers, indirect_callers, truncated = _bfs_callers(
566
605
  seed_fqns, cir.reverse_graph, depth
@@ -580,8 +619,13 @@ class ImpactOrchestrator:
580
619
  try:
581
620
  boundary = model.tx_index.effective_boundary(resolved_symbol)
582
621
  if boundary is None and "#" not in resolved_symbol:
583
- # Class-level symbol — try class_level directly
622
+ # Class-level symbol — try class_level directly, then fall back
623
+ # to first method-level boundary if class has only method-level TX.
584
624
  boundary = model.tx_index.class_level.get(resolved_symbol)
625
+ if boundary is None:
626
+ method_boundaries = model.tx_index.by_class.get(resolved_symbol, [])
627
+ if method_boundaries:
628
+ boundary = method_boundaries[0]
585
629
  if boundary is not None:
586
630
  tx_boundary = boundary.to_dict()
587
631
  except Exception:
@@ -63,6 +63,30 @@ _CATCH_SWALLOW_RE = re.compile(
63
63
  _RETHROW_IN_CATCH_RE = re.compile(r'\bthrow\b')
64
64
 
65
65
 
66
+ def _extract_method_body(source: str, method_name: str) -> str:
67
+ """Extract the first method body matching method_name using brace counting.
68
+
69
+ Returns the text from '{' to the matching '}', or empty string if not found.
70
+ Needed to scope TX-005 regex to the specific method instead of the whole file.
71
+ """
72
+ pattern = re.compile(r'\b' + re.escape(method_name) + r'\s*\(')
73
+ for m in pattern.finditer(source):
74
+ brace_pos = source.find('{', m.end())
75
+ if brace_pos < 0:
76
+ continue
77
+ depth = 1
78
+ i = brace_pos + 1
79
+ while i < len(source) and depth > 0:
80
+ c = source[i]
81
+ if c == '{':
82
+ depth += 1
83
+ elif c == '}':
84
+ depth -= 1
85
+ i += 1
86
+ return source[brace_pos:i]
87
+ return ""
88
+
89
+
66
90
  # ---------------------------------------------------------------------------
67
91
  # Pattern protocol
68
92
  # ---------------------------------------------------------------------------
@@ -531,8 +555,16 @@ class _TX005ExceptionSwallowing:
531
555
  return findings
532
556
 
533
557
  def _has_swallowed_exception(self, source: str, symbol: str) -> bool:
534
- """Return True if source contains a catch-log-no-rethrow pattern."""
535
- for match in _CATCH_SWALLOW_RE.finditer(source):
558
+ """Return True if the specific method body has a catch-log-no-rethrow pattern.
559
+
560
+ Scopes the search to the method body only (not the whole file) to avoid
561
+ false positives when other methods in the same file have swallowed exceptions.
562
+ """
563
+ method_name = symbol.split("#")[-1] if "#" in symbol else symbol.rsplit(".", 1)[-1]
564
+ body = _extract_method_body(source, method_name)
565
+ if not body:
566
+ return False
567
+ for match in _CATCH_SWALLOW_RE.finditer(body):
536
568
  block = match.group(0)
537
569
  if not _RETHROW_IN_CATCH_RE.search(block):
538
570
  return True
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 1.35.2
3
+ Version: 1.35.4
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
- ![Version](https://img.shields.io/badge/version-1.35.2-blue)
42
+ ![Version](https://img.shields.io/badge/version-1.35.4-blue)
43
43
  ![Python](https://img.shields.io/badge/python-3.10%2B-green)
44
44
 
45
45
  ---
@@ -113,7 +113,7 @@ pipx install sourcecode
113
113
 
114
114
  ```bash
115
115
  sourcecode version
116
- # sourcecode 1.33.4
116
+ # sourcecode 1.35.4
117
117
  ```
118
118
 
119
119
  ---
@@ -133,6 +133,15 @@ sourcecode --agent
133
133
  # Blast radius: what breaks if this class changes?
134
134
  sourcecode impact OrderService /path/to/repo
135
135
 
136
+ # Spring semantic audit: TX anomalies + security surface (free)
137
+ sourcecode spring-audit /path/to/repo
138
+
139
+ # Impact chain: systemic blast radius with TX/SEC enrichment (free)
140
+ sourcecode impact-chain OrderService /path/to/repo
141
+
142
+ # Event topology: publisher → event → consumer graph (free)
143
+ sourcecode impact-chain OrderPlacedEvent /path/to/repo --type events
144
+
136
145
  # REST endpoint surface
137
146
  sourcecode endpoints /path/to/repo
138
147
 
@@ -187,15 +196,21 @@ sourcecode /repo --agent # ~4,500–5,500 tokens — more detail
187
196
  sourcecode onboard /repo # task-structured: entry points, key files, gaps
188
197
  ```
189
198
 
190
- ### Before every change — blast radius check
199
+ ### Before every change — blast radius + TX/SEC check
191
200
 
192
201
  ```bash
193
202
  # Always target the INTERFACE in Spring projects, not the implementation:
194
203
  sourcecode impact OrderService /repo # ✓ 30 callers, 11 endpoints
195
204
  sourcecode impact OrderServiceImpl /repo # ✗ 0 callers (Spring DI blindness)
196
205
 
197
- # Large hub interfaces depth=1 is faster and still the most actionable signal:
198
- sourcecode impact KeycloakSession /repo --depth 1
206
+ # Impact chain: blast radius enriched with TX boundary and security surfaces
207
+ sourcecode impact-chain OrderService /repo
208
+
209
+ # Event topology: who publishes/consumes this event, and in what TX phase?
210
+ sourcecode impact-chain OrderPlacedEvent /repo --type events
211
+
212
+ # Spring audit: catch TX anomalies before they hit production
213
+ sourcecode spring-audit /repo --scope tx
199
214
  ```
200
215
 
201
216
  ### Continuous agent loop — delta context
@@ -262,6 +277,9 @@ Specifically:
262
277
  - Endpoint recall for JAX-RS subresource locator pattern is ~65%
263
278
  - `impact` on implementation classes (e.g. `OrderServiceImpl`) returns 0 callers in Spring Boot — callers inject the interface via `@Autowired`. Always target the interface. When `direct_callers: []` with `confidence_level: high` for a `@Service` class, re-query the interface.
264
279
  - `no_security_signal` on endpoints means no method-level annotations found — does **not** mean the endpoint is unsecured. Projects using Spring Security filter chains show 100% `no_security_signal` even when fully secured.
280
+ - `spring-audit` and `impact-chain` are **Java/Spring only** — non-Java repos return `spring_detected: false`
281
+ - Event topology via `--type events` does not resolve Kafka/RabbitMQ/Redis message routes — only Spring ApplicationEvent and `@EventListener` chains
282
+ - Self-invocation TX bypass (calling `@Transactional` method from the same class without going through the proxy) is not detected
265
283
 
266
284
  ---
267
285
 
@@ -312,6 +330,81 @@ sourcecode endpoints /path/to/repo --output endpoints.json
312
330
 
313
331
  Extracts all Spring MVC (`@GetMapping`, `@PostMapping`, `@RequestMapping`, etc.) and JAX-RS (`@GET`, `@POST`, `@Path`) endpoint methods. Returns HTTP method, path, controller class, and handler method.
314
332
 
333
+ ### `spring-audit` — Spring semantic audit [free]
334
+
335
+ ```bash
336
+ sourcecode spring-audit /path/to/repo
337
+ sourcecode spring-audit /path/to/repo --scope tx # TX anomalies only
338
+ sourcecode spring-audit /path/to/repo --scope security # security surface only
339
+ sourcecode spring-audit /path/to/repo --min-severity high
340
+ ```
341
+
342
+ Detects structural Spring anomalies that survive code review and tests, but cause production failures:
343
+
344
+ | Pattern | Description |
345
+ |---------|-------------|
346
+ | `TX-001` | `@Transactional` on private/final method — CGLIB proxy bypass, TX silently ignored |
347
+ | `TX-002` | `REQUIRES_NEW` nested inside `REQUIRED` call chain — unexpected transaction nesting |
348
+ | `TX-003` | `readOnly=true` boundary propagating to write operation |
349
+ | `TX-004` | `NOT_SUPPORTED`/`NEVER` called within active TX chain |
350
+ | `TX-005` | Exception swallowing inside `@Transactional` — silent TX rollback suppression |
351
+ | `SEC-001` | Unsecured endpoint in annotation-based security model |
352
+ | `SEC-002` | CVE-2025-41248: `@PreAuthorize` on inherited method from generic supertype |
353
+ | `SEC-003` | `@Transactional` on `@Controller`/`@RestController` — TX in wrong layer |
354
+
355
+ Returns structured findings with `severity`, `confidence`, `symbol`, `source_file`, `evidence`, `explanation`, and `fix_hint`. JAVA/SPRING ONLY.
356
+
357
+ ### `impact-chain` — systemic blast radius with TX/SEC enrichment [free]
358
+
359
+ ```bash
360
+ sourcecode impact-chain OrderService /path/to/repo
361
+ sourcecode impact-chain com.example.OrderService#placeOrder /path/to/repo
362
+ sourcecode impact-chain PaymentService . --depth 6
363
+ ```
364
+
365
+ Unlike `impact` (which traces the caller graph), `impact-chain` builds on the SpringSemanticModel to enrich every step of the blast cone with transaction and security context:
366
+
367
+ | Field | Description |
368
+ |-------|-------------|
369
+ | `direct_callers` | Symbols that directly call the target |
370
+ | `indirect_callers` | Transitive callers (BFS up to `--depth` hops, default: 4) |
371
+ | `endpoints_affected` | HTTP endpoints reachable through the call chain |
372
+ | `transaction_boundary` | `@Transactional` semantics on the target: propagation, isolation, readOnly |
373
+ | `security_surfaces` | Per-endpoint security policy + SEC finding IDs |
374
+ | `impact_findings` | TX-001..005 and SEC-001..003 findings that touch the call chain |
375
+ | `risk_level` | `critical` \| `high` \| `medium` \| `low` |
376
+
377
+ **Event topology** — query the publisher/consumer graph for a Spring event class:
378
+
379
+ ```bash
380
+ sourcecode impact-chain OrderPlacedEvent /path/to/repo --type events
381
+ ```
382
+
383
+ | Field | Description |
384
+ |-------|-------------|
385
+ | `publishers` | FQNs that publish this event class |
386
+ | `consumers` | Listeners with TX phase metadata (`AFTER_COMMIT`, `BEFORE_COMMIT`, etc.) |
387
+ | `event_graph` | Publisher → event → consumer edges (BFS ≤ 2 hops) |
388
+ | `transaction_context` | `AFTER_COMMIT` consumers, `BEFORE_COMMIT` risks |
389
+ | `risk_level` | Derived from TX phase and consumer count |
390
+
391
+ **Limitations of event topology:**
392
+ - Resolves Spring `ApplicationEvent` / `@EventListener` chains only
393
+ - Does not trace Kafka, RabbitMQ, Redis, or other message brokers
394
+ - Does not detect self-invocation proxy bypass
395
+ - Conditional beans (`@ConditionalOnProperty`) are not evaluated at analysis time
396
+
397
+ ### `cold-start` — RIS bootstrap context
398
+
399
+ ```bash
400
+ sourcecode cold-start /path/to/repo
401
+ sourcecode cold-start /path/to/repo --compact # ~10K token subset
402
+ ```
403
+
404
+ Returns the Repository Intelligence Snapshot (RIS) instantly — zero re-analysis. The RIS is built by a prior warm cache pass and includes stacks, entry points, endpoint surface, and Spring semantic signals. Status field: `cold_start_ready` | `cold_start_stale` | `no_ris`.
405
+
406
+ Use `--compact` to get a ~10K token subset safe for direct LLM injection. Full snapshot can exceed 100K tokens on medium repos — use `--output FILE` for local search tooling.
407
+
315
408
  ### `repo-ir` — symbol-level IR
316
409
 
317
410
  ```bash
@@ -1,13 +1,13 @@
1
- sourcecode/__init__.py,sha256=e_12xn8iAOn0Ld38ig-U9tbnikpJi5jHz6llETh6nDk,103
1
+ sourcecode/__init__.py,sha256=HvT-OD6BpIxm8uMM8dF8IJUh4mkn6RKbB0__ghb_vHI,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
5
5
  sourcecode/ast_extractor.py,sha256=_btmeOJIe3t-NicF94D5ZAesa2YIJ0_QNExGnbHxGFE,50578
6
6
  sourcecode/cache.py,sha256=wAyPrXN5DqiGivnMpeEuun2xHDKfBer2_oBsh6kj_vc,30447
7
7
  sourcecode/canonical_ir.py,sha256=uwpwCnJxMh_eiIVg4cOLv7-aZthvmDFcG4azCOycLkw,24281
8
- sourcecode/cir_graphs.py,sha256=6CBwPoFCbHrqW8Bq_9fZUMi2IgcQBmoaiXiJjlOY9QE,7740
8
+ sourcecode/cir_graphs.py,sha256=wuRjW0MjSr9KLq03L1TzYqkIeY_phUmIbVA3FQDfHKk,8603
9
9
  sourcecode/classifier.py,sha256=2lYoSH3vOTkXZYPU7Go2WIet1-IuNzTWVhc-ULnXtgw,8024
10
- sourcecode/cli.py,sha256=D6QbdECWLdGENhugefKVBEmVzBg3YP2DOMoIX60zlj8,214843
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,19 +36,20 @@ 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=54L2tN0e_fEhAspMAaz8ft43Y5RiRNEZ5CtpO7HQwqA,162600
39
+ sourcecode/repository_ir.py,sha256=uLIewoLBg4nknI1JlI8bPo_kl9SVyT-GFQqENTeFz1M,167673
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
- sourcecode/spring_impact.py,sha256=ACmkbQMWgoJqZ9QLdHJLB1qNuFLgjEVv2r624X9Y6Y4,29004
48
+ sourcecode/spring_impact.py,sha256=-ET4oB9tZQYfcyhfI781mMJCU-przz6x4Ejwr3hejA8,31743
48
49
  sourcecode/spring_model.py,sha256=IzMcM5ftw1_EHG3FGUDT7qdAMpo3eqbAE1LRuasfr_4,14739
49
50
  sourcecode/spring_security_audit.py,sha256=RPr491FGAmWNxqe-uN0FS2gmjQ0M5ryRyXjLNMMzKFk,20077
50
51
  sourcecode/spring_semantic.py,sha256=CiAf77p48-RFrUF0zbgww4w2Xigrbo1t5M3ZCDIfV_g,12032
51
- sourcecode/spring_tx_analyzer.py,sha256=0r5hQEd3fFERrcx2SZKuYixMdDGKIssRYxrlunmTMwQ,25952
52
+ sourcecode/spring_tx_analyzer.py,sha256=eBcYLRKhlUllHl195CVGNWeg2vMext4u1Tezu_Mwrdg,27143
52
53
  sourcecode/summarizer.py,sha256=YspHEVeYJVmltq0FMtGZF8kIP3qiR2KLcanGL6Y7uTI,20747
53
54
  sourcecode/tree_utils.py,sha256=8GAkIfQAsvtEudIeW1l4ooH_oRtrWR8cpJQJsEa_Pfw,2093
54
55
  sourcecode/workspace.py,sha256=X_6NmNnitvT3_38V-JDChydo_sR68s249hLFlrQskU0,8271
@@ -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.2.dist-info/METADATA,sha256=XdJipgxGDt6qiU1emmUPj1VS2ti9uOKsgB-f2VFq3jk,16440
93
- sourcecode-1.35.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
94
- sourcecode-1.35.2.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
95
- sourcecode-1.35.2.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
96
- sourcecode-1.35.2.dist-info/RECORD,,
93
+ sourcecode-1.35.4.dist-info/METADATA,sha256=Pl9s6-m8pSLDCwawxAneFDVHpQvJLxehMZvruYMWnr0,21263
94
+ sourcecode-1.35.4.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
95
+ sourcecode-1.35.4.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
96
+ sourcecode-1.35.4.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
97
+ sourcecode-1.35.4.dist-info/RECORD,,