sourcecode 1.35.0__py3-none-any.whl → 1.35.2__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.2"
@@ -20,8 +20,11 @@ from dataclasses import dataclass, field
20
20
  from pathlib import Path
21
21
  from typing import Any, Optional
22
22
 
23
+ from sourcecode.cir_graphs import ImplementationGraph, InjectionGraph
23
24
  from sourcecode.repository_ir import (
24
25
  build_repo_ir,
26
+ )
27
+ from sourcecode.repository_ir import (
25
28
  compute_blast_radius as _compute_blast_radius,
26
29
  )
27
30
 
@@ -78,7 +81,7 @@ class CanonicalSecurity:
78
81
  @classmethod
79
82
  def from_policy_dict(
80
83
  cls, d: dict, *, source_scope: str = "method"
81
- ) -> "CanonicalSecurity":
84
+ ) -> CanonicalSecurity:
82
85
  """Build from the policy dict emitted by _route_security_from_sym."""
83
86
  return cls(
84
87
  policy=d.get("policy", ""),
@@ -165,6 +168,15 @@ class CanonicalRepositoryIR:
165
168
  endpoints: list[CanonicalEndpoint] # canonical endpoint list
166
169
  security_index: dict[str, CanonicalSecurity] # handler_symbol → security
167
170
  metadata: dict[str, Any] # stats, gaps, subsystems, etc.
171
+ # Derived graph indices — built from dependencies at CIR construction time.
172
+ # CH-001: interface → implementation(s) lookup
173
+ implementation_graph: ImplementationGraph = field(
174
+ default_factory=ImplementationGraph, repr=False, compare=False
175
+ )
176
+ # CH-002: DI injection dependency → dependents + field/constructor lifting
177
+ injection_graph: InjectionGraph = field(
178
+ default_factory=InjectionGraph, repr=False, compare=False
179
+ )
168
180
  # Raw IR dict retained for projections that need full IR fields
169
181
  # (e.g. project_blast_radius delegates to compute_blast_radius)
170
182
  _raw_ir: dict = field(default_factory=dict, repr=False, compare=False)
@@ -339,6 +351,11 @@ def ir_dict_to_canonical(
339
351
  IR_SCHEMA_VERSION, files, symbols, endpoints, call_graph
340
352
  )
341
353
 
354
+ # Derived graph indices built from dependency edges
355
+ known_symbols: set[str] = set(symbols)
356
+ impl_graph = ImplementationGraph.build(dependencies, known_symbols)
357
+ inj_graph = InjectionGraph.build(dependencies)
358
+
342
359
  return CanonicalRepositoryIR(
343
360
  schema_version=IR_SCHEMA_VERSION,
344
361
  cir_hash=cir_hash,
@@ -350,6 +367,8 @@ def ir_dict_to_canonical(
350
367
  endpoints=endpoints,
351
368
  security_index=security_index,
352
369
  metadata=metadata,
370
+ implementation_graph=impl_graph,
371
+ injection_graph=inj_graph,
353
372
  _raw_ir=ir,
354
373
  )
355
374
 
@@ -0,0 +1,186 @@
1
+ """cir_graphs.py — Derived graph indices built from CanonicalRepositoryIR.
2
+
3
+ ImplementationGraph (CH-001): interface → implementation(s) lookup.
4
+ InjectionGraph (CH-002): DI dependency → dependents lookup, with field/constructor lifting.
5
+
6
+ Both are built from cir.dependencies (implements + injects edges) and are keyed to
7
+ known CIR symbols only. External interfaces (java.io.Serializable, etc.) are excluded.
8
+
9
+ Architecture constraint: these classes depend only on CIR data. They must never import
10
+ from spring_model, spring_impact, or any semantic layer.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # ImplementationGraph — CH-001
18
+ # ---------------------------------------------------------------------------
19
+
20
+ @dataclass
21
+ class ImplementationGraph:
22
+ """Maps interface FQNs to their in-repo implementing classes, and vice-versa.
23
+
24
+ Built from implements edges where BOTH ends are known CIR symbols (internal
25
+ interface/class pairs). External framework interfaces are excluded.
26
+ """
27
+ _impl_of: dict[str, list[str]] = field(default_factory=dict)
28
+ _ifaces_of: dict[str, list[str]] = field(default_factory=dict)
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Queries
32
+ # ---------------------------------------------------------------------------
33
+
34
+ def implementations_of(self, interface_fqn: str) -> list[str]:
35
+ """Return FQNs of classes that implement interface_fqn (in-repo only)."""
36
+ return self._impl_of.get(interface_fqn, [])
37
+
38
+ def interfaces_of(self, class_fqn: str) -> list[str]:
39
+ """Return FQNs of in-repo interfaces implemented by class_fqn."""
40
+ return self._ifaces_of.get(class_fqn, [])
41
+
42
+ def primary_implementation(self, interface_fqn: str) -> str | None:
43
+ """Return the single implementation if unambiguous, else None.
44
+
45
+ A single implementation is unambiguous by definition.
46
+ Multiple implementations are ambiguous — callers must decide.
47
+ @Primary detection is not yet implemented (requires annotation data in CIR).
48
+ """
49
+ impls = self._impl_of.get(interface_fqn, [])
50
+ return impls[0] if len(impls) == 1 else None
51
+
52
+ def has_implementations(self, interface_fqn: str) -> bool:
53
+ return bool(self._impl_of.get(interface_fqn))
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Builder
57
+ # ---------------------------------------------------------------------------
58
+
59
+ @classmethod
60
+ def build(
61
+ cls,
62
+ dependencies: list[dict],
63
+ known_symbols: set[str],
64
+ ) -> ImplementationGraph:
65
+ """Build from CIR dependencies list, restricting to known in-repo symbols.
66
+
67
+ Args:
68
+ dependencies: cir.dependencies — list of edge dicts with 'from'/'to'/'type'
69
+ known_symbols: set(cir.symbols) — only in-repo FQNs
70
+
71
+ Excludes implements edges where the interface (to_fqn) is NOT in known_symbols
72
+ (e.g. java.io.Serializable, org.springframework.* framework interfaces).
73
+ Includes edges where the implementing class (from_fqn) is NOT in known_symbols
74
+ only when the interface IS known — this handles partial-parse edge cases.
75
+ """
76
+ impl_of: dict[str, list[str]] = {}
77
+ ifaces_of: dict[str, list[str]] = {}
78
+
79
+ for edge in dependencies:
80
+ if edge.get("type") != "implements":
81
+ continue
82
+ from_fqn = (edge.get("from") or "").strip()
83
+ to_fqn = (edge.get("to") or "").strip()
84
+ if not from_fqn or not to_fqn:
85
+ continue
86
+ # Only track when the interface is an in-repo symbol
87
+ if to_fqn not in known_symbols:
88
+ continue
89
+ # Ignore malformed FQNs (e.g. generic type fragments like "Long>")
90
+ if ">" in to_fqn or "<" in to_fqn:
91
+ continue
92
+ if ">" in from_fqn or "<" in from_fqn:
93
+ continue
94
+
95
+ if from_fqn not in impl_of.get(to_fqn, []):
96
+ impl_of.setdefault(to_fqn, []).append(from_fqn)
97
+ if to_fqn not in ifaces_of.get(from_fqn, []):
98
+ ifaces_of.setdefault(from_fqn, []).append(to_fqn)
99
+
100
+ return cls(_impl_of=impl_of, _ifaces_of=ifaces_of)
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # InjectionGraph — CH-002
105
+ # ---------------------------------------------------------------------------
106
+
107
+ @dataclass
108
+ class InjectionGraph:
109
+ """Maps DI injection edges to class-level dependency relationships.
110
+
111
+ Resolves field FQN and constructor FQN injectors to their enclosing class,
112
+ enabling BFS traversal to continue past injection boundaries.
113
+
114
+ Injects edge forms:
115
+ constructor: ClassName#<init> → DependencyFQN
116
+ field: ClassName#fieldName → DependencyFQN
117
+ lombok: ClassName → DependencyFQN (already class-level)
118
+ """
119
+ _deps_of: dict[str, list[str]] = field(default_factory=dict)
120
+ _dependents_of: dict[str, list[str]] = field(default_factory=dict)
121
+ # Maps field/constructor FQN → enclosing class FQN
122
+ _injector_to_class: dict[str, str] = field(default_factory=dict)
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # Queries
126
+ # ---------------------------------------------------------------------------
127
+
128
+ def dependencies_of(self, class_fqn: str) -> list[str]:
129
+ """Return service FQNs injected into class_fqn (de-duplicated, sorted)."""
130
+ return self._deps_of.get(class_fqn, [])
131
+
132
+ def dependents_of(self, service_fqn: str) -> list[str]:
133
+ """Return class FQNs that inject service_fqn (class-level, de-duplicated)."""
134
+ return self._dependents_of.get(service_fqn, [])
135
+
136
+ def class_of_injector(self, injector_fqn: str) -> str | None:
137
+ """Resolve a field/constructor FQN to its enclosing class.
138
+
139
+ Returns None if injector_fqn is not a known injection site.
140
+ """
141
+ return self._injector_to_class.get(injector_fqn)
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # Builder
145
+ # ---------------------------------------------------------------------------
146
+
147
+ @classmethod
148
+ def build(cls, dependencies: list[dict]) -> InjectionGraph:
149
+ """Build from CIR dependencies list.
150
+
151
+ Args:
152
+ dependencies: cir.dependencies — list of edge dicts with 'from'/'to'/'type'
153
+ """
154
+ deps_of: dict[str, list[str]] = {}
155
+ dependents_of: dict[str, list[str]] = {}
156
+ injector_to_class: dict[str, str] = {}
157
+
158
+ for edge in dependencies:
159
+ if edge.get("type") != "injects":
160
+ continue
161
+ from_fqn = (edge.get("from") or "").strip()
162
+ to_fqn = (edge.get("to") or "").strip()
163
+ if not from_fqn or not to_fqn:
164
+ continue
165
+
166
+ # Resolve injector to class level
167
+ if "#" in from_fqn:
168
+ class_fqn = from_fqn.rsplit("#", 1)[0]
169
+ injector_to_class[from_fqn] = class_fqn
170
+ else:
171
+ class_fqn = from_fqn
172
+
173
+ # Build class → [dep, ...] and service → [class, ...] indices
174
+ deps = deps_of.setdefault(class_fqn, [])
175
+ if to_fqn not in deps:
176
+ deps.append(to_fqn)
177
+
178
+ dependents = dependents_of.setdefault(to_fqn, [])
179
+ if class_fqn not in dependents:
180
+ dependents.append(class_fqn)
181
+
182
+ return cls(
183
+ _deps_of=deps_of,
184
+ _dependents_of=dependents_of,
185
+ _injector_to_class=injector_to_class,
186
+ )
sourcecode/cli.py CHANGED
@@ -225,6 +225,8 @@ _SUBCOMMANDS: frozenset[str] = frozenset(
225
225
  "cold-start",
226
226
  # Spring semantic audit
227
227
  "spring-audit",
228
+ # Spring impact chain
229
+ "impact-chain",
228
230
  }
229
231
  )
230
232
 
@@ -3766,7 +3768,7 @@ def spring_audit_cmd(
3766
3768
  from sourcecode.spring_findings import SpringAuditResult, SpringFinding
3767
3769
  from sourcecode.spring_tx_analyzer import run_tx_audit
3768
3770
  from sourcecode.spring_security_audit import run_security_audit
3769
- from sourcecode.spring_semantic import build_tx_index
3771
+ from sourcecode.spring_model import SpringSemanticModel
3770
3772
 
3771
3773
  target = path.resolve()
3772
3774
  if not target.exists() or not target.is_dir():
@@ -3816,13 +3818,13 @@ def spring_audit_cmd(
3816
3818
  return
3817
3819
 
3818
3820
  cir = build_canonical_ir(file_list, target)
3819
- tx_idx = build_tx_index(cir)
3821
+ _model = SpringSemanticModel.build(cir)
3820
3822
 
3821
3823
  results: list[SpringAuditResult] = []
3822
3824
  if scope in ("all", "tx"):
3823
- results.append(run_tx_audit(cir, root=target, min_severity=min_severity))
3825
+ results.append(run_tx_audit(cir, root=target, min_severity=min_severity, model=_model))
3824
3826
  if scope in ("all", "security"):
3825
- results.append(run_security_audit(cir, root=target, min_severity=min_severity, tx_index=tx_idx))
3827
+ results.append(run_security_audit(cir, root=target, min_severity=min_severity, model=_model))
3826
3828
 
3827
3829
  if len(results) == 1:
3828
3830
  combined = results[0]
@@ -3843,6 +3845,18 @@ def spring_audit_cmd(
3843
3845
  metadata=merged_meta,
3844
3846
  ).finalize()
3845
3847
 
3848
+ # Populate git_head from repo HEAD — non-fatal.
3849
+ try:
3850
+ import subprocess as _sub_sa
3851
+ _sha_r = _sub_sa.run(
3852
+ ["git", "-C", str(target), "rev-parse", "--short", "HEAD"],
3853
+ capture_output=True, text=True, timeout=3,
3854
+ )
3855
+ if _sha_r.returncode == 0:
3856
+ combined.git_head = _sha_r.stdout.strip()
3857
+ except Exception:
3858
+ pass
3859
+
3846
3860
  data = combined.to_dict()
3847
3861
 
3848
3862
  # Non-fatal RIS side-effect — persist summary only (not full findings).
@@ -3867,6 +3881,139 @@ def spring_audit_cmd(
3867
3881
  typer.echo("✓ copied to clipboard", err=True)
3868
3882
 
3869
3883
 
3884
+ # ── Spring Impact Chain ───────────────────────────────────────────────────────
3885
+
3886
+
3887
+ @app.command("impact-chain")
3888
+ def impact_chain_cmd(
3889
+ symbol: str = typer.Argument(
3890
+ ...,
3891
+ help=(
3892
+ "Symbol to query: FQN, class name, or Class#method. "
3893
+ "Examples: OrderService, com.example.OrderService#placeOrder"
3894
+ ),
3895
+ ),
3896
+ path: Path = typer.Argument(
3897
+ Path("."),
3898
+ help="Repository root (default: current directory)",
3899
+ ),
3900
+ depth: int = typer.Option(
3901
+ 4,
3902
+ "--depth",
3903
+ help="Indirect caller BFS depth (1–8, default: 4).",
3904
+ min=1,
3905
+ max=8,
3906
+ ),
3907
+ output_path: Optional[Path] = typer.Option(
3908
+ None, "--output", "-o",
3909
+ help="Write output to a file instead of stdout.",
3910
+ ),
3911
+ format: str = typer.Option(
3912
+ "json", "--format", "-f",
3913
+ help="Output format: json (default) or yaml.",
3914
+ show_default=True,
3915
+ ),
3916
+ copy: bool = typer.Option(
3917
+ False, "--copy", "-c",
3918
+ help="Copy output to clipboard after a successful run.",
3919
+ ),
3920
+ ) -> None:
3921
+ """Spring impact-chain: systemic blast radius of a symbol with TX/SEC enrichment.
3922
+
3923
+ \b
3924
+ Given a symbol (class or method), returns:
3925
+ - direct_callers — symbols that directly call the target
3926
+ - indirect_callers — transitive callers (BFS up to --depth hops)
3927
+ - endpoints_affected — HTTP endpoints reachable through the call chain
3928
+ - transaction_boundary — @Transactional semantics on the target (if any)
3929
+ - security_surfaces — per-endpoint security policy + SEC findings
3930
+ - impact_findings — TX/SEC audit findings touching the call chain
3931
+ - risk_level — critical | high | medium | low
3932
+
3933
+ \b
3934
+ Consumes SpringSemanticModel — zero duplicate CIR traversals.
3935
+ JAVA/SPRING ONLY.
3936
+
3937
+ \b
3938
+ Examples:
3939
+ sourcecode impact-chain OrderService .
3940
+ sourcecode impact-chain com.example.OrderService#placeOrder /path/to/repo
3941
+ sourcecode impact-chain PaymentService . --depth 6 --output impact.json
3942
+ """
3943
+ import json as _json
3944
+
3945
+ from sourcecode.repository_ir import find_java_files
3946
+ from sourcecode.canonical_ir import build_canonical_ir
3947
+ from sourcecode.spring_model import SpringSemanticModel
3948
+ from sourcecode.spring_impact import run_impact_chain
3949
+ from sourcecode.spring_findings import SpringAuditResult
3950
+
3951
+ target = path.resolve()
3952
+ if not target.exists() or not target.is_dir():
3953
+ _emit_error_json(
3954
+ INVALID_INPUT_CODE,
3955
+ f"'{target}' is not a valid directory.",
3956
+ path=str(target),
3957
+ hint="Pass an existing repository directory.",
3958
+ expected="A directory path.",
3959
+ )
3960
+ raise typer.Exit(code=1)
3961
+
3962
+ if format not in ("json", "yaml"):
3963
+ _emit_error_json(
3964
+ INVALID_INPUT_CODE,
3965
+ f"Invalid format '{format}'.",
3966
+ hint="format must be: json or yaml.",
3967
+ expected="json | yaml",
3968
+ )
3969
+ raise typer.Exit(code=1)
3970
+
3971
+ file_list = find_java_files(target)
3972
+ if not file_list:
3973
+ data = {
3974
+ "schema_version": "1.0",
3975
+ "symbol": symbol,
3976
+ "resolution": "not_found",
3977
+ "analysis_warnings": ["No Java files found in repository — Spring analysis requires Java source."],
3978
+ "risk_level": "unknown",
3979
+ "confidence": "low",
3980
+ "metadata": {},
3981
+ }
3982
+ output = _serialize_dict(data, format)
3983
+ if output_path is not None:
3984
+ output_path.write_text(output, encoding="utf-8")
3985
+ typer.echo("Impact chain written to " + str(output_path), err=True)
3986
+ else:
3987
+ sys.stdout.buffer.write(output.encode("utf-8"))
3988
+ sys.stdout.buffer.write(b"\n")
3989
+ sys.stdout.buffer.flush()
3990
+ return
3991
+
3992
+ cir = build_canonical_ir(file_list, target)
3993
+ _model = SpringSemanticModel.build(cir)
3994
+ result = run_impact_chain(cir, symbol, depth=depth, root=target, model=_model)
3995
+
3996
+ data = result.to_dict()
3997
+ output = _serialize_dict(data, format)
3998
+
3999
+ if output_path is not None:
4000
+ output_path.write_text(output, encoding="utf-8")
4001
+ typer.echo(
4002
+ f"Impact chain written to {output_path} "
4003
+ f"(risk: {result.risk_level}, "
4004
+ f"{len(result.direct_callers)} direct callers, "
4005
+ f"{len(result.endpoints_affected)} endpoints)",
4006
+ err=True,
4007
+ )
4008
+ else:
4009
+ sys.stdout.buffer.write(output.encode("utf-8"))
4010
+ sys.stdout.buffer.write(b"\n")
4011
+ sys.stdout.buffer.flush()
4012
+ if copy:
4013
+ if _copy_to_clipboard(output):
4014
+ typer.echo("✓ copied to clipboard", err=True)
4015
+
4016
+
3870
4017
  # ── Enterprise Workflow Commands ──────────────────────────────────────────────
3871
4018
  #
3872
4019
  # These are the five canonical enterprise workflows. Each is a thin wrapper
sourcecode/mcp/server.py CHANGED
@@ -649,6 +649,46 @@ def get_spring_audit(repo_path: str = ".", scope: str = "all") -> dict:
649
649
  )
650
650
 
651
651
 
652
+ @mcp.tool()
653
+ def get_impact_chain(repo_path: str = ".", symbol: str = "", depth: int = 4) -> dict:
654
+ """Spring impact-chain: systemic blast radius of a symbol with TX/SEC semantic enrichment. JAVA/SPRING ONLY.
655
+
656
+ Do NOT call this on non-Java repositories — it will return resolution=not_found.
657
+
658
+ Maps to: sourcecode impact-chain <symbol> <repo_path> [--depth <depth>]
659
+ Returns: ImpactChainResult with schema_version, symbol, resolution,
660
+ direct_callers, indirect_callers, endpoints_affected,
661
+ transaction_boundary (propagation/isolation/read_only),
662
+ security_surfaces (per-endpoint policy + finding IDs),
663
+ impact_findings (TX-001..005 + SEC-001..003 findings in call chain),
664
+ analysis_warnings, risk_level, confidence, metadata.
665
+
666
+ symbol: FQN, class name, or Class#method. Examples:
667
+ "OrderService", "com.example.OrderService#placeOrder"
668
+ repo_path: absolute path to the Java repository (default: current working directory).
669
+ depth: BFS depth for indirect caller traversal (1–8, default: 4).
670
+ """
671
+ _raw = repo_path
672
+ try:
673
+ if not isinstance(repo_path, str):
674
+ return _err("repo_path must be a string", "INVALID_ARGUMENT")
675
+ if not isinstance(symbol, str) or not symbol.strip():
676
+ return _err("symbol must be a non-empty string", "INVALID_ARGUMENT")
677
+ if not isinstance(depth, int) or depth < 1 or depth > 8:
678
+ return _err("depth must be an integer between 1 and 8", "INVALID_ARGUMENT")
679
+ repo_path = _normalize_repo_path(repo_path)
680
+ _path_err = _check_repo_path(repo_path)
681
+ if _path_err is not None:
682
+ return _path_err
683
+ args = ["impact-chain", symbol.strip(), repo_path, "--depth", str(depth)]
684
+ return _execute(args)
685
+ except Exception as exc:
686
+ return _err(
687
+ f"Internal error: {type(exc).__name__}: {exc} — repo_path recibido: {_raw}",
688
+ "INTERNAL_ERROR",
689
+ )
690
+
691
+
652
692
  @mcp.tool()
653
693
  def get_module_context(repo_path: str = ".", module: str = "") -> dict:
654
694
  """Compact analysis of a specific module or subdirectory within a repository.
@@ -128,3 +128,20 @@ class SpringAuditResult:
128
128
  "limitations": self.limitations,
129
129
  "metadata": self.metadata,
130
130
  }
131
+
132
+
133
+ # ---------------------------------------------------------------------------
134
+ # Shared engine utilities — used by TxPatternEngine and SecurityScanner
135
+ # ---------------------------------------------------------------------------
136
+
137
+ SEVERITY_ORDER: dict[str, int] = {"critical": 0, "high": 1, "medium": 2, "low": 3}
138
+
139
+
140
+ def deduplicate_findings(findings: list[SpringFinding]) -> list[SpringFinding]:
141
+ seen: set[str] = set()
142
+ out: list[SpringFinding] = []
143
+ for f in findings:
144
+ if f.id not in seen:
145
+ seen.add(f.id)
146
+ out.append(f)
147
+ return out