sourcecode 2.1.0__py3-none-any.whl → 2.3.0__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/archetype.py +605 -0
- sourcecode/architecture_summary.py +41 -0
- sourcecode/caller_metrics.py +42 -0
- sourcecode/classifier.py +94 -1
- sourcecode/cli.py +191 -29
- sourcecode/detectors/java.py +29 -1
- sourcecode/endpoint_metrics.py +42 -0
- sourcecode/explain.py +2 -0
- sourcecode/graph_evidence.py +352 -0
- sourcecode/path_filters.py +2 -1
- sourcecode/repository_ir.py +50 -14
- sourcecode/semantic_integration_engine.py +18 -2
- sourcecode/serializer.py +23 -3
- sourcecode/spring_semantic.py +18 -5
- sourcecode/spring_tx_analyzer.py +21 -6
- {sourcecode-2.1.0.dist-info → sourcecode-2.3.0.dist-info}/METADATA +4 -4
- {sourcecode-2.1.0.dist-info → sourcecode-2.3.0.dist-info}/RECORD +21 -17
- {sourcecode-2.1.0.dist-info → sourcecode-2.3.0.dist-info}/WHEEL +0 -0
- {sourcecode-2.1.0.dist-info → sourcecode-2.3.0.dist-info}/entry_points.txt +0 -0
- {sourcecode-2.1.0.dist-info → sourcecode-2.3.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Single source of truth for the endpoint / route-surface reconciliation note.
|
|
2
|
+
|
|
3
|
+
Three commands report an endpoint count, each from a different point in the
|
|
4
|
+
pipeline. Read side by side they look contradictory (e.g. 245 vs 225 vs 220 for
|
|
5
|
+
one repo) — they are not. Expected ordering for a single repo:
|
|
6
|
+
|
|
7
|
+
repo-ir.route_surface >= endpoints.total >= spring-audit.endpoints_analyzed
|
|
8
|
+
|
|
9
|
+
- ``repo-ir.route_surface`` is the RAW structural surface (every @RequestMapping /
|
|
10
|
+
@Path handler after inheritance projection and JAX-RS sub-resource-locator
|
|
11
|
+
composition). It still contains framework dynamic-admin routes whose path is a
|
|
12
|
+
Java FQN (e.g. ``/org.broadleafcommerce.core.search.domain.FieldImpl``), which
|
|
13
|
+
the ``endpoints`` command filters out — so it is NOT the canonical API surface.
|
|
14
|
+
- ``endpoints.total`` is the canonical REST/API surface (FQN-shaped framework
|
|
15
|
+
routes removed). This is the number to cite for "how many endpoints".
|
|
16
|
+
- ``spring-audit.endpoints_analyzed`` is that canonical surface further deduplicated
|
|
17
|
+
by (method, path, controller, handler) for security analysis.
|
|
18
|
+
|
|
19
|
+
The exact arithmetic between the three is intentionally NOT asserted (the pipelines
|
|
20
|
+
differ in dedup/expansion, so route_surface minus FQN-paths does not equal
|
|
21
|
+
endpoints.total). Only the robust ordering and the population of each are stated.
|
|
22
|
+
|
|
23
|
+
``spring-audit`` already documents its own count inline; this note gives repo-ir's
|
|
24
|
+
raw ``route_surface`` the same self-description so no reader mistakes it for the
|
|
25
|
+
canonical endpoint total.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
ENDPOINT_SURFACE_RECONCILIATION: str = (
|
|
29
|
+
"route_surface is the RAW structural route surface: every @RequestMapping/@Path "
|
|
30
|
+
"handler after inheritance projection and JAX-RS sub-resource-locator composition. "
|
|
31
|
+
"It is NOT the canonical API surface — it still contains framework dynamic-admin "
|
|
32
|
+
"routes whose path is a Java FQN (e.g. /org.broadleafcommerce...), which the "
|
|
33
|
+
"`endpoints` command excludes. Endpoint counts differ across commands BY DESIGN "
|
|
34
|
+
"and are not contradictory; expected ordering for one repo is "
|
|
35
|
+
"repo-ir.route_surface (raw, includes FQN-shaped framework routes) >= "
|
|
36
|
+
"endpoints.total (canonical REST/API surface, FQN-shaped routes filtered out) >= "
|
|
37
|
+
"spring-audit.metadata.endpoints_analyzed (canonical surface further deduplicated "
|
|
38
|
+
"by (method, path, controller, handler) for security analysis). Cite "
|
|
39
|
+
"endpoints.total for the real API surface; the pipelines differ in dedup and "
|
|
40
|
+
"prefix expansion, so the three counts are related by this ordering, not by exact "
|
|
41
|
+
"arithmetic."
|
|
42
|
+
)
|
sourcecode/explain.py
CHANGED
|
@@ -17,6 +17,7 @@ from __future__ import annotations
|
|
|
17
17
|
from dataclasses import dataclass, field
|
|
18
18
|
from typing import TYPE_CHECKING, Optional
|
|
19
19
|
|
|
20
|
+
from sourcecode.caller_metrics import CALLER_METRIC_RECONCILIATION
|
|
20
21
|
from sourcecode.fqn_utils import normalize_owner_fqn
|
|
21
22
|
|
|
22
23
|
if TYPE_CHECKING:
|
|
@@ -121,6 +122,7 @@ class ClassExplanation:
|
|
|
121
122
|
"purpose": self.purpose,
|
|
122
123
|
"public_methods": self.public_methods,
|
|
123
124
|
"incoming_callers": self.incoming_callers,
|
|
125
|
+
"incoming_callers_note": CALLER_METRIC_RECONCILIATION,
|
|
124
126
|
"outgoing_deps": self.outgoing_deps,
|
|
125
127
|
"events_published": self.events_published,
|
|
126
128
|
"events_consumed": self.events_consumed,
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""Graph Evidence Provider — turns the structural module graph into reusable
|
|
2
|
+
topological evidence (ADR-0003, migration step 2).
|
|
3
|
+
|
|
4
|
+
Responsibility boundary (deliberately narrow):
|
|
5
|
+
|
|
6
|
+
ModuleGraph -> [ this module ] -> topological metrics -> GraphEvidence
|
|
7
|
+
|
|
8
|
+
This component knows GRAPHS, not ARCHETYPES. It does not classify, does not
|
|
9
|
+
emit prose verdicts, and never imports the archetype scorer. It computes a
|
|
10
|
+
small, robust set of topological metrics and packages each as a language- and
|
|
11
|
+
domain-agnostic ``GraphEvidence`` object. The archetype scorer consumes those
|
|
12
|
+
evidence objects; it never traverses the graph itself.
|
|
13
|
+
|
|
14
|
+
The metrics are chosen so that architectural style can emerge from STRUCTURE
|
|
15
|
+
rather than from directory names:
|
|
16
|
+
|
|
17
|
+
* scc_mass — fraction of nodes that sit inside a NON-TRIVIAL
|
|
18
|
+
strongly-connected component (an import cycle).
|
|
19
|
+
Range [0,1]. 0 for a clean DAG (layered/
|
|
20
|
+
hexagonal); high for a woven engine core.
|
|
21
|
+
Cost O(V+E) (iterative Tarjan).
|
|
22
|
+
* cyclic_density — fraction of edges that live INSIDE a non-trivial
|
|
23
|
+
SCC (true back-edges). Range [0,1]. Low for
|
|
24
|
+
layered; high for engine. Cost O(V+E).
|
|
25
|
+
* hub_concentration — share of all edges pointing at the single
|
|
26
|
+
highest fan-in node. Range [0,1]. High for
|
|
27
|
+
engine/microkernel cores. Cost O(V+E).
|
|
28
|
+
* module_centralization — Freeman centralization of the in-degree
|
|
29
|
+
distribution. Range [0,1]. ~1 for a star
|
|
30
|
+
(microkernel), ~0 for a uniform mesh. Cost O(V).
|
|
31
|
+
* downward_edge_ratio — fraction of edges flowing from a LESS stable to
|
|
32
|
+
a MORE stable node (Martin instability
|
|
33
|
+
I = out/(in+out)). Range [0,1]. High for
|
|
34
|
+
layered/hexagonal directed flow. Cost O(V+E).
|
|
35
|
+
* dependency_inversion_ratio— fraction of edges whose TARGET sits in the most
|
|
36
|
+
stable quartile (a stable, depended-upon core).
|
|
37
|
+
Range [0,1]. High for hexagonal/clean. O(V+E).
|
|
38
|
+
* fan_in_gini — Gini inequality of the in-degree distribution.
|
|
39
|
+
Range [0,1]. High when a few modules absorb most
|
|
40
|
+
dependencies (engine). Cost O(V log V).
|
|
41
|
+
|
|
42
|
+
All metrics are name-free: they are functions of the directed topology only.
|
|
43
|
+
"""
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
from dataclasses import dataclass, field
|
|
47
|
+
from typing import Iterable, Optional
|
|
48
|
+
|
|
49
|
+
# Structural edge kinds that represent a real dependency between modules.
|
|
50
|
+
_STRUCTURAL_EDGE_KINDS = frozenset({"imports", "calls"})
|
|
51
|
+
|
|
52
|
+
# Graph-size thresholds for how much a metric can be trusted / how much mass it
|
|
53
|
+
# is allowed to contribute. A three-node toy graph must not speak with the same
|
|
54
|
+
# authority as a sixty-module one.
|
|
55
|
+
_MIN_NODES_FOR_METRICS = 4
|
|
56
|
+
_MIN_EDGES_FOR_METRICS = 3
|
|
57
|
+
_HIGH_CONF_NODES = 12
|
|
58
|
+
_MASS_SATURATION_NODES = 10.0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class GraphEvidence:
|
|
63
|
+
"""One topological fact, ready to be consumed by any scorer.
|
|
64
|
+
|
|
65
|
+
Archetype-agnostic: it says *what the graph looks like*, never *what the
|
|
66
|
+
system is*. ``value`` is the raw metric in [0,1]; the scorer decides which
|
|
67
|
+
archetype (if any) that value supports and with what weight."""
|
|
68
|
+
|
|
69
|
+
provider: str # always "module_graph"
|
|
70
|
+
kind: str # metric name, e.g. "scc_mass"
|
|
71
|
+
value: float # raw metric, 0..1
|
|
72
|
+
mass: float # 0..1 — topological coverage (graph size proxy)
|
|
73
|
+
confidence: str # low | medium | high — from graph size
|
|
74
|
+
detail: str # human-readable one-liner
|
|
75
|
+
|
|
76
|
+
def to_dict(self) -> dict:
|
|
77
|
+
return {
|
|
78
|
+
"provider": self.provider,
|
|
79
|
+
"kind": self.kind,
|
|
80
|
+
"value": round(self.value, 4),
|
|
81
|
+
"mass": round(self.mass, 4),
|
|
82
|
+
"confidence": self.confidence,
|
|
83
|
+
"detail": self.detail,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class GraphEvidenceResult:
|
|
89
|
+
"""Metrics + evidence produced from a single module graph."""
|
|
90
|
+
|
|
91
|
+
node_count: int
|
|
92
|
+
edge_count: int
|
|
93
|
+
metrics: dict[str, float] = field(default_factory=dict)
|
|
94
|
+
evidence: list[GraphEvidence] = field(default_factory=list)
|
|
95
|
+
available: bool = False # False when the graph is too small to trust
|
|
96
|
+
confidence: str = "low" # overall provider confidence
|
|
97
|
+
|
|
98
|
+
def metric(self, kind: str, default: float = 0.0) -> float:
|
|
99
|
+
return self.metrics.get(kind, default)
|
|
100
|
+
|
|
101
|
+
def to_dict(self) -> dict:
|
|
102
|
+
return {
|
|
103
|
+
"node_count": self.node_count,
|
|
104
|
+
"edge_count": self.edge_count,
|
|
105
|
+
"available": self.available,
|
|
106
|
+
"confidence": self.confidence,
|
|
107
|
+
"metrics": {k: round(v, 4) for k, v in self.metrics.items()},
|
|
108
|
+
"evidence": [e.to_dict() for e in self.evidence],
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class GraphEvidenceProvider:
|
|
113
|
+
"""Transforms a module graph (nodes + directed edges) into GraphEvidence.
|
|
114
|
+
|
|
115
|
+
Stateless. Accepts anything exposing ``nodes`` and ``edges`` (a
|
|
116
|
+
``schema.ModuleGraph`` does), so tests can hand it synthetic graphs without
|
|
117
|
+
building a repository."""
|
|
118
|
+
|
|
119
|
+
def analyze(
|
|
120
|
+
self,
|
|
121
|
+
graph: object,
|
|
122
|
+
*,
|
|
123
|
+
edge_kinds: Optional[Iterable[str]] = None,
|
|
124
|
+
) -> GraphEvidenceResult:
|
|
125
|
+
nodes, adjacency = self._project(graph, edge_kinds)
|
|
126
|
+
n = len(nodes)
|
|
127
|
+
edges = [(u, v) for u, outs in adjacency.items() for v in outs]
|
|
128
|
+
e = len(edges)
|
|
129
|
+
|
|
130
|
+
result = GraphEvidenceResult(node_count=n, edge_count=e)
|
|
131
|
+
if n < _MIN_NODES_FOR_METRICS or e < _MIN_EDGES_FOR_METRICS:
|
|
132
|
+
# Too small to say anything structural. Report nothing rather than
|
|
133
|
+
# asserting metrics on noise.
|
|
134
|
+
return result
|
|
135
|
+
|
|
136
|
+
result.available = True
|
|
137
|
+
result.confidence = "high" if n >= _HIGH_CONF_NODES else "medium"
|
|
138
|
+
topo_mass = min(1.0, n / _MASS_SATURATION_NODES)
|
|
139
|
+
|
|
140
|
+
in_deg, out_deg = self._degrees(nodes, edges)
|
|
141
|
+
scc_of, scc_sizes = self._tarjan_scc(nodes, adjacency)
|
|
142
|
+
instability = self._instability(nodes, in_deg, out_deg)
|
|
143
|
+
|
|
144
|
+
m: dict[str, float] = {}
|
|
145
|
+
# Only NON-TRIVIAL SCCs (size > 1) count: a pure DAG has none, a woven
|
|
146
|
+
# engine core has many. A single-node "component" is not a cycle.
|
|
147
|
+
cyclic_nodes = sum(sz for sz in scc_sizes.values() if sz > 1)
|
|
148
|
+
m["scc_mass"] = (cyclic_nodes / n) if n else 0.0
|
|
149
|
+
m["cyclic_density"] = self._cyclic_density(edges, scc_of, scc_sizes, e)
|
|
150
|
+
m["hub_concentration"] = (max(in_deg.values(), default=0) / e) if e else 0.0
|
|
151
|
+
m["module_centralization"] = self._centralization(in_deg, n)
|
|
152
|
+
m["downward_edge_ratio"] = self._downward_ratio(edges, instability)
|
|
153
|
+
m["dependency_inversion_ratio"] = self._inversion_ratio(edges, instability)
|
|
154
|
+
m["fan_in_gini"] = self._gini([in_deg[x] for x in nodes])
|
|
155
|
+
result.metrics = m
|
|
156
|
+
|
|
157
|
+
# One evidence object per metric.
|
|
158
|
+
details = {
|
|
159
|
+
"scc_mass": f"{m['scc_mass']:.0%} of modules sit inside an import cycle",
|
|
160
|
+
"cyclic_density": f"{m['cyclic_density']:.0%} of edges are back-edges inside a cycle",
|
|
161
|
+
"hub_concentration": f"top module absorbs {m['hub_concentration']:.0%} of all dependencies",
|
|
162
|
+
"module_centralization": f"in-degree centralization {m['module_centralization']:.2f} (1=star, 0=mesh)",
|
|
163
|
+
"downward_edge_ratio": f"{m['downward_edge_ratio']:.0%} of edges flow toward more-stable modules",
|
|
164
|
+
"dependency_inversion_ratio": f"{m['dependency_inversion_ratio']:.0%} of edges point into the stable core",
|
|
165
|
+
"fan_in_gini": f"in-degree inequality (Gini) {m['fan_in_gini']:.2f}",
|
|
166
|
+
}
|
|
167
|
+
result.evidence = [
|
|
168
|
+
GraphEvidence(
|
|
169
|
+
provider="module_graph", kind=k, value=m[k], mass=topo_mass,
|
|
170
|
+
confidence=result.confidence, detail=details[k],
|
|
171
|
+
)
|
|
172
|
+
for k in details
|
|
173
|
+
]
|
|
174
|
+
return result
|
|
175
|
+
|
|
176
|
+
# ── graph projection ───────────────────────────────────────────────────────
|
|
177
|
+
@staticmethod
|
|
178
|
+
def _project(
|
|
179
|
+
graph: object, edge_kinds: Optional[Iterable[str]]
|
|
180
|
+
) -> "tuple[list[str], dict[str, set[str]]]":
|
|
181
|
+
"""Reduce the graph to a deduped directed adjacency over the relevant
|
|
182
|
+
vertices. Prefers module-granularity nodes; falls back to all nodes."""
|
|
183
|
+
raw_nodes = list(getattr(graph, "nodes", []) or [])
|
|
184
|
+
raw_edges = list(getattr(graph, "edges", []) or [])
|
|
185
|
+
kinds = frozenset(edge_kinds) if edge_kinds is not None else _STRUCTURAL_EDGE_KINDS
|
|
186
|
+
|
|
187
|
+
module_ids = {getattr(nd, "id") for nd in raw_nodes if getattr(nd, "kind", None) == "module"}
|
|
188
|
+
# If the graph carries module nodes, restrict to them; otherwise use every
|
|
189
|
+
# node (synthetic test graphs, symbol-only graphs).
|
|
190
|
+
if module_ids:
|
|
191
|
+
allowed = module_ids
|
|
192
|
+
else:
|
|
193
|
+
allowed = {getattr(nd, "id") for nd in raw_nodes}
|
|
194
|
+
|
|
195
|
+
adjacency: dict[str, set[str]] = {nid: set() for nid in allowed}
|
|
196
|
+
for ed in raw_edges:
|
|
197
|
+
if getattr(ed, "kind", None) not in kinds:
|
|
198
|
+
continue
|
|
199
|
+
u = getattr(ed, "source", None)
|
|
200
|
+
v = getattr(ed, "target", None)
|
|
201
|
+
if u is None or v is None or u == v:
|
|
202
|
+
continue
|
|
203
|
+
if u not in allowed or v not in allowed:
|
|
204
|
+
continue
|
|
205
|
+
adjacency.setdefault(u, set()).add(v)
|
|
206
|
+
adjacency.setdefault(v, set())
|
|
207
|
+
nodes = sorted(adjacency.keys())
|
|
208
|
+
return nodes, adjacency
|
|
209
|
+
|
|
210
|
+
# ── metric primitives ──────────────────────────────────────────────────────
|
|
211
|
+
@staticmethod
|
|
212
|
+
def _degrees(
|
|
213
|
+
nodes: list[str], edges: list[tuple[str, str]]
|
|
214
|
+
) -> "tuple[dict[str, int], dict[str, int]]":
|
|
215
|
+
in_deg = {x: 0 for x in nodes}
|
|
216
|
+
out_deg = {x: 0 for x in nodes}
|
|
217
|
+
for u, v in edges:
|
|
218
|
+
out_deg[u] += 1
|
|
219
|
+
in_deg[v] += 1
|
|
220
|
+
return in_deg, out_deg
|
|
221
|
+
|
|
222
|
+
@staticmethod
|
|
223
|
+
def _tarjan_scc(
|
|
224
|
+
nodes: list[str], adjacency: dict[str, set[str]]
|
|
225
|
+
) -> "tuple[dict[str, int], dict[int, int]]":
|
|
226
|
+
"""Iterative Tarjan. Returns (node -> scc_id, scc_id -> size)."""
|
|
227
|
+
index_of: dict[str, int] = {}
|
|
228
|
+
low: dict[str, int] = {}
|
|
229
|
+
on_stack: set[str] = set()
|
|
230
|
+
stack: list[str] = []
|
|
231
|
+
scc_of: dict[str, int] = {}
|
|
232
|
+
sizes: dict[int, int] = {}
|
|
233
|
+
counter = 0
|
|
234
|
+
scc_id = 0
|
|
235
|
+
|
|
236
|
+
for root in nodes:
|
|
237
|
+
if root in index_of:
|
|
238
|
+
continue
|
|
239
|
+
# work stack of (node, iterator-position)
|
|
240
|
+
work: list[tuple[str, int]] = [(root, 0)]
|
|
241
|
+
while work:
|
|
242
|
+
v, pi = work[-1]
|
|
243
|
+
if pi == 0:
|
|
244
|
+
index_of[v] = low[v] = counter
|
|
245
|
+
counter += 1
|
|
246
|
+
stack.append(v)
|
|
247
|
+
on_stack.add(v)
|
|
248
|
+
succ = sorted(adjacency.get(v, ()))
|
|
249
|
+
if pi < len(succ):
|
|
250
|
+
work[-1] = (v, pi + 1)
|
|
251
|
+
w = succ[pi]
|
|
252
|
+
if w not in index_of:
|
|
253
|
+
work.append((w, 0))
|
|
254
|
+
elif w in on_stack:
|
|
255
|
+
low[v] = min(low[v], index_of[w])
|
|
256
|
+
continue
|
|
257
|
+
# done with v
|
|
258
|
+
if low[v] == index_of[v]:
|
|
259
|
+
size = 0
|
|
260
|
+
while True:
|
|
261
|
+
w = stack.pop()
|
|
262
|
+
on_stack.discard(w)
|
|
263
|
+
scc_of[w] = scc_id
|
|
264
|
+
size += 1
|
|
265
|
+
if w == v:
|
|
266
|
+
break
|
|
267
|
+
sizes[scc_id] = size
|
|
268
|
+
scc_id += 1
|
|
269
|
+
work.pop()
|
|
270
|
+
if work:
|
|
271
|
+
parent = work[-1][0]
|
|
272
|
+
low[parent] = min(low[parent], low[v])
|
|
273
|
+
return scc_of, sizes
|
|
274
|
+
|
|
275
|
+
@staticmethod
|
|
276
|
+
def _cyclic_density(
|
|
277
|
+
edges: list[tuple[str, str]], scc_of: dict[str, int],
|
|
278
|
+
scc_sizes: dict[int, int], e: int,
|
|
279
|
+
) -> float:
|
|
280
|
+
if not e:
|
|
281
|
+
return 0.0
|
|
282
|
+
intra = 0
|
|
283
|
+
for u, v in edges:
|
|
284
|
+
su = scc_of.get(u)
|
|
285
|
+
if su is not None and su == scc_of.get(v) and scc_sizes.get(su, 1) > 1:
|
|
286
|
+
intra += 1
|
|
287
|
+
return intra / e
|
|
288
|
+
|
|
289
|
+
@staticmethod
|
|
290
|
+
def _centralization(in_deg: dict[str, int], n: int) -> float:
|
|
291
|
+
"""Freeman in-degree centralization, normalized to [0,1]."""
|
|
292
|
+
if n < 2:
|
|
293
|
+
return 0.0
|
|
294
|
+
mx = max(in_deg.values(), default=0)
|
|
295
|
+
total_diff = sum(mx - d for d in in_deg.values())
|
|
296
|
+
denom = (n - 1) * (n - 1) # theoretical max for a directed star
|
|
297
|
+
return min(1.0, total_diff / denom) if denom else 0.0
|
|
298
|
+
|
|
299
|
+
@staticmethod
|
|
300
|
+
def _instability(
|
|
301
|
+
nodes: list[str], in_deg: dict[str, int], out_deg: dict[str, int]
|
|
302
|
+
) -> dict[str, float]:
|
|
303
|
+
"""Martin instability I = out / (in + out). Isolated nodes -> None-like
|
|
304
|
+
(excluded via absence)."""
|
|
305
|
+
inst: dict[str, float] = {}
|
|
306
|
+
for x in nodes:
|
|
307
|
+
total = in_deg[x] + out_deg[x]
|
|
308
|
+
if total > 0:
|
|
309
|
+
inst[x] = out_deg[x] / total
|
|
310
|
+
return inst
|
|
311
|
+
|
|
312
|
+
@staticmethod
|
|
313
|
+
def _downward_ratio(
|
|
314
|
+
edges: list[tuple[str, str]], instability: dict[str, float]
|
|
315
|
+
) -> float:
|
|
316
|
+
considered = 0
|
|
317
|
+
downward = 0
|
|
318
|
+
for u, v in edges:
|
|
319
|
+
if u in instability and v in instability:
|
|
320
|
+
considered += 1
|
|
321
|
+
# edge from less-stable (higher I) to more-stable (lower I)
|
|
322
|
+
if instability[u] >= instability[v]:
|
|
323
|
+
downward += 1
|
|
324
|
+
return (downward / considered) if considered else 0.0
|
|
325
|
+
|
|
326
|
+
@staticmethod
|
|
327
|
+
def _inversion_ratio(
|
|
328
|
+
edges: list[tuple[str, str]], instability: dict[str, float]
|
|
329
|
+
) -> float:
|
|
330
|
+
"""Fraction of edges whose target sits in the most-stable quartile."""
|
|
331
|
+
vals = sorted(instability.values())
|
|
332
|
+
if not vals or not edges:
|
|
333
|
+
return 0.0
|
|
334
|
+
q1 = vals[max(0, len(vals) // 4 - 1)]
|
|
335
|
+
into_core = sum(
|
|
336
|
+
1 for _u, v in edges
|
|
337
|
+
if v in instability and instability[v] <= q1
|
|
338
|
+
)
|
|
339
|
+
return into_core / len(edges)
|
|
340
|
+
|
|
341
|
+
@staticmethod
|
|
342
|
+
def _gini(values: list[int]) -> float:
|
|
343
|
+
vals = sorted(float(v) for v in values)
|
|
344
|
+
n = len(vals)
|
|
345
|
+
s = sum(vals)
|
|
346
|
+
if n == 0 or s == 0:
|
|
347
|
+
return 0.0
|
|
348
|
+
cum = 0.0
|
|
349
|
+
for i, v in enumerate(vals, start=1):
|
|
350
|
+
cum += i * v
|
|
351
|
+
# Gini = (2*sum(i*x_i) / (n*sum)) - (n+1)/n
|
|
352
|
+
return max(0.0, min(1.0, (2 * cum) / (n * s) - (n + 1) / n))
|
sourcecode/path_filters.py
CHANGED
|
@@ -16,7 +16,8 @@ _TEST_SEGMENTS = frozenset({
|
|
|
16
16
|
# code lives under src/main (e.g. a Maven module that ships a test framework).
|
|
17
17
|
# A finding under one of these modules is test infrastructure, not the product.
|
|
18
18
|
_TEST_MODULE_SEGMENTS = frozenset({
|
|
19
|
-
"testsuite", "test-
|
|
19
|
+
"testsuite", "test-suite", "test-suites",
|
|
20
|
+
"test-framework", "testframework",
|
|
20
21
|
"integration-arquillian", "arquillian",
|
|
21
22
|
"test-utils", "test-util", "testutils",
|
|
22
23
|
"test-support", "testsupport",
|
sourcecode/repository_ir.py
CHANGED
|
@@ -22,6 +22,8 @@ from dataclasses import dataclass, field
|
|
|
22
22
|
from pathlib import Path
|
|
23
23
|
from typing import Any, Iterable, Optional
|
|
24
24
|
|
|
25
|
+
from sourcecode.caller_metrics import CALLER_METRIC_RECONCILIATION
|
|
26
|
+
from sourcecode.endpoint_metrics import ENDPOINT_SURFACE_RECONCILIATION
|
|
25
27
|
from sourcecode.fqn_utils import normalize_owner_fqn as _normalize_owner_fqn
|
|
26
28
|
from sourcecode.path_filters import is_test_path as _is_test_path
|
|
27
29
|
from sourcecode.security_config import (
|
|
@@ -3965,6 +3967,11 @@ def _assemble(
|
|
|
3965
3967
|
return {
|
|
3966
3968
|
**_base,
|
|
3967
3969
|
"route_surface": _route_surface,
|
|
3970
|
+
# SC-9 (Broadleaf field test): route_surface is the RAW surface (includes
|
|
3971
|
+
# framework dynamic-admin FQN-shaped routes the `endpoints` command filters).
|
|
3972
|
+
# Self-describe it so its count is never read as contradicting endpoints /
|
|
3973
|
+
# spring-audit totals when cross-referenced.
|
|
3974
|
+
"route_surface_note": ENDPOINT_SURFACE_RECONCILIATION,
|
|
3968
3975
|
"spring_events": _spring_events,
|
|
3969
3976
|
"analysis_gaps": _analysis_gaps,
|
|
3970
3977
|
"security_model": _security_model_asm,
|
|
@@ -4236,6 +4243,10 @@ def _build_route_surface(
|
|
|
4236
4243
|
"return_type": (sym.return_type.strip() if sym.return_type else "void"),
|
|
4237
4244
|
"stable_id": sym.stable_id,
|
|
4238
4245
|
"inheritance_depth": 0,
|
|
4246
|
+
# Provenance: which annotation family this modeled route came from.
|
|
4247
|
+
# Used downstream so a MODELED framework (JAX-RS) is never also reported
|
|
4248
|
+
# as an un-modeled "non_spring_rest_surface" (A2 field-validation fix).
|
|
4249
|
+
"framework": "jax_rs" if ann_name in _JAXRS_HTTP_ANNOTATIONS else "spring_mvc",
|
|
4239
4250
|
}
|
|
4240
4251
|
_route_entry["security_annotations"] = _sec
|
|
4241
4252
|
routes.append(_route_entry)
|
|
@@ -4721,11 +4732,16 @@ def apply_ir_size_limits(
|
|
|
4721
4732
|
if isinstance(raw_rs, list):
|
|
4722
4733
|
_rs_total = len(raw_rs)
|
|
4723
4734
|
out["route_surface"] = raw_rs[:50]
|
|
4735
|
+
# SC-9: always carry the raw-surface reconciliation note; prepend the
|
|
4736
|
+
# truncation notice when the list was capped.
|
|
4724
4737
|
if _rs_total > 50:
|
|
4725
4738
|
out["route_surface_note"] = (
|
|
4726
|
-
f"Showing 50/{_rs_total}
|
|
4727
|
-
"Remove --summary-only for full route surface."
|
|
4739
|
+
f"Showing 50/{_rs_total} route-surface entries. "
|
|
4740
|
+
"Remove --summary-only for the full route surface. "
|
|
4741
|
+
+ ENDPOINT_SURFACE_RECONCILIATION
|
|
4728
4742
|
)
|
|
4743
|
+
else:
|
|
4744
|
+
out["route_surface_note"] = ENDPOINT_SURFACE_RECONCILIATION
|
|
4729
4745
|
elif isinstance(raw_rs, dict):
|
|
4730
4746
|
# Legacy dict format with "endpoints" sub-key
|
|
4731
4747
|
raw_eps: list = raw_rs.get("endpoints") or []
|
|
@@ -5116,16 +5132,19 @@ def extract_java_endpoints(root: Path) -> "dict[str, Any]":
|
|
|
5116
5132
|
# total false negative that also disables the `validation` command downstream.
|
|
5117
5133
|
# Detection is by SYNTACTIC SHAPE (no framework knowledge): an HTTP-verb method
|
|
5118
5134
|
# name, a first argument that is a string literal looking like a path (starts
|
|
5119
|
-
# with "/", may contain :param / {param}), AND a second argument
|
|
5120
|
-
#
|
|
5121
|
-
#
|
|
5122
|
-
#
|
|
5123
|
-
#
|
|
5124
|
-
#
|
|
5125
|
-
#
|
|
5135
|
+
# with "/", may contain :param / {param}), AND a second argument that is a
|
|
5136
|
+
# HANDLER, not a literal. Matches both bare `get(...)` (static-import / Spark
|
|
5137
|
+
# style) and `app.get(...)` (Javalin style); the lookbehind only rejects an
|
|
5138
|
+
# identifier char so `forget(` is not mistaken for `get(`.
|
|
5139
|
+
# B1 fix (field validation, openmrs FormUtil): a route handler is always a
|
|
5140
|
+
# method-ref / lambda / call / identifier — never a bare literal. The negative
|
|
5141
|
+
# lookahead after the comma rejects a literal second argument, so a plain map
|
|
5142
|
+
# write like `swapChars.put("/", "slash")` is no longer mistaken for a `PUT /`
|
|
5143
|
+
# route. Reported at confidence "medium" — an occasional flagged false positive
|
|
5144
|
+
# beats a total silent false negative.
|
|
5126
5145
|
_DSL_ROUTE_RE = _re.compile(
|
|
5127
5146
|
r'(?<![A-Za-z0-9_])(get|post|put|delete|patch|head|options)\s*\(\s*'
|
|
5128
|
-
r'"(/[^"\s]*)"\s*,'
|
|
5147
|
+
r'"(/[^"\s]*)"\s*,(?!\s*(?:["0-9]|true\b|false\b|null\b))'
|
|
5129
5148
|
)
|
|
5130
5149
|
_dsl_routes: list[dict] = []
|
|
5131
5150
|
|
|
@@ -5441,6 +5460,15 @@ def extract_java_endpoints(root: Path) -> "dict[str, Any]":
|
|
|
5441
5460
|
if _webscript_descriptors:
|
|
5442
5461
|
_nonspring["webscripts"] += _webscript_descriptors
|
|
5443
5462
|
|
|
5463
|
+
# A2 fix (eureka/keycloak field validation): JAX-RS is MODELED by _build_route_surface
|
|
5464
|
+
# (routes carry framework="jax_rs"). When we actually modeled JAX-RS endpoints, it is
|
|
5465
|
+
# NOT an un-modeled "non_spring_rest_surface" — reporting it as such contradicted the
|
|
5466
|
+
# endpoints just emitted (e.g. keycloak's 689 JAX-RS routes labelled "not modeled").
|
|
5467
|
+
# Only genuinely un-modeled surfaces (WebScripts, Servlets, or JAX-RS that produced no
|
|
5468
|
+
# modeled route — client proxies / extraction gaps) remain here.
|
|
5469
|
+
if any(r.get("framework") == "jax_rs" for r in routes):
|
|
5470
|
+
_nonspring.pop("jax_rs", None)
|
|
5471
|
+
|
|
5444
5472
|
_nonspring_total = sum(_nonspring.values())
|
|
5445
5473
|
if _nonspring_total:
|
|
5446
5474
|
_frameworks = [k for k, v in _nonspring.items() if v]
|
|
@@ -5471,9 +5499,9 @@ def extract_java_endpoints(root: Path) -> "dict[str, Any]":
|
|
|
5471
5499
|
)
|
|
5472
5500
|
else:
|
|
5473
5501
|
_msg = (
|
|
5474
|
-
f"Additional REST surface
|
|
5475
|
-
f"
|
|
5476
|
-
f"
|
|
5502
|
+
f"Additional REST surface detected but NOT modeled here: {_fw_label}. "
|
|
5503
|
+
f"The endpoints above cover the annotation-modeled surface (Spring MVC and "
|
|
5504
|
+
f"JAX-RS); the {_fw_label} surface is additional and not included in the count."
|
|
5477
5505
|
)
|
|
5478
5506
|
result.setdefault("warnings", []).append(_msg)
|
|
5479
5507
|
|
|
@@ -6153,10 +6181,18 @@ def compute_blast_radius(
|
|
|
6153
6181
|
"no indirect callers reachable from sampled seeds (terminal sink or sparse graph). "
|
|
6154
6182
|
"Use a lower-fan-in entry point for full transitive traversal."
|
|
6155
6183
|
)
|
|
6184
|
+
# SC-6 (Broadleaf field test): the direct_callers array is a truncated display
|
|
6185
|
+
# sample. Disclose the cap and point to stats.direct_caller_count as the true
|
|
6186
|
+
# total (== explain.incoming_callers), then append the cross-command
|
|
6187
|
+
# reconciliation so len(direct_callers) is never misread as a contradiction.
|
|
6156
6188
|
if len(direct_callers) > 30:
|
|
6157
6189
|
out["direct_callers_note"] = (
|
|
6158
|
-
f"Showing 30/{n_direct} direct callers
|
|
6190
|
+
f"Showing 30/{n_direct} distinct direct callers; "
|
|
6191
|
+
f"stats.direct_caller_count = {n_direct} is the true total "
|
|
6192
|
+
f"(use --output for the full list). " + CALLER_METRIC_RECONCILIATION
|
|
6159
6193
|
)
|
|
6194
|
+
else:
|
|
6195
|
+
out["direct_callers_note"] = CALLER_METRIC_RECONCILIATION
|
|
6160
6196
|
if len(indirect_callers) > 50:
|
|
6161
6197
|
out["indirect_callers_note"] = (
|
|
6162
6198
|
f"Showing 50/{n_indirect} indirect callers. Use --output to inspect full IR."
|
|
@@ -179,7 +179,9 @@ class SemanticIntegrationEngine:
|
|
|
179
179
|
|
|
180
180
|
def detect(self) -> list[Integration]:
|
|
181
181
|
"""All outbound integrations recoverable from typed facts, deterministically
|
|
182
|
-
ordered. One record per (
|
|
182
|
+
ordered. One record per semantic point (kind + evidence location): a type that
|
|
183
|
+
touches several client libraries of the same kind counts once. Targets are
|
|
184
|
+
attributed by
|
|
183
185
|
annotation args (declarative) or by literal scheme (imperative), never by a
|
|
184
186
|
receiver variable name."""
|
|
185
187
|
per_type: dict[str, list[Integration]] = {}
|
|
@@ -231,7 +233,21 @@ class SemanticIntegrationEngine:
|
|
|
231
233
|
per_type[t.fqn] = list(recs.values())
|
|
232
234
|
|
|
233
235
|
out = [r for recs in per_type.values() for r in recs]
|
|
234
|
-
|
|
236
|
+
out.sort(key=lambda i: (i.kind, i.client, i.evidence))
|
|
237
|
+
# REG-1 (Broadleaf field test): one integration per semantic point =
|
|
238
|
+
# (kind, evidence location). A single type touching several client-library
|
|
239
|
+
# types of the SAME kind — e.g. spring-ldap + spring-security-ldap on one
|
|
240
|
+
# LdapUserDetailsMapper, or javamail + spring-mail on one MessageCreator —
|
|
241
|
+
# previously emitted one record per client label, double-counting the same
|
|
242
|
+
# integration. Collapse to a single record per (kind, evidence), preferring
|
|
243
|
+
# one that carries a resolved target; ties broken by the deterministic
|
|
244
|
+
# (kind, client, evidence) order established above.
|
|
245
|
+
deduped: dict[tuple, Integration] = {}
|
|
246
|
+
for r in out:
|
|
247
|
+
k = (r.kind, r.evidence)
|
|
248
|
+
if k not in deduped or (r.target and deduped[k].target is None):
|
|
249
|
+
deduped[k] = r
|
|
250
|
+
return list(deduped.values())
|
|
235
251
|
|
|
236
252
|
def _coverage(self, n_records: int, n_files: int) -> "tuple[str, str]":
|
|
237
253
|
"""Honest coverage signal (mirrors integration_detector): a low count on a
|
sourcecode/serializer.py
CHANGED
|
@@ -462,11 +462,31 @@ def _mybatis_pairing(sm: "SourceMap", *, full: bool = False) -> "Optional[dict[s
|
|
|
462
462
|
|
|
463
463
|
|
|
464
464
|
def _spring_boot_version(sm: "SourceMap") -> "Optional[str]":
|
|
465
|
-
"""Extract Spring Boot version from detected frameworks.
|
|
465
|
+
"""Extract Spring Boot version from detected frameworks.
|
|
466
|
+
|
|
467
|
+
A3 fix (field validation, Broadleaf): when Spring Boot IS detected but the
|
|
468
|
+
stack detector could not attach a version (e.g. the version lives in a
|
|
469
|
+
`<spring.boot.version>` pom property rather than spring-boot-starter-parent),
|
|
470
|
+
fall back to migrate-check's property-resolving detector so `--compact`
|
|
471
|
+
reports the SAME version migrate-check already knows — the engine must not
|
|
472
|
+
surface a fact in one command and hide it in another. Gated on Boot already
|
|
473
|
+
being detected, so no phantom version is ever introduced.
|
|
474
|
+
"""
|
|
475
|
+
_boot_detected = False
|
|
466
476
|
for s in sm.stacks:
|
|
467
477
|
for fw in s.frameworks:
|
|
468
|
-
if fw.name == "Spring Boot"
|
|
469
|
-
|
|
478
|
+
if fw.name == "Spring Boot":
|
|
479
|
+
if fw.version:
|
|
480
|
+
return fw.version
|
|
481
|
+
_boot_detected = True
|
|
482
|
+
if _boot_detected:
|
|
483
|
+
_root = getattr(sm.metadata, "analyzed_path", None) if sm.metadata else None
|
|
484
|
+
if _root:
|
|
485
|
+
try:
|
|
486
|
+
from sourcecode.migrate_check import _detect_spring_boot
|
|
487
|
+
return _detect_spring_boot(Path(_root), 0)[1]
|
|
488
|
+
except Exception:
|
|
489
|
+
return None
|
|
470
490
|
return None
|
|
471
491
|
|
|
472
492
|
|