sourcecode 1.35.0__py3-none-any.whl → 1.35.1__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.0"
3
+ __version__ = "1.35.1"
sourcecode/cli.py CHANGED
@@ -3766,7 +3766,7 @@ def spring_audit_cmd(
3766
3766
  from sourcecode.spring_findings import SpringAuditResult, SpringFinding
3767
3767
  from sourcecode.spring_tx_analyzer import run_tx_audit
3768
3768
  from sourcecode.spring_security_audit import run_security_audit
3769
- from sourcecode.spring_semantic import build_tx_index
3769
+ from sourcecode.spring_model import SpringSemanticModel
3770
3770
 
3771
3771
  target = path.resolve()
3772
3772
  if not target.exists() or not target.is_dir():
@@ -3816,13 +3816,13 @@ def spring_audit_cmd(
3816
3816
  return
3817
3817
 
3818
3818
  cir = build_canonical_ir(file_list, target)
3819
- tx_idx = build_tx_index(cir)
3819
+ _model = SpringSemanticModel.build(cir)
3820
3820
 
3821
3821
  results: list[SpringAuditResult] = []
3822
3822
  if scope in ("all", "tx"):
3823
- results.append(run_tx_audit(cir, root=target, min_severity=min_severity))
3823
+ results.append(run_tx_audit(cir, root=target, min_severity=min_severity, model=_model))
3824
3824
  if scope in ("all", "security"):
3825
- results.append(run_security_audit(cir, root=target, min_severity=min_severity, tx_index=tx_idx))
3825
+ results.append(run_security_audit(cir, root=target, min_severity=min_severity, model=_model))
3826
3826
 
3827
3827
  if len(results) == 1:
3828
3828
  combined = results[0]
@@ -3843,6 +3843,18 @@ def spring_audit_cmd(
3843
3843
  metadata=merged_meta,
3844
3844
  ).finalize()
3845
3845
 
3846
+ # Populate git_head from repo HEAD — non-fatal.
3847
+ try:
3848
+ import subprocess as _sub_sa
3849
+ _sha_r = _sub_sa.run(
3850
+ ["git", "-C", str(target), "rev-parse", "--short", "HEAD"],
3851
+ capture_output=True, text=True, timeout=3,
3852
+ )
3853
+ if _sha_r.returncode == 0:
3854
+ combined.git_head = _sha_r.stdout.strip()
3855
+ except Exception:
3856
+ pass
3857
+
3846
3858
  data = combined.to_dict()
3847
3859
 
3848
3860
  # Non-fatal RIS side-effect — persist summary only (not full findings).
@@ -0,0 +1,258 @@
1
+ """spring_model.py — Shared Spring semantic model.
2
+
3
+ Builds once per analysis run from a CanonicalRepositoryIR.
4
+ All pattern analyzers consume this rather than re-deriving shared structures.
5
+
6
+ Components:
7
+ CallAdjacency — forward call adjacency (caller → callees)
8
+ InheritanceGraph — extends/implements graph with generic-parent detection
9
+ BeanGraph — Spring bean registry + injection graph
10
+ SpringSemanticModel — umbrella: tx_index + call_adj + inheritance + bean_graph
11
+
12
+ Eliminates per-pattern duplicate traversals:
13
+ build_tx_index() was called 2× per scope=all run → now 1×
14
+ _build_forward_adjacency() was called 3× (TX-002/003/004) → now 1×
15
+ _build_extends_map() was called per-run (SEC-002) → now 1×
16
+
17
+ Usage:
18
+ model = SpringSemanticModel.build(cir)
19
+ # or with pre-built tx_index (avoids double-build in CLI):
20
+ model = SpringSemanticModel.build(cir, tx_index=existing_index)
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import re
25
+ import time
26
+ from dataclasses import dataclass, field
27
+ from typing import TYPE_CHECKING, Optional
28
+
29
+ from sourcecode.spring_semantic import TransactionBoundaryIndex, build_tx_index
30
+
31
+ if TYPE_CHECKING:
32
+ from sourcecode.canonical_ir import CanonicalRepositoryIR
33
+
34
+ # Edge types excluded from forward adjacency (structural, not call edges)
35
+ _CALL_SKIP: frozenset[str] = frozenset({"annotated_with", "mapped_to", "contained_in"})
36
+
37
+ # Spring bean stereotype annotations
38
+ _BEAN_ANNOTATIONS: frozenset[str] = frozenset({
39
+ "@Component", "@Service", "@Repository",
40
+ "@Controller", "@RestController", "@Configuration", "@Bean",
41
+ })
42
+
43
+ _GENERIC_PARAM_RE = re.compile(r"<[A-Z][\w,\s<>?]*>")
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # CallAdjacency
48
+ # ---------------------------------------------------------------------------
49
+
50
+ @dataclass
51
+ class CallAdjacency:
52
+ """Forward call adjacency built from CIR call_graph edges.
53
+
54
+ Shared across TX patterns (TX-002, TX-003, TX-004) to avoid one rebuild
55
+ per pattern per analysis run.
56
+ """
57
+ adjacency: dict[str, list[str]] = field(default_factory=dict) # caller → [callees]
58
+
59
+ @classmethod
60
+ def build(cls, cir: "CanonicalRepositoryIR") -> "CallAdjacency":
61
+ adj: dict[str, list[str]] = {}
62
+ for edge in cir.call_graph:
63
+ if not isinstance(edge, dict):
64
+ continue
65
+ if edge.get("type") in _CALL_SKIP:
66
+ continue
67
+ frm = edge.get("from") or ""
68
+ to = edge.get("to") or ""
69
+ if frm and to:
70
+ adj.setdefault(frm, []).append(to)
71
+ return cls(adjacency=adj)
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # InheritanceGraph
76
+ # ---------------------------------------------------------------------------
77
+
78
+ @dataclass
79
+ class InheritanceGraph:
80
+ """Extends/implements graph derived from CIR dependency edges.
81
+
82
+ Provides inheritance relationships for SEC-002 (@PreAuthorize on generic
83
+ supertype) and future self-invocation detection.
84
+
85
+ generic_parents: FQNs whose immediate parent signature has type parameters.
86
+ Computed once; avoids per-pattern regex matching at analysis time.
87
+ """
88
+ parent_of: dict[str, str] = field(default_factory=dict) # child FQN → parent signature
89
+ generic_parents: set[str] = field(default_factory=set) # FQNs with a generic parent
90
+
91
+ @classmethod
92
+ def build(cls, cir: "CanonicalRepositoryIR") -> "InheritanceGraph":
93
+ parent_of: dict[str, str] = {}
94
+ generic_parents: set[str] = set()
95
+ for edge in cir.dependencies:
96
+ if not isinstance(edge, dict):
97
+ continue
98
+ if edge.get("type") != "extends":
99
+ continue
100
+ child = edge.get("from") or ""
101
+ parent = edge.get("to") or ""
102
+ if child and parent:
103
+ parent_of[child] = parent
104
+ if _GENERIC_PARAM_RE.search(parent):
105
+ generic_parents.add(child)
106
+ return cls(parent_of=parent_of, generic_parents=generic_parents)
107
+
108
+ def immediate_parent(self, fqn: str) -> str:
109
+ """Return the immediate parent signature for fqn, or empty string."""
110
+ return self.parent_of.get(fqn, "")
111
+
112
+ def has_generic_parent(self, fqn: str) -> bool:
113
+ """True when the immediate parent of fqn has type parameters."""
114
+ return fqn in self.generic_parents
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # BeanGraph
119
+ # ---------------------------------------------------------------------------
120
+
121
+ @dataclass
122
+ class BeanNode:
123
+ """Minimal representation of a detected Spring bean."""
124
+ fqn: str
125
+ stereotype: str # component|service|repository|controller|configuration|bean
126
+ source_file: str
127
+
128
+
129
+ @dataclass
130
+ class BeanGraph:
131
+ """Spring bean registry derived from _raw_ir graph nodes.
132
+
133
+ Foundation for future capabilities:
134
+ - self-invocation detection (proxy cannot intercept this.method())
135
+ - conditional bean analysis (@ConditionalOn* chains)
136
+ - module impact analysis (inter-bean dependency tracing)
137
+
138
+ injections: @Autowired / constructor injection edges (type="injects").
139
+ """
140
+ beans: dict[str, BeanNode] = field(default_factory=dict) # fqn → node
141
+ injections: dict[str, list[str]] = field(default_factory=dict) # fqn → [injected FQNs]
142
+
143
+ @classmethod
144
+ def build(cls, cir: "CanonicalRepositoryIR") -> "BeanGraph":
145
+ beans: dict[str, BeanNode] = {}
146
+ injections: dict[str, list[str]] = {}
147
+
148
+ raw_ir = getattr(cir, "_raw_ir", {}) or {}
149
+ nodes = (raw_ir.get("graph") or {}).get("nodes") or []
150
+
151
+ for node in nodes:
152
+ if not isinstance(node, dict):
153
+ continue
154
+ ann_set = set(node.get("annotations") or [])
155
+ match = ann_set & _BEAN_ANNOTATIONS
156
+ if not match:
157
+ continue
158
+ fqn = node.get("fqn") or ""
159
+ if not fqn:
160
+ continue
161
+ ann = next(iter(match))
162
+ stereotype = ann.lstrip("@").lower()
163
+ beans[fqn] = BeanNode(
164
+ fqn=fqn,
165
+ stereotype=stereotype,
166
+ source_file=node.get("source_file") or "",
167
+ )
168
+
169
+ for edge in cir.call_graph:
170
+ if not isinstance(edge, dict):
171
+ continue
172
+ if edge.get("type") != "injects":
173
+ continue
174
+ frm = edge.get("from") or ""
175
+ to = edge.get("to") or ""
176
+ if frm and to and frm in beans:
177
+ injections.setdefault(frm, []).append(to)
178
+
179
+ return cls(beans=beans, injections=injections)
180
+
181
+ def is_bean(self, fqn: str) -> bool:
182
+ return fqn in self.beans
183
+
184
+ def get_stereotype(self, fqn: str) -> str:
185
+ node = self.beans.get(fqn)
186
+ return node.stereotype if node else ""
187
+
188
+
189
+ # ---------------------------------------------------------------------------
190
+ # SpringSemanticModel
191
+ # ---------------------------------------------------------------------------
192
+
193
+ @dataclass
194
+ class SpringSemanticModel:
195
+ """Shared semantic context. Built once per analysis run.
196
+
197
+ Eliminates duplicate CIR traversals when multiple patterns run together.
198
+ Pattern analyzers that receive a model should consume it rather than
199
+ re-deriving from the CIR.
200
+
201
+ Fields:
202
+ tx_index: @Transactional boundary index.
203
+ call_adj: Forward call adjacency (caller → callees).
204
+ inheritance: Extends/implements graph + generic-parent detection.
205
+ bean_graph: Spring bean registry and injection graph.
206
+ build_time_ms: Wall-clock ms to build all sub-models (not counting
207
+ a pre-built tx_index passed by the caller).
208
+ """
209
+ tx_index: TransactionBoundaryIndex
210
+ call_adj: CallAdjacency
211
+ inheritance: InheritanceGraph
212
+ bean_graph: BeanGraph
213
+ build_time_ms: float = 0.0
214
+
215
+ @classmethod
216
+ def build(
217
+ cls,
218
+ cir: "CanonicalRepositoryIR",
219
+ *,
220
+ tx_index: Optional[TransactionBoundaryIndex] = None,
221
+ ) -> "SpringSemanticModel":
222
+ """Build all sub-models from a CIR. Never raises.
223
+
224
+ Args:
225
+ cir: CanonicalRepositoryIR from build_canonical_ir().
226
+ tx_index: Pre-built index to reuse — avoids double build in CLI
227
+ when tx_index was already computed for another purpose.
228
+ """
229
+ t0 = time.monotonic()
230
+
231
+ try:
232
+ tx = tx_index if tx_index is not None else build_tx_index(cir)
233
+ except Exception:
234
+ tx = TransactionBoundaryIndex()
235
+
236
+ try:
237
+ adj = CallAdjacency.build(cir)
238
+ except Exception:
239
+ adj = CallAdjacency()
240
+
241
+ try:
242
+ inh = InheritanceGraph.build(cir)
243
+ except Exception:
244
+ inh = InheritanceGraph()
245
+
246
+ try:
247
+ bg = BeanGraph.build(cir)
248
+ except Exception:
249
+ bg = BeanGraph()
250
+
251
+ elapsed = round((time.monotonic() - t0) * 1000, 2)
252
+ return cls(
253
+ tx_index=tx,
254
+ call_adj=adj,
255
+ inheritance=inh,
256
+ bean_graph=bg,
257
+ build_time_ms=elapsed,
258
+ )
@@ -9,12 +9,14 @@ All patterns are deterministic and never raise.
9
9
  """
10
10
  from __future__ import annotations
11
11
 
12
+ import inspect
12
13
  import re
13
14
  import time
14
15
  from pathlib import Path
15
- from typing import TYPE_CHECKING, Optional, Protocol, runtime_checkable
16
+ from typing import TYPE_CHECKING, Any, Optional, Protocol, runtime_checkable
16
17
 
17
18
  from sourcecode.spring_findings import SpringAuditResult, SpringFinding
19
+ from sourcecode.spring_model import SpringSemanticModel
18
20
  from sourcecode.spring_semantic import TransactionBoundaryIndex, build_tx_index
19
21
 
20
22
  if TYPE_CHECKING:
@@ -38,10 +40,29 @@ class SecurityPattern(Protocol):
38
40
  cir: "CanonicalRepositoryIR",
39
41
  tx_index: Optional[TransactionBoundaryIndex],
40
42
  root: Optional[Path],
43
+ *,
44
+ model: Optional[SpringSemanticModel] = None,
41
45
  ) -> list[SpringFinding]:
42
46
  ...
43
47
 
44
48
 
49
+ def _call_pattern_analyze(
50
+ pattern: Any,
51
+ cir: "CanonicalRepositoryIR",
52
+ tx_index: Optional[TransactionBoundaryIndex],
53
+ root: Optional[Path],
54
+ model: Optional[SpringSemanticModel],
55
+ ) -> list[SpringFinding]:
56
+ """Dispatch to pattern.analyze(), injecting model if the pattern accepts it."""
57
+ try:
58
+ sig = inspect.signature(pattern.analyze)
59
+ if "model" in sig.parameters:
60
+ return pattern.analyze(cir, tx_index, root, model=model)
61
+ except (ValueError, TypeError):
62
+ pass
63
+ return pattern.analyze(cir, tx_index, root)
64
+
65
+
45
66
  # ---------------------------------------------------------------------------
46
67
  # SEC-001: Endpoint without security guard (annotation_based model)
47
68
  # ---------------------------------------------------------------------------
@@ -55,6 +76,8 @@ class _SEC001UnsecuredEndpoint:
55
76
  cir: "CanonicalRepositoryIR",
56
77
  tx_index: Optional[TransactionBoundaryIndex],
57
78
  root: Optional[Path],
79
+ *,
80
+ model: Optional[SpringSemanticModel] = None,
58
81
  ) -> list[SpringFinding]:
