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