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.
@@ -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))
@@ -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-framework", "testframework",
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",
@@ -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} endpoints. "
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 (the trailing
5120
- # comma) the comma is what distinguishes a route registration from a 1-arg
5121
- # getter such as Map.get("/k"). Matches both bare `get(...)` (static-import /
5122
- # Spark style) and `app.get(...)` (Javalin style); the lookbehind only rejects
5123
- # an identifier char so `forget(` is not mistaken for `get(`. Reported at
5124
- # confidence "medium" an occasional flagged false positive beats a total
5125
- # silent false negative.
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 present outside Spring MVC: detected {_fw_label}. "
5475
- f"These endpoints are NOT modeled here; the count covers Spring-MVC-annotated "
5476
- f"endpoints only."
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. Use --output to inspect full IR."
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 (type, kind, client). Targets are attributed by
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
- return sorted(out, key=lambda i: (i.kind, i.client, i.evidence))
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" and fw.version:
469
- return fw.version
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