59
82
  security_model = cir.metadata.get("security_model", "unknown")
60
83
  # filter_based model has centralized config — per-endpoint annotation absence is expected
@@ -119,9 +142,16 @@ class _SEC002PreAuthorizeGenericInheritance:
119
142
  cir: "CanonicalRepositoryIR",
120
143
  tx_index: Optional[TransactionBoundaryIndex],
121
144
  root: Optional[Path],
145
+ *,
146
+ model: Optional[SpringSemanticModel] = None,
122
147
  ) -> list[SpringFinding]:
123
- # Build extends map: child_fqn parent_signature (may contain generics)
124
- extends_map = _build_extends_map(cir)
148
+ # Use shared inheritance graph when available; fall back to local build.
149
+ if model is not None:
150
+ _parent_of = model.inheritance.parent_of
151
+ _generic_parents = model.inheritance.generic_parents
152
+ else:
153
+ _parent_of = _build_extends_map(cir)
154
+ _generic_parents = None # determined via regex below
125
155
 
126
156
  findings: list[SpringFinding] = []
127
157
  seen: set[str] = set()
@@ -135,8 +165,11 @@ class _SEC002PreAuthorizeGenericInheritance:
135
165
  continue
136
166
 
137
167
  # Resolve parent class signature for this controller
138
- parent_sig = extends_map.get(ep.controller_class, "")
139
- has_generics = bool(_GENERIC_PARAM_RE.search(parent_sig))
168
+ parent_sig = _parent_of.get(ep.controller_class, "")
169
+ if _generic_parents is not None:
170
+ has_generics = ep.controller_class in _generic_parents
171
+ else:
172
+ has_generics = bool(_GENERIC_PARAM_RE.search(parent_sig))
140
173
  confidence = "high" if has_generics else "medium"
141
174
 
142
175
  key = f"{ep.controller_class}#{ep.handler_symbol}"
@@ -205,6 +238,8 @@ class _SEC003TransactionalOnController:
205
238
  cir: "CanonicalRepositoryIR",
206
239
  tx_index: Optional[TransactionBoundaryIndex],
207
240
  root: Optional[Path],
241
+ *,
242
+ model: Optional[SpringSemanticModel] = None,
208
243
  ) -> list[SpringFinding]:
209
244
  if tx_index is None:
210
245
  return []
@@ -337,11 +372,12 @@ def _build_extends_map(cir: "CanonicalRepositoryIR") -> dict[str, str]:
337
372
  def _controller_source_file(cir: "CanonicalRepositoryIR", controller_fqn: str) -> str:
338
373
  """Return the source_file for a controller class from cir.files, or empty string."""
339
374
  simple = controller_fqn.split(".")[-1]
340
- for f in cir.files:
341
- if not isinstance(f, dict):
342
- continue
343
- path = f.get("path", "")
344
- if simple and (path.endswith(f"{simple}.java") or path.endswith(f"{simple}.kt")):
375
+ if not simple:
376
+ return ""
377
+ for path in cir.files:
378
+ if isinstance(path, str) and (
379
+ path.endswith(f"{simple}.java") or path.endswith(f"{simple}.kt")
380
+ ):
345
381
  return path
346
382
  return ""
347
383
 
@@ -379,6 +415,8 @@ class SecurityScanner:
379
415
  Usage:
380
416
  scanner = SecurityScanner()
381
417
  findings = scanner.analyze(cir, tx_index=tx_index, root=Path("/repo"))
418
+ # or with pre-built model (shares inheritance graph, avoids rebuilds):
419
+ findings = scanner.analyze(cir, tx_index=tx_index, root=Path("/repo"), model=model)
382
420
 
383
421
  Never raises. Pattern errors are silently swallowed (finding missed, not crash).
384
422
  """
@@ -393,11 +431,13 @@ class SecurityScanner:
393
431
  cir: "CanonicalRepositoryIR",
394
432
  tx_index: Optional[TransactionBoundaryIndex] = None,
395
433
  root: Optional[Path] = None,
434
+ *,
435
+ model: Optional[SpringSemanticModel] = None,
396
436
  ) -> list[SpringFinding]:
397
437
  all_findings: list[SpringFinding] = []
398
438
  for pattern in self.patterns:
399
439
  try:
400
- found = pattern.analyze(cir, tx_index, root)
440
+ found = _call_pattern_analyze(pattern, cir, tx_index, root, model)
401
441
  all_findings.extend(found)
402
442
  except Exception:
403
443
  pass
@@ -417,6 +457,7 @@ def run_security_audit(
417
457
  min_severity: str = "low",
418
458
  patterns: Optional[list[SecurityPattern]] = None,
419
459
  tx_index: Optional[TransactionBoundaryIndex] = None,
460
+ model: Optional[SpringSemanticModel] = None,
420
461
  ) -> SpringAuditResult:
421
462
  """Run security surface audit and return a SpringAuditResult.
422
463
 
@@ -427,17 +468,20 @@ def run_security_audit(
427
468
  min_severity: Filter findings below this severity.
428
469
  patterns: Override default pattern list (for testing).
429
470
  tx_index: Pre-built TransactionBoundaryIndex (built from cir if None).
471
+ model: Pre-built SpringSemanticModel (avoids duplicate build in CLI).
430
472
  """
431
473
  _sev_rank = {"critical": 0, "high": 1, "medium": 2, "low": 3}
432
474
  min_rank = _sev_rank.get(min_severity, 3)
433
475
 
434
476
  t0 = time.monotonic()
435
477
 
436
- if tx_index is None:
478
+ if model is not None:
479
+ tx_index = model.tx_index
480
+ elif tx_index is None:
437
481
  tx_index = build_tx_index(cir)
438
482
 
439
483
  scanner = SecurityScanner(patterns=patterns)
440
- findings = scanner.analyze(cir, tx_index=tx_index, root=root)
484
+ findings = scanner.analyze(cir, tx_index=tx_index, root=root, model=model)
441
485
 
442
486
  findings = [f for f in findings if _sev_rank.get(f.severity, 9) <= min_rank]
443
487
 
@@ -14,13 +14,15 @@ All patterns are deterministic and never raise.
14
14
  """
15
15
  from __future__ import annotations
16
16
 
17
+ import inspect
17
18
  import re
18
19
  import time
19
20
  from collections import deque
20
21
  from pathlib import Path
21
- from typing import TYPE_CHECKING, Optional, Protocol, runtime_checkable
22
+ from typing import TYPE_CHECKING, Any, Optional, Protocol, runtime_checkable
22
23
 
23
24
  from sourcecode.spring_findings import SpringAuditResult, SpringFinding
25
+ from sourcecode.spring_model import SpringSemanticModel
24
26
  from sourcecode.spring_semantic import (
25
27
  PROPAGATION_DEFAULT,
26
28
  TransactionBoundary,
@@ -70,10 +72,33 @@ class TxPattern(Protocol):
70
72
  cir: "CanonicalRepositoryIR",
71
73
  tx_index: TransactionBoundaryIndex,
72
74
  root: Optional[Path],
75
+ *,
76
+ model: Optional[SpringSemanticModel] = None,
73
77
  ) -> list[SpringFinding]:
74
78
  ...
75
79
 
76
80
 
81
+ def _call_pattern_analyze(
82
+ pattern: Any,
83
+ cir: "CanonicalRepositoryIR",
84
+ tx_index: TransactionBoundaryIndex,
85
+ root: Optional[Path],
86
+ model: Optional[SpringSemanticModel],
87
+ ) -> list[SpringFinding]:
88
+ """Dispatch to pattern.analyze(), injecting model if the pattern accepts it.
89
+
90
+ Patterns that declare `model` in their signature receive the shared model.
91
+ Patterns without it (e.g. test doubles) are called with the legacy signature.
92
+ """
93
+ try:
94
+ sig = inspect.signature(pattern.analyze)
95
+ if "model" in sig.parameters:
96
+ return pattern.analyze(cir, tx_index, root, model=model)
97
+ except (ValueError, TypeError):
98
+ pass
99
+ return pattern.analyze(cir, tx_index, root)
100
+
101
+
77
102
  # ---------------------------------------------------------------------------
78
103
  # TX-001: @Transactional on private or final method
79
104
  # ---------------------------------------------------------------------------
@@ -87,6 +112,8 @@ class _TX001ProxyBypass:
87
112
  cir: "CanonicalRepositoryIR",
88
113
  tx_index: TransactionBoundaryIndex,
89
114
  root: Optional[Path],
115
+ *,
116
+ model: Optional[SpringSemanticModel] = None,
90
117
  ) -> list[SpringFinding]:
91
118
  findings: list[SpringFinding] = []
92
119
 
@@ -210,11 +237,13 @@ class _TX002RequiresNewNested:
210
237
  cir: "CanonicalRepositoryIR",
211
238
  tx_index: TransactionBoundaryIndex,
212
239
  root: Optional[Path],
240
+ *,
241
+ model: Optional[SpringSemanticModel] = None,
213
242
  ) -> list[SpringFinding]:
214
243
  findings: list[SpringFinding] = []
215
244
  seen_pairs: set[tuple[str, str]] = set()
216
245
 
217
- adj = _build_forward_adjacency(cir)
246
+ adj = model.call_adj.adjacency if model is not None else _build_forward_adjacency(cir)
218
247
  deadline = time.monotonic_ns() + _BFS_TIMEOUT_MS * 1_000_000
219
248
 
220
249
  for boundary in tx_index.all_boundaries():
@@ -284,11 +313,13 @@ class _TX003ReadOnlyWritePropagation:
284
313
  cir: "CanonicalRepositoryIR",
285
314
  tx_index: TransactionBoundaryIndex,
286
315
  root: Optional[Path],
316
+ *,
317
+ model: Optional[SpringSemanticModel] = None,
287
318
  ) -> list[SpringFinding]:
288
319
  findings: list[SpringFinding] = []
289
320
  seen_pairs: set[tuple[str, str]] = set()
290
321
 
291
- adj = _build_forward_adjacency(cir)
322
+ adj = model.call_adj.adjacency if model is not None else _build_forward_adjacency(cir)
292
323
  deadline = time.monotonic_ns() + _BFS_TIMEOUT_MS * 1_000_000
293
324
 
294
325
  for boundary in tx_index.all_boundaries():
@@ -364,11 +395,13 @@ class _TX004TxSuspensionRisk:
364
395
  cir: "CanonicalRepositoryIR",
365
396
  tx_index: TransactionBoundaryIndex,
366
397
  root: Optional[Path],
398
+ *,
399
+ model: Optional[SpringSemanticModel] = None,
367
400
  ) -> list[SpringFinding]:
368
401
  findings: list[SpringFinding] = []
369
402
  seen_pairs: set[tuple[str, str]] = set()
370
403
 
371
- adj = _build_forward_adjacency(cir)
404
+ adj = model.call_adj.adjacency if model is not None else _build_forward_adjacency(cir)
372
405
  deadline = time.monotonic_ns() + _BFS_TIMEOUT_MS * 1_000_000
373
406
 
374
407
  for boundary in tx_index.all_boundaries():
@@ -448,6 +481,8 @@ class _TX005ExceptionSwallowing:
448
481
  cir: "CanonicalRepositoryIR",
449
482
  tx_index: TransactionBoundaryIndex,
450
483
  root: Optional[Path],
484
+ *,
485
+ model: Optional[SpringSemanticModel] = None,
451
486
  ) -> list[SpringFinding]:
452
487
  if root is None:
453
488
  return []
@@ -551,6 +586,8 @@ class TxPatternEngine:
551
586
  Usage:
552
587
  engine = TxPatternEngine()
553
588
  findings = engine.analyze(cir, tx_index, root=Path("/repo"))
589
+ # or with pre-built model (eliminates duplicate adjacency builds):
590
+ findings = engine.analyze(cir, tx_index, root=Path("/repo"), model=model)
554
591
 
555
592
  Never raises. Pattern errors are silently swallowed (finding missed, not crash).
556
593
  """
@@ -563,11 +600,13 @@ class TxPatternEngine:
563
600
  cir: "CanonicalRepositoryIR",
564
601
  tx_index: TransactionBoundaryIndex,
565
602
  root: Optional[Path] = None,
603
+ *,
604
+ model: Optional[SpringSemanticModel] = None,
566
605
  ) -> list[SpringFinding]:
567
606
  all_findings: list[SpringFinding] = []
568
607
  for pattern in self.patterns:
569
608
  try:
570
- found = pattern.analyze(cir, tx_index, root)
609
+ found = _call_pattern_analyze(pattern, cir, tx_index, root, model)
571
610
  all_findings.extend(found)
572
611
  except Exception:
573
612
  pass
@@ -586,6 +625,7 @@ def run_tx_audit(
586
625
  scope: str = "all",
587
626
  min_severity: str = "low",
588
627
  patterns: Optional[list[TxPattern]] = None,
628
+ model: Optional[SpringSemanticModel] = None,
589
629
  ) -> SpringAuditResult:
590
630
  """Run TX anomaly detection and return a SpringAuditResult.
591
631
 
@@ -595,15 +635,18 @@ def run_tx_audit(
595
635
  scope: "all" | "tx" (reserved — always "tx" for this function).
596
636
  min_severity: Filter findings below this severity.
597
637
  patterns: Override default pattern list (for testing).
638
+ model: Pre-built SpringSemanticModel (avoids duplicate build in CLI).
598
639
  """
599
640
  _sev_rank = {"critical": 0, "high": 1, "medium": 2, "low": 3}
600
641
  min_rank = _sev_rank.get(min_severity, 3)
601
642
 
602
643
  t0 = time.monotonic()
603
644
 
604
- tx_index = build_tx_index(cir)
645
+ if model is None:
646
+ model = SpringSemanticModel.build(cir)
647
+ tx_index = model.tx_index
605
648
  engine = TxPatternEngine(patterns=patterns)
606
- findings = engine.analyze(cir, tx_index, root=root)
649
+ findings = engine.analyze(cir, tx_index, root=root, model=model)
607
650
 
608
651
  # Filter by min_severity
609
652
  findings = [f for f in findings if _sev_rank.get(f.severity, 9) <= min_rank]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 1.35.0
3
+ Version: 1.35.1
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.0-blue)
42
+ ![Version](https://img.shields.io/badge/version-1.35.1-blue)
43
43
  ![Python](https://img.shields.io/badge/python-3.10%2B-green)
44
44
 
45
45
  ---
@@ -1,4 +1,4 @@
1
- sourcecode/__init__.py,sha256=6bPiO8TtMToR65q6XDyXDECgmlM2Pas7zdK10xzozL8,103
1
+ sourcecode/__init__.py,sha256=fKh2jyTIk2SQsqHWlV7SGNYEFuYHJHR6k1lWsJj_W44,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
@@ -6,7 +6,7 @@ sourcecode/ast_extractor.py,sha256=_btmeOJIe3t-NicF94D5ZAesa2YIJ0_QNExGnbHxGFE,5
6
6
  sourcecode/cache.py,sha256=wAyPrXN5DqiGivnMpeEuun2xHDKfBer2_oBsh6kj_vc,30447
7
7
  sourcecode/canonical_ir.py,sha256=GD0rMyaHFoBjgeJPa0L4ASG2EDr-BmvV8X0GO62cUQ8,23408
8
8
  sourcecode/classifier.py,sha256=2lYoSH3vOTkXZYPU7Go2WIet1-IuNzTWVhc-ULnXtgw,8024
9
- sourcecode/cli.py,sha256=omun45BEYK_2XMCc-92-YabsMG2y2QZGrFrmuneYXFs,209551
9
+ sourcecode/cli.py,sha256=Tg9ySkBwOe5LJfvN_keqOaKLg-8avIAUabCzY7iXZ7E,209967
10
10
  sourcecode/code_notes_analyzer.py,sha256=EJemNCNc9Dn-1RZYu-aNbK0ELzmsyC4s6FdHi3XyNEI,9392
11
11
  sourcecode/confidence_analyzer.py,sha256=_jckZSxksV-OU38vbkxfVNBnWCtlCq8Vwfg23x1uspA,19054
12
12
  sourcecode/context_scorer.py,sha256=QpChSpsmaAYz91rXA4Ue5xzQmNz_ZboZN09YOHScq1U,14679
@@ -43,9 +43,10 @@ sourcecode/schema.py,sha256=aHNXDf8LGyUC8ZDE_VS9kiskC2-Oswhi_WnpdGy6HDw,24897
43
43
  sourcecode/semantic_analyzer.py,sha256=TDuC3wzZR2DPm1mgrAg1YSLk2QzJoueS3TZAmyGGpCU,89417
44
44
  sourcecode/serializer.py,sha256=ooNZW2_fqx__BXII25eAWq-BomodvqQ6opUT_niQYCA,123403
45
45
  sourcecode/spring_findings.py,sha256=6OL0t3jVfXCbInBFordkfL_leGgwbN1nSKijtOE8hFk,4838
46
- sourcecode/spring_security_audit.py,sha256=79uZuKzoKM8Eqaj0x2hWYiRJeFHf3Q46pIKPUbevEbc,18949
46
+ sourcecode/spring_model.py,sha256=-lYFcfzFBEfCu_SYDTxyuDdlCIPwDXfPBatWzVC4qeM,9198
47
+ sourcecode/spring_security_audit.py,sha256=SKAckdLJgZ64NEnUJ4CiS1fGRA6ehOBQUH5caNOBKxU,20671
47
48
  sourcecode/spring_semantic.py,sha256=CiAf77p48-RFrUF0zbgww4w2Xigrbo1t5M3ZCDIfV_g,12032
48
- sourcecode/spring_tx_analyzer.py,sha256=Cf9xI7iseiz7u6T6ILjTOWM88Y6GqMAK0GBC346TrfA,24922
49
+ sourcecode/spring_tx_analyzer.py,sha256=D-qICuDLOI1ISw2HH1CEbJ-BEHMACbEFtIs4uSpamxs,26717
49
50
  sourcecode/summarizer.py,sha256=YspHEVeYJVmltq0FMtGZF8kIP3qiR2KLcanGL6Y7uTI,20747
50
51
  sourcecode/tree_utils.py,sha256=8GAkIfQAsvtEudIeW1l4ooH_oRtrWR8cpJQJsEa_Pfw,2093
51
52
  sourcecode/workspace.py,sha256=X_6NmNnitvT3_38V-JDChydo_sR68s249hLFlrQskU0,8271
@@ -86,8 +87,8 @@ sourcecode/telemetry/consent.py,sha256=wLMvGNJeSSyZoNkQXpoUioY6mMv4Qdvuw7S9jAEWn
86
87
  sourcecode/telemetry/events.py,sha256=oEvvulfsv5GIDWG2174gSS6tNB95w38AIYiYeifGKlE,2294
87
88
  sourcecode/telemetry/filters.py,sha256=Asa71oRl7q3Wt_FMwuufIZJFzSYdgRNKS8LHCIyFeYE,4805
88
89
  sourcecode/telemetry/transport.py,sha256=KJeIPCPWMdmbCP3ySGs2iUlia34U6vWne2dZsUezesw,1560
89
- sourcecode-1.35.0.dist-info/METADATA,sha256=qink27FH4f86LR6zpb6C_d8LYTcXUfpublVXwkxXeFI,16440
90
- sourcecode-1.35.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
91
- sourcecode-1.35.0.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
92
- sourcecode-1.35.0.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
93
- sourcecode-1.35.0.dist-info/RECORD,,
90
+ sourcecode-1.35.1.dist-info/METADATA,sha256=7OEaajYsr9cRIHRLamadUnx_VCU1J0_FDBDWMpewgkk,16440
91
+ sourcecode-1.35.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
92
+ sourcecode-1.35.1.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
93
+ sourcecode-1.35.1.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
94
+ sourcecode-1.35.1.dist-info/RECORD,,