sourcecode 1.70.0__py3-none-any.whl → 1.72.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/architecture_summary.py +27 -0
- sourcecode/canonical_ir.py +14 -0
- sourcecode/cli.py +77 -13
- sourcecode/detectors/java.py +40 -2
- sourcecode/explain.py +7 -2
- sourcecode/integration_detector.py +57 -0
- sourcecode/migrate_check.py +170 -12
- sourcecode/repository_ir.py +343 -10
- sourcecode/spring_security_audit.py +11 -0
- sourcecode/spring_tx_analyzer.py +70 -9
- {sourcecode-1.70.0.dist-info → sourcecode-1.72.0.dist-info}/METADATA +16 -5
- {sourcecode-1.70.0.dist-info → sourcecode-1.72.0.dist-info}/RECORD +16 -16
- {sourcecode-1.70.0.dist-info → sourcecode-1.72.0.dist-info}/WHEEL +0 -0
- {sourcecode-1.70.0.dist-info → sourcecode-1.72.0.dist-info}/entry_points.txt +0 -0
- {sourcecode-1.70.0.dist-info → sourcecode-1.72.0.dist-info}/licenses/LICENSE +0 -0
sourcecode/__init__.py
CHANGED
|
@@ -142,6 +142,7 @@ class ArchitectureSummarizer:
|
|
|
142
142
|
# Line 2: architecture pattern + layers
|
|
143
143
|
if arch and arch.pattern not in (None, "unknown", "flat"):
|
|
144
144
|
arch_line = self._describe_arch_pattern(arch)
|
|
145
|
+
arch_line = self._qualify_web_pattern(arch, arch_line, sm)
|
|
145
146
|
if arch_line:
|
|
146
147
|
lines.append(arch_line)
|
|
147
148
|
|
|
@@ -227,6 +228,32 @@ class ArchitectureSummarizer:
|
|
|
227
228
|
self._endpoint_support_cache = (total, high)
|
|
228
229
|
return self._endpoint_support_cache
|
|
229
230
|
|
|
231
|
+
def _qualify_web_pattern(self, arch: Any, arch_line: str, sm: SourceMap) -> str:
|
|
232
|
+
"""BUG #1 (Jenkins field test): the "mvc" pattern is a directory-name
|
|
233
|
+
heuristic (dirs matching controller/model/view keywords). On a Java/Kotlin
|
|
234
|
+
repo, do not assert "MVC pattern with ... view layers" in prose when the
|
|
235
|
+
canonical endpoint extractor finds no HTTP controllers in the same run —
|
|
236
|
+
Jenkins (Stapler web framework) matches model/view-ish dirs but declares
|
|
237
|
+
zero Spring MVC controllers. Cross-check against `endpoints` and degrade to
|
|
238
|
+
a consistent, non-committal phrasing instead of a categorical MVC claim."""
|
|
239
|
+
if not arch_line or arch.pattern not in ("mvc", "spring_mvc_layered"):
|
|
240
|
+
return arch_line
|
|
241
|
+
primary = next((s for s in sm.stacks if s.primary), sm.stacks[0] if sm.stacks else None)
|
|
242
|
+
if primary is None or primary.stack not in {"java", "kotlin"}:
|
|
243
|
+
return arch_line
|
|
244
|
+
_total, high = self._endpoint_support()
|
|
245
|
+
if high >= _MIN_REST_ENDPOINTS_FOR_LABEL:
|
|
246
|
+
return arch_line
|
|
247
|
+
# No controller backing → the MVC/view claim is unverified. Report the
|
|
248
|
+
# directory-derived layers without the framework label or the "view" claim.
|
|
249
|
+
layer_names = [l.name for l in arch.layers[:4] if l.name != "view"] if arch.layers else []
|
|
250
|
+
if layer_names:
|
|
251
|
+
return (
|
|
252
|
+
f"Layered code organization ({', '.join(layer_names)}; "
|
|
253
|
+
f"directory-based — no HTTP controllers detected)."
|
|
254
|
+
)
|
|
255
|
+
return ""
|
|
256
|
+
|
|
230
257
|
def _describe_arch_pattern(self, arch: Any) -> str:
|
|
231
258
|
pattern_labels = {
|
|
232
259
|
"clean": "Clean Architecture",
|
sourcecode/canonical_ir.py
CHANGED
|
@@ -16,6 +16,7 @@ from __future__ import annotations
|
|
|
16
16
|
|
|
17
17
|
import hashlib
|
|
18
18
|
import json
|
|
19
|
+
import re
|
|
19
20
|
from dataclasses import dataclass, field
|
|
20
21
|
from pathlib import Path
|
|
21
22
|
from typing import Any, Optional
|
|
@@ -37,6 +38,11 @@ IR_SCHEMA_VERSION = "1.0.0"
|
|
|
37
38
|
# Edge types excluded from reverse graph (mirrors repository_ir._REVERSE_EXCLUDE)
|
|
38
39
|
_REVERSE_EXCLUDE: frozenset[str] = frozenset({"annotated_with", "mapped_to"})
|
|
39
40
|
|
|
41
|
+
# A route path that looks like a Java FQN is framework dynamic-admin routing, not a
|
|
42
|
+
# real REST endpoint (mirrors the filter in repository_ir.extract_java_endpoints so
|
|
43
|
+
# the CIR endpoint count reconciles with the `endpoints` command). See BUG #7.
|
|
44
|
+
_FQN_PATH_RE: re.Pattern = re.compile(r"/(org|com|net|io|edu)\.[a-z][a-z0-9]*\.[a-zA-Z]")
|
|
45
|
+
|
|
40
46
|
|
|
41
47
|
# ---------------------------------------------------------------------------
|
|
42
48
|
# CanonicalSecurity
|
|
@@ -324,9 +330,17 @@ def ir_dict_to_canonical(
|
|
|
324
330
|
# Build canonical endpoints from route_surface — stable ordering
|
|
325
331
|
route_surface = ir.get("route_surface") or []
|
|
326
332
|
# Deduplicate by endpoint id (route_surface can have duplicates from multi-prefix)
|
|
333
|
+
# BUG #7 (Broadleaf field test): the `endpoints` command (extract_java_endpoints)
|
|
334
|
+
# drops routes whose path looks like a Java FQN — framework dynamic-admin routing
|
|
335
|
+
# (Broadleaf @AdminSection registers entity class FQNs as URL segments) is not a
|
|
336
|
+
# real REST surface. The CIR endpoint list feeds `spring-audit` (endpoints_analyzed),
|
|
337
|
+
# `impact` and `validation`; it must apply the SAME filter so its count reconciles
|
|
338
|
+
# with `endpoints` instead of being silently inflated by those pseudo-routes.
|
|
327
339
|
_seen_ids: set[str] = set()
|
|
328
340
|
raw_endpoints: list[CanonicalEndpoint] = []
|
|
329
341
|
for r in route_surface:
|
|
342
|
+
if _FQN_PATH_RE.search(r.get("path", "") or ""):
|
|
343
|
+
continue
|
|
330
344
|
ep = _route_to_canonical_endpoint(r)
|
|
331
345
|
if ep.id not in _seen_ids:
|
|
332
346
|
_seen_ids.add(ep.id)
|
sourcecode/cli.py
CHANGED
|
@@ -5764,11 +5764,63 @@ def modernize_cmd(
|
|
|
5764
5764
|
key=lambda h: (-h["hotspot_score"], h["fqn"]),
|
|
5765
5765
|
)[:15]
|
|
5766
5766
|
|
|
5767
|
-
# Cross-module tangles:
|
|
5768
|
-
|
|
5769
|
-
|
|
5770
|
-
|
|
5771
|
-
)
|
|
5767
|
+
# Cross-module tangles: actual inter-subsystem coupling measured from the
|
|
5768
|
+
# dependency graph, NOT a re-labelled subsystem list. For every structural
|
|
5769
|
+
# edge whose endpoints live in two different subsystems we tally a directed
|
|
5770
|
+
# (from_subsystem → to_subsystem) coupling count; pairs coupled in BOTH
|
|
5771
|
+
# directions are the real tangles (bidirectional/cyclic module coupling).
|
|
5772
|
+
_graph_edges: list = (ir.get("graph") or {}).get("edges") or []
|
|
5773
|
+
# Structural coupling edge types. annotated_with / mapped_to / event edges are
|
|
5774
|
+
# excluded (metadata + separate topology, not module-decomposition coupling).
|
|
5775
|
+
_TANGLE_EDGE_TYPES = frozenset({
|
|
5776
|
+
"imports", "injects", "extends", "implements",
|
|
5777
|
+
"references", "calls", "instantiates", "returns",
|
|
5778
|
+
})
|
|
5779
|
+
_fqn_to_pkg: dict[str, str] = {}
|
|
5780
|
+
_pkg_to_label: dict[str, str] = {}
|
|
5781
|
+
for _s in subsystems:
|
|
5782
|
+
_pp = _s.get("package_prefix") or _s.get("pkg") or ""
|
|
5783
|
+
if not _pp:
|
|
5784
|
+
continue
|
|
5785
|
+
_pkg_to_label[_pp] = _s.get("label") or _s.get("name") or _pp
|
|
5786
|
+
for _m in (_s.get("members") or []):
|
|
5787
|
+
_fqn_to_pkg[_m] = _pp
|
|
5788
|
+
|
|
5789
|
+
from collections import Counter as _Counter
|
|
5790
|
+
_pair_counts: "_Counter[tuple[str, str]]" = _Counter()
|
|
5791
|
+
_pair_example: dict[tuple[str, str], str] = {}
|
|
5792
|
+
for _e in _graph_edges:
|
|
5793
|
+
if _e.get("type") not in _TANGLE_EDGE_TYPES:
|
|
5794
|
+
continue
|
|
5795
|
+
_a = _fqn_to_pkg.get(_e.get("from"))
|
|
5796
|
+
_b = _fqn_to_pkg.get(_e.get("to"))
|
|
5797
|
+
if not _a or not _b or _a == _b:
|
|
5798
|
+
continue
|
|
5799
|
+
_pair_counts[(_a, _b)] += 1
|
|
5800
|
+
_pair_example.setdefault((_a, _b), f"{_e.get('from')} → {_e.get('to')}")
|
|
5801
|
+
|
|
5802
|
+
_cross_module_tangles = []
|
|
5803
|
+
for (_a, _b), _cnt in sorted(
|
|
5804
|
+
_pair_counts.items(), key=lambda kv: (-kv[1], kv[0])
|
|
5805
|
+
):
|
|
5806
|
+
_reverse = _pair_counts.get((_b, _a), 0)
|
|
5807
|
+
_cross_module_tangles.append({
|
|
5808
|
+
"from_subsystem": _pkg_to_label.get(_a, _a),
|
|
5809
|
+
"to_subsystem": _pkg_to_label.get(_b, _b),
|
|
5810
|
+
"from_package": _a,
|
|
5811
|
+
"to_package": _b,
|
|
5812
|
+
"edge_count": _cnt,
|
|
5813
|
+
"reverse_edge_count": _reverse,
|
|
5814
|
+
# A mutual (cyclic) dependency is the actual "tangle" — both modules
|
|
5815
|
+
# reference each other, so neither can be extracted independently.
|
|
5816
|
+
"mutual": _reverse > 0,
|
|
5817
|
+
"example": _pair_example.get((_a, _b), ""),
|
|
5818
|
+
})
|
|
5819
|
+
# Surface mutual tangles first (highest decomposition risk), then by strength.
|
|
5820
|
+
_cross_module_tangles.sort(
|
|
5821
|
+
key=lambda t: (not t["mutual"], -t["edge_count"], t["from_package"])
|
|
5822
|
+
)
|
|
5823
|
+
_cross_module_tangles = _cross_module_tangles[:15]
|
|
5772
5824
|
|
|
5773
5825
|
_summary = {
|
|
5774
5826
|
"total_classes": len([n for n in graph_nodes if n.get("type") in ("class", "interface")]),
|
|
@@ -5837,6 +5889,17 @@ def modernize_cmd(
|
|
|
5837
5889
|
{"fqn": n["fqn"], "in_degree": n.get("in_degree", 0), "role": n.get("role", "other")}
|
|
5838
5890
|
for n in coupling_nodes
|
|
5839
5891
|
],
|
|
5892
|
+
# BUG #6 (Broadleaf field test): make the in_degree metric self-describing
|
|
5893
|
+
# so it is not confused with `explain`'s caller count. They differ by design.
|
|
5894
|
+
"high_coupling_nodes_note": (
|
|
5895
|
+
"in_degree = raw count of incoming graph edges across ALL edge types "
|
|
5896
|
+
"(imports, injects, extends/implements, references, annotations), "
|
|
5897
|
+
"counted at symbol level. This is deliberately larger than and NOT "
|
|
5898
|
+
"equal to `sourcecode explain`'s caller count, which reports DISTINCT "
|
|
5899
|
+
"dependent classes (DI dependents + reverse-call-graph callers, "
|
|
5900
|
+
"deduplicated to class level). Use in_degree for blast-radius ranking, "
|
|
5901
|
+
"explain's caller list for the concrete dependents to inspect."
|
|
5902
|
+
),
|
|
5840
5903
|
"statically_unreferenced": [
|
|
5841
5904
|
{"fqn": n["fqn"], "type": n.get("type", ""), "role": n.get("role", "other")}
|
|
5842
5905
|
for n in dead_zones
|
|
@@ -5853,14 +5916,15 @@ def modernize_cmd(
|
|
|
5853
5916
|
],
|
|
5854
5917
|
"subsystem_summary": _subsystem_summary,
|
|
5855
5918
|
"subsystem_summary_note": _subsystem_summary_note,
|
|
5856
|
-
"cross_module_tangles":
|
|
5857
|
-
|
|
5858
|
-
|
|
5859
|
-
|
|
5860
|
-
|
|
5861
|
-
|
|
5862
|
-
|
|
5863
|
-
|
|
5919
|
+
"cross_module_tangles": _cross_module_tangles,
|
|
5920
|
+
"cross_module_tangles_note": (
|
|
5921
|
+
"Directed inter-subsystem coupling measured from structural graph "
|
|
5922
|
+
"edges (imports/injects/extends/implements/references/calls). "
|
|
5923
|
+
"edge_count = edges from_package→to_package; mutual=true marks a "
|
|
5924
|
+
"bidirectional (cyclic) dependency — the actual tangle that blocks "
|
|
5925
|
+
"independent module extraction. Empty means no cross-subsystem "
|
|
5926
|
+
"structural coupling was detected."
|
|
5927
|
+
),
|
|
5864
5928
|
# BUG-05 fix: don't recommend "Start with hotspot_candidates" when the list is empty.
|
|
5865
5929
|
"recommendation": (
|
|
5866
5930
|
(
|
sourcecode/detectors/java.py
CHANGED
|
@@ -284,10 +284,43 @@ class JavaDetector(AbstractDetector):
|
|
|
284
284
|
if parent_artifact == "spring-boot-starter-parent":
|
|
285
285
|
sb_version = (parent_elem.findtext(f"{ns}version") or "").strip() or None
|
|
286
286
|
|
|
287
|
-
|
|
287
|
+
# BUG #1 (Jenkins field test): framework tokens must be matched only inside
|
|
288
|
+
# genuine dependency / plugin / parent coordinates, never anywhere in the
|
|
289
|
+
# serialized pom. Jenkins' war/pom.xml lists `<exclude>...:spring-web</exclude>`
|
|
290
|
+
# and `<exclude>...:spring-aop</exclude>` in an enforcer-plugin bytecode rule
|
|
291
|
+
# — those are exclusions of transitive artifacts, not declared dependencies.
|
|
292
|
+
# Serializing the whole tree let them read as "uses Spring MVC / Spring AOP".
|
|
293
|
+
# Restrict the scan to real dependency/plugin/parent artifact coordinates;
|
|
294
|
+
# `<exclusion>` and `<exclude>` elements carry neither.
|
|
295
|
+
text = self._pom_coordinate_text(root_elem, ns)
|
|
288
296
|
frameworks = self._detect_jvm_frameworks(text, "pom.xml", sb_version=sb_version)
|
|
289
297
|
return frameworks
|
|
290
298
|
|
|
299
|
+
def _pom_coordinate_text(self, root_elem: ElementTree.Element, ns: str) -> str:
|
|
300
|
+
"""Collect groupId:artifactId coordinates from real dependency, plugin, and
|
|
301
|
+
parent declarations only. Skips `<exclusion>`/`<exclude>` (transitive-artifact
|
|
302
|
+
removals) and comments, which must never be read as usage signal."""
|
|
303
|
+
coords: list[str] = []
|
|
304
|
+
|
|
305
|
+
def _coord(elem: ElementTree.Element) -> None:
|
|
306
|
+
gid = (elem.findtext(f"{ns}groupId") or "").strip()
|
|
307
|
+
aid = (elem.findtext(f"{ns}artifactId") or "").strip()
|
|
308
|
+
if aid:
|
|
309
|
+
coords.append(f"{gid}:{aid}" if gid else aid)
|
|
310
|
+
|
|
311
|
+
# <dependency> nodes anywhere (dependencies, dependencyManagement, profiles).
|
|
312
|
+
# ElementTree never yields <dependency> under <exclusions> (those are
|
|
313
|
+
# <exclusion>), so exclusion coordinates are structurally excluded.
|
|
314
|
+
for dep in root_elem.iter(f"{ns}dependency"):
|
|
315
|
+
_coord(dep)
|
|
316
|
+
for plugin in root_elem.iter(f"{ns}plugin"):
|
|
317
|
+
_coord(plugin)
|
|
318
|
+
parent_elem = root_elem.find(f"{ns}parent")
|
|
319
|
+
if parent_elem is not None:
|
|
320
|
+
_coord(parent_elem)
|
|
321
|
+
|
|
322
|
+
return "\n".join(coords).lower()
|
|
323
|
+
|
|
291
324
|
def _frameworks_from_gradle(self, path: Path) -> list[FrameworkDetection]:
|
|
292
325
|
original = "\n".join(read_text_lines(path))
|
|
293
326
|
content = original.lower()
|
|
@@ -361,7 +394,12 @@ class JavaDetector(AbstractDetector):
|
|
|
361
394
|
frameworks.append(FrameworkDetection(name="Thymeleaf", source=source))
|
|
362
395
|
if "freemarker" in text:
|
|
363
396
|
frameworks.append(FrameworkDetection(name="FreeMarker", source=source))
|
|
364
|
-
if
|
|
397
|
+
if (
|
|
398
|
+
"spring-boot-starter-security" in text
|
|
399
|
+
or "spring-security-core" in text
|
|
400
|
+
or "spring-security-web" in text
|
|
401
|
+
or "spring-security-config" in text
|
|
402
|
+
):
|
|
365
403
|
frameworks.append(FrameworkDetection(name="Spring Security", source=source))
|
|
366
404
|
if "spring-boot-starter-data-jpa" in text or "spring-data-jpa" in text:
|
|
367
405
|
frameworks.append(FrameworkDetection(name="Spring Data JPA", source=source))
|
sourcecode/explain.py
CHANGED
|
@@ -298,10 +298,15 @@ def _structural_purpose(
|
|
|
298
298
|
head = f"Likely {role} (no DI annotations found)" if role else \
|
|
299
299
|
"Likely a structurally significant class (no DI annotations found)"
|
|
300
300
|
signals: list[str] = []
|
|
301
|
+
# BUG #6 (Broadleaf field test): this counts DISTINCT dependent classes (DI
|
|
302
|
+
# dependents + reverse-call-graph callers, deduped to class level), which is a
|
|
303
|
+
# different metric from modernize's node.in_degree (raw incoming-edge count
|
|
304
|
+
# across ALL edge types, symbol-level). Name it explicitly so the two numbers
|
|
305
|
+
# are not read as a contradiction when cross-referenced.
|
|
301
306
|
if in_degree >= 3:
|
|
302
|
-
signals.append(f"high in
|
|
307
|
+
signals.append(f"high fan-in ({in_degree} distinct caller classes)")
|
|
303
308
|
elif in_degree:
|
|
304
|
-
signals.append(f"
|
|
309
|
+
signals.append(f"{in_degree} distinct caller class{'es' if in_degree != 1 else ''}")
|
|
305
310
|
if lifecycle:
|
|
306
311
|
signals.append(f"lifecycle methods detected ({'/'.join(lifecycle)})")
|
|
307
312
|
suffix = f" — {', '.join(signals)}" if signals else ""
|
|
@@ -58,6 +58,13 @@ _ATTR_URL_RE = re.compile(r'url\s*=\s*"([^"]*)"')
|
|
|
58
58
|
_ATTR_NAME_RE = re.compile(r'(?:name|value)\s*=\s*"([^"]*)"')
|
|
59
59
|
_FIRST_LITERAL_RE = re.compile(r'^\s*"([^"]*)"')
|
|
60
60
|
|
|
61
|
+
# BUG #5 (Jenkins field test): thresholds for the structured coverage_confidence
|
|
62
|
+
# signal. A repo at/above _LARGE_REPO_FILE_THRESHOLD source files with fewer than
|
|
63
|
+
# _LOW_COVERAGE_COUNT recognized integration constructs is flagged "low" coverage —
|
|
64
|
+
# the count almost certainly under-represents custom-protocol/SPI integrations.
|
|
65
|
+
_LARGE_REPO_FILE_THRESHOLD: int = 300
|
|
66
|
+
_LOW_COVERAGE_COUNT: int = 10
|
|
67
|
+
|
|
61
68
|
# token -> (kind, client). Matched as whole-word usage outside imports/comments.
|
|
62
69
|
# Covers Spring AND plain-Java/Jakarta stacks (Quarkus, Micronaut, Keycloak SPI):
|
|
63
70
|
# the detector must not be Spring-centric, or a non-Spring repo with real LDAP /
|
|
@@ -76,6 +83,17 @@ _TOKEN_CLIENTS: "tuple[tuple[str, str, str], ...]" = (
|
|
|
76
83
|
("InitialLdapContext", "ldap", "jndi-ldap"),
|
|
77
84
|
("InitialDirContext", "ldap", "jndi-ldap"),
|
|
78
85
|
("LdapContext", "ldap", "jndi-ldap"),
|
|
86
|
+
# Spring Security LDAP authentication (BUG #5, Broadleaf field test): the
|
|
87
|
+
# idiomatic auth integration is a UserDetails mapper that extends
|
|
88
|
+
# LdapUserDetailsMapper and consumes DirContextOperations — neither uses
|
|
89
|
+
# LdapTemplate/ContextSource directly, so the connection-type tokens above
|
|
90
|
+
# miss it entirely. These types come from spring-security-ldap / spring-ldap
|
|
91
|
+
# and are unambiguous LDAP integration points.
|
|
92
|
+
("LdapUserDetailsMapper", "ldap", "spring-security-ldap"),
|
|
93
|
+
("DirContextOperations", "ldap", "spring-ldap"),
|
|
94
|
+
("LdapContextSource", "ldap", "spring-ldap"),
|
|
95
|
+
("DefaultSpringSecurityContextSource", "ldap", "spring-security-ldap"),
|
|
96
|
+
("BindAuthenticator", "ldap", "spring-security-ldap"),
|
|
79
97
|
# Mail / SMTP (JavaMail / Jakarta Mail)
|
|
80
98
|
("JavaMailSender", "smtp", "spring-mail"),
|
|
81
99
|
("MimeMessage", "smtp", "javamail"),
|
|
@@ -316,11 +334,50 @@ def detect_integrations(file_paths: "list[str]", root: Path) -> dict:
|
|
|
316
334
|
# clients (DI, config-driven endpoints, JCA connectors) are invisible to static
|
|
317
335
|
# text matching. Report that explicitly instead of an authoritative "0".
|
|
318
336
|
confidence = "observed" if records else "not_analyzed"
|
|
337
|
+
|
|
338
|
+
# BUG #5 (Jenkins field test): the coverage caveat lived only inside the prose
|
|
339
|
+
# `coverage_note`. Propagate it as a STRUCTURED signal so an automated consumer
|
|
340
|
+
# does not read a low `count` as "low external coupling". A large repo with few
|
|
341
|
+
# recognized constructs (Jenkins: custom remoting/SCM/Update-Center SPIs, not
|
|
342
|
+
# RestTemplate/WebClient) is under-counted, not loosely coupled.
|
|
343
|
+
_repo_size = len(file_paths)
|
|
344
|
+
_large_repo = _repo_size >= _LARGE_REPO_FILE_THRESHOLD
|
|
345
|
+
if not records:
|
|
346
|
+
coverage_confidence = "low"
|
|
347
|
+
_cov_reason = (
|
|
348
|
+
"no recognized client-library construct found; custom protocol/SPI-based "
|
|
349
|
+
"or DI-wired integrations are not statically detectable by this analyzer. "
|
|
350
|
+
"A count of 0 does not imply the system has no outbound integrations."
|
|
351
|
+
)
|
|
352
|
+
elif _large_repo and len(records) < _LOW_COVERAGE_COUNT:
|
|
353
|
+
coverage_confidence = "low"
|
|
354
|
+
_cov_reason = (
|
|
355
|
+
f"only {len(records)} recognized construct(s) across {_repo_size} source "
|
|
356
|
+
"files; count reflects only recognized client-library patterns "
|
|
357
|
+
"(RestTemplate/WebClient/JDK/Apache/OkHttp/LDAP/JMS/…). Custom protocol/"
|
|
358
|
+
"SPI-based integrations are likely under-counted — do not read a low count "
|
|
359
|
+
"as low external coupling."
|
|
360
|
+
)
|
|
361
|
+
elif len(records) < _LOW_COVERAGE_COUNT:
|
|
362
|
+
coverage_confidence = "partial"
|
|
363
|
+
_cov_reason = (
|
|
364
|
+
"recognized client-library constructs detected; custom protocol/SPI-based "
|
|
365
|
+
"or DI-wired integrations, if any, are not statically visible."
|
|
366
|
+
)
|
|
367
|
+
else:
|
|
368
|
+
coverage_confidence = "high"
|
|
369
|
+
_cov_reason = (
|
|
370
|
+
"recognized client-library constructs cover the observable integration "
|
|
371
|
+
"surface; runtime/DI-wired clients remain out of static scope."
|
|
372
|
+
)
|
|
373
|
+
|
|
319
374
|
return {
|
|
320
375
|
"integrations": records,
|
|
321
376
|
"by_kind": {k: by_kind[k] for k in sorted(by_kind)},
|
|
322
377
|
"count": len(records),
|
|
323
378
|
"confidence": confidence,
|
|
379
|
+
"coverage_confidence": coverage_confidence,
|
|
380
|
+
"coverage_confidence_reason": _cov_reason,
|
|
324
381
|
"coverage_note": (
|
|
325
382
|
"Detects HTTP (RestTemplate/WebClient/JDK/Apache/OkHttp), LDAP (Spring "
|
|
326
383
|
"+ JNDI), DNS (JNDI DirContext w/ DnsContextFactory), SMTP (JavaMail, "
|
sourcecode/migrate_check.py
CHANGED
|
@@ -645,6 +645,18 @@ def _classify_code_context(finding: "MigrationFinding") -> str:
|
|
|
645
645
|
if is_test_or_fixture_path(path):
|
|
646
646
|
return "test"
|
|
647
647
|
norm = path.replace("\\", "/").lower()
|
|
648
|
+
# BUG #4 (Broadleaf field test): a Spring XML config file can live under
|
|
649
|
+
# src/main/resources yet be test-only scaffolding loaded exclusively by
|
|
650
|
+
# test @ContextConfiguration (e.g. bl-applicationContext-test-security.xml).
|
|
651
|
+
# is_test_or_fixture_path deliberately ignores the filename, so a `test`
|
|
652
|
+
# marker in the config file's own name is not seen. For XML config, a `test`
|
|
653
|
+
# token in the base filename is a strong, low-FP test-scope signal —
|
|
654
|
+
# production context files are not named `-test-`. Keep it out of
|
|
655
|
+
# blocking_count so test scaffolding never inflates the headline blocker.
|
|
656
|
+
if norm.endswith(".xml"):
|
|
657
|
+
base = norm.rsplit("/", 1)[-1]
|
|
658
|
+
if re.search(r"(?:^|[-_.])test(?:[-_.]|$)", base):
|
|
659
|
+
return "test"
|
|
648
660
|
if any(frag in norm for frag in _GENERATED_PATH_FRAGMENTS):
|
|
649
661
|
return "generated"
|
|
650
662
|
# javax.annotation.Generated is the marker emitted into autogenerated code.
|
|
@@ -652,6 +664,13 @@ def _classify_code_context(finding: "MigrationFinding") -> str:
|
|
|
652
664
|
imp.rsplit(".", 1)[-1] == "Generated" for imp in finding.imports_found
|
|
653
665
|
):
|
|
654
666
|
return "generated"
|
|
667
|
+
# BUG #3 (Jenkins field test): a namespace-migration finding (javax→jakarta,
|
|
668
|
+
# e.g. javax.servlet) on a class-level @Deprecated type is a frozen-legacy
|
|
669
|
+
# compatibility shim — kept for binary compat, with a live replacement elsewhere
|
|
670
|
+
# (Jenkins ChainedServletFilter → ChainedServletFilter2). Not an active blocker:
|
|
671
|
+
# segregate it out of blocking_count like test/generated, with its own label.
|
|
672
|
+
if finding.enclosing_deprecated and finding.migration_target in _JAKARTA_TARGETS:
|
|
673
|
+
return "deprecated_shim"
|
|
655
674
|
return "main"
|
|
656
675
|
|
|
657
676
|
|
|
@@ -929,6 +948,42 @@ def _find_non_java_files(
|
|
|
929
948
|
return xml_files, build_files
|
|
930
949
|
|
|
931
950
|
|
|
951
|
+
def _xml_explanation_for(rule: "_XmlRule", matches: list) -> str:
|
|
952
|
+
"""Return an explanation reflecting the sub-pattern that actually matched.
|
|
953
|
+
|
|
954
|
+
BUG #4 (Broadleaf field test): MIG-031's pattern is an OR of two independent
|
|
955
|
+
triggers — old-style `<http auto-config="true">` and a versioned legacy schema
|
|
956
|
+
(spring-security-[2-5].x.xsd). The static template asserted BOTH, so a file
|
|
957
|
+
that only used auto-config (its schema line already being the unversioned
|
|
958
|
+
spring-security.xsd) got a factually wrong explanation claiming a versioned
|
|
959
|
+
schema. Emit only the condition(s) present in this file.
|
|
960
|
+
"""
|
|
961
|
+
if rule.id != "MIG-031":
|
|
962
|
+
return rule.explanation
|
|
963
|
+
matched_text = " ".join(m.group(0).lower() for m in matches)
|
|
964
|
+
has_autoconfig = "auto-config" in matched_text
|
|
965
|
+
has_versioned_schema = bool(re.search(r"spring-security-[2345]\.", matched_text))
|
|
966
|
+
triggers: list[str] = []
|
|
967
|
+
if has_autoconfig:
|
|
968
|
+
triggers.append(
|
|
969
|
+
"uses the old-style <http auto-config='true'> shortcut, which was "
|
|
970
|
+
"removed in Spring Security 6"
|
|
971
|
+
)
|
|
972
|
+
if has_versioned_schema:
|
|
973
|
+
triggers.append(
|
|
974
|
+
"references a versioned legacy schema (spring-security-[2-5].x.xsd) "
|
|
975
|
+
"whose namespace attributes changed in Spring Security 6"
|
|
976
|
+
)
|
|
977
|
+
if not triggers: # defensive — pattern matched but neither branch classified
|
|
978
|
+
return rule.explanation
|
|
979
|
+
return (
|
|
980
|
+
"XML-based Spring Security configuration in this file "
|
|
981
|
+
+ " and ".join(triggers)
|
|
982
|
+
+ ". Spring Security 6 (Spring Boot 3) requires migrating this to "
|
|
983
|
+
"Java-based @Configuration with a SecurityFilterChain @Bean."
|
|
984
|
+
)
|
|
985
|
+
|
|
986
|
+
|
|
932
987
|
def _scan_xml_file(text: str, rel_path: str) -> list["MigrationFinding"]:
|
|
933
988
|
"""Apply XML rules to raw XML text. Returns one finding per matched rule."""
|
|
934
989
|
findings: list[MigrationFinding] = []
|
|
@@ -940,6 +995,7 @@ def _scan_xml_file(text: str, rel_path: str) -> list["MigrationFinding"]:
|
|
|
940
995
|
continue
|
|
941
996
|
first_line = text[: matches[0].start()].count("\n") + 1
|
|
942
997
|
snippets = [m.group(0)[:120].strip() for m in matches[:5]]
|
|
998
|
+
explanation = _xml_explanation_for(rule, matches)
|
|
943
999
|
findings.append(
|
|
944
1000
|
MigrationFinding(
|
|
945
1001
|
id=MigrationFinding.make_id(rule.id, rel_path),
|
|
@@ -949,7 +1005,7 @@ def _scan_xml_file(text: str, rel_path: str) -> list["MigrationFinding"]:
|
|
|
949
1005
|
source_file=rel_path,
|
|
950
1006
|
first_line=first_line,
|
|
951
1007
|
imports_found=snippets,
|
|
952
|
-
explanation=
|
|
1008
|
+
explanation=explanation,
|
|
953
1009
|
fix_hint=rule.fix_hint,
|
|
954
1010
|
migration_target=rule.migration_target,
|
|
955
1011
|
openrewrite_recipe=rule.openrewrite_recipe,
|
|
@@ -1181,6 +1237,11 @@ class MigrationFinding:
|
|
|
1181
1237
|
# harness), or "generated" (autogenerated source). Only "main" counts toward
|
|
1182
1238
|
# blocking_count / readiness; test+generated are reported in a separate bucket.
|
|
1183
1239
|
code_context: str = "main"
|
|
1240
|
+
# BUG #3 (Jenkins field test): the enclosing top-level TYPE carries a class-level
|
|
1241
|
+
# @Deprecated annotation. A namespace-migration finding on such a class is a
|
|
1242
|
+
# frozen-legacy compatibility shim (kept for binary compat, replaced elsewhere),
|
|
1243
|
+
# not an active migration blocker — reclassified out of blocking_count.
|
|
1244
|
+
enclosing_deprecated: bool = False
|
|
1184
1245
|
|
|
1185
1246
|
@staticmethod
|
|
1186
1247
|
def make_id(rule_id: str, source_file: str) -> str:
|
|
@@ -1294,6 +1355,12 @@ class MigrationReport:
|
|
|
1294
1355
|
spring_boot_version_detected: Optional[str] = None
|
|
1295
1356
|
# BUG #2: whether Spring is used AT RUNTIME. The Boot3 dimension is N/A without it.
|
|
1296
1357
|
spring_present: bool = True
|
|
1358
|
+
# BUG #2 (Jenkins field test): whether the repo is a Spring BOOT application
|
|
1359
|
+
# (spring-boot* coordinate / @SpringBootApplication), as opposed to merely having
|
|
1360
|
+
# a Spring library (e.g. spring-security-web) on the classpath. The boot3 (Boot
|
|
1361
|
+
# 2→3) migration dimension is applicable ONLY to Boot repos — a non-Boot Spring
|
|
1362
|
+
# repo has no Boot upgrade axis and must not receive a contaminated readiness.
|
|
1363
|
+
spring_boot_present: bool = False
|
|
1297
1364
|
# BUG #1/#2: Spring appears ONLY as a test dependency (e.g. spring-test). Reported
|
|
1298
1365
|
# so a consumer can see WHY boot3 is N/A despite an org.springframework coordinate.
|
|
1299
1366
|
spring_test_only: bool = False
|
|
@@ -1366,7 +1433,19 @@ class MigrationReport:
|
|
|
1366
1433
|
self.jakarta_readiness = _dimension_score(main_findings, _JAKARTA_TARGETS)
|
|
1367
1434
|
self.boot3_readiness = _dimension_score(main_findings, _BOOT3_MIGRATION_TARGETS)
|
|
1368
1435
|
self.jdk_modernization = _dimension_score(main_findings, None)
|
|
1369
|
-
|
|
1436
|
+
# BUG #2 (Jenkins field test): the boot3 axis applies only with POSITIVE
|
|
1437
|
+
# evidence — a Spring BOOT repo (spring-boot* coordinate / @SpringBootApplication),
|
|
1438
|
+
# OR a concrete Boot-3 / Security-6 migration finding in main code. A repo that
|
|
1439
|
+
# merely carries a Spring library (spring-security-web) with no Boot and no
|
|
1440
|
+
# Boot/Security migration findings (Jenkins) has no boot3 axis — jakarta-shaped
|
|
1441
|
+
# findings must not masquerade as a boot3 readiness deficit. Note: jakarta
|
|
1442
|
+
# findings do NOT make boot3 applicable; only genuine boot3/security-6 targets do.
|
|
1443
|
+
_boot3_migration_findings = any(
|
|
1444
|
+
f.migration_target in (_BOOT3_MIGRATION_TARGETS - _JAKARTA_TARGETS)
|
|
1445
|
+
for f in main_findings
|
|
1446
|
+
)
|
|
1447
|
+
_boot3_applies = self.spring_boot_present or _boot3_migration_findings
|
|
1448
|
+
if not _boot3_applies:
|
|
1370
1449
|
self.boot3_readiness = 100
|
|
1371
1450
|
|
|
1372
1451
|
# BUG #3/#4: the jakarta dimension is applicable ONLY with POSITIVE evidence the
|
|
@@ -1442,14 +1521,23 @@ class MigrationReport:
|
|
|
1442
1521
|
else "N/A — no migratable javax.* imports detected"),
|
|
1443
1522
|
},
|
|
1444
1523
|
"boot3": {
|
|
1445
|
-
"applicable":
|
|
1446
|
-
"score": self.boot3_readiness if
|
|
1447
|
-
"reason": (
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1524
|
+
"applicable": _boot3_applies,
|
|
1525
|
+
"score": self.boot3_readiness if _boot3_applies else None,
|
|
1526
|
+
"reason": (
|
|
1527
|
+
"Spring Boot 2→3 / Security 6 migration"
|
|
1528
|
+
if self.spring_boot_present
|
|
1529
|
+
else "Spring Security 6 migration (spring_security_6 / spring_boot_3 "
|
|
1530
|
+
"findings present) — no Spring Boot detected"
|
|
1531
|
+
if _boot3_applies
|
|
1532
|
+
else "N/A — Spring present only as a TEST dependency (spring-test); "
|
|
1533
|
+
"no runtime Spring, no Spring Boot"
|
|
1534
|
+
if self.spring_test_only
|
|
1535
|
+
else "N/A — Spring library on the classpath but no spring-boot "
|
|
1536
|
+
"dependency / @SpringBootApplication and no Boot/Security-6 "
|
|
1537
|
+
"migration finding — not a Spring Boot application"
|
|
1538
|
+
if self.spring_present
|
|
1539
|
+
else "N/A — no Spring usage detected (non-Spring stack)"
|
|
1540
|
+
),
|
|
1453
1541
|
},
|
|
1454
1542
|
"jdk_modernization": {"applicable": True, "score": self.jdk_modernization,
|
|
1455
1543
|
"reason": "orthogonal JDK modernization debt (excluded from "
|
|
@@ -1542,8 +1630,11 @@ class MigrationReport:
|
|
|
1542
1630
|
"count": sum(_nb_by_ctx.values()),
|
|
1543
1631
|
"by_context": _nb_by_ctx,
|
|
1544
1632
|
"by_rule": _nb_by_rule,
|
|
1545
|
-
"note": ("Findings in test/fixture harnesses
|
|
1546
|
-
"
|
|
1633
|
+
"note": ("Findings in test/fixture harnesses, autogenerated sources, or "
|
|
1634
|
+
"class-level @Deprecated frozen-legacy shims (code_context="
|
|
1635
|
+
"\"deprecated_shim\" — kept for binary compat with a live "
|
|
1636
|
+
"replacement; verify before removing). Excluded from "
|
|
1637
|
+
"blocking_count, readiness, and effort."),
|
|
1547
1638
|
}
|
|
1548
1639
|
|
|
1549
1640
|
self.summary = {
|
|
@@ -1585,6 +1676,7 @@ class MigrationReport:
|
|
|
1585
1676
|
"hygiene_findings": self.hygiene_findings,
|
|
1586
1677
|
"non_blocking": self.non_blocking,
|
|
1587
1678
|
"spring_present": self.spring_present,
|
|
1679
|
+
"spring_boot_present": self.spring_boot_present,
|
|
1588
1680
|
"spring_test_only": self.spring_test_only,
|
|
1589
1681
|
"spring_boot_2_detected": self.spring_boot_2_detected,
|
|
1590
1682
|
"spring_boot_version_detected": self.spring_boot_version_detected,
|
|
@@ -1746,12 +1838,35 @@ def _neutralize_non_spring_explanation(text: str) -> str:
|
|
|
1746
1838
|
return text
|
|
1747
1839
|
|
|
1748
1840
|
|
|
1841
|
+
# BUG #3 (Jenkins field test): the primary top-level type declaration in a .java
|
|
1842
|
+
# file. Used to test whether a class-level @Deprecated annotation precedes it.
|
|
1843
|
+
_PRIMARY_TYPE_DECL_RE: re.Pattern = re.compile(
|
|
1844
|
+
r"(?m)^[ \t]*(?:public\s+|final\s+|abstract\s+|sealed\s+|non-sealed\s+|strictfp\s+)*"
|
|
1845
|
+
r"(?:class|interface|enum|record)\s+\w"
|
|
1846
|
+
)
|
|
1847
|
+
|
|
1848
|
+
|
|
1849
|
+
def _primary_type_is_deprecated(source: str) -> bool:
|
|
1850
|
+
"""True iff the file's primary top-level type carries a class-level @Deprecated
|
|
1851
|
+
annotation. Scoped to the region between the last import and the type keyword so
|
|
1852
|
+
a @Deprecated on a METHOD (below the class declaration) never counts — that is a
|
|
1853
|
+
live class with a deprecated member, not a frozen-legacy shim."""
|
|
1854
|
+
m = _PRIMARY_TYPE_DECL_RE.search(source)
|
|
1855
|
+
if not m:
|
|
1856
|
+
return False
|
|
1857
|
+
head = source[: m.start()]
|
|
1858
|
+
last_import = head.rfind("\nimport ")
|
|
1859
|
+
window = head[last_import:] if last_import != -1 else head
|
|
1860
|
+
return "@Deprecated" in window
|
|
1861
|
+
|
|
1862
|
+
|
|
1749
1863
|
def _scan_file(
|
|
1750
1864
|
source: str,
|
|
1751
1865
|
rel_path: str,
|
|
1752
1866
|
rules: list[_Rule],
|
|
1753
1867
|
) -> list[MigrationFinding]:
|
|
1754
1868
|
findings: list[MigrationFinding] = []
|
|
1869
|
+
type_deprecated = _primary_type_is_deprecated(source)
|
|
1755
1870
|
|
|
1756
1871
|
for rule in rules:
|
|
1757
1872
|
matched_imports: list[str] = []
|
|
@@ -1820,6 +1935,7 @@ def _scan_file(
|
|
|
1820
1935
|
fix_hint=rule.fix_hint,
|
|
1821
1936
|
migration_target=rule.migration_target,
|
|
1822
1937
|
openrewrite_recipe=rule.openrewrite_recipe,
|
|
1938
|
+
enclosing_deprecated=type_deprecated,
|
|
1823
1939
|
)
|
|
1824
1940
|
)
|
|
1825
1941
|
|
|
@@ -1941,6 +2057,7 @@ def run_migrate_check(
|
|
|
1941
2057
|
spring_present, spring_test_only = _detect_spring_usage(
|
|
1942
2058
|
root, spring_runtime_import_seen, spring_test_import_seen
|
|
1943
2059
|
)
|
|
2060
|
+
spring_boot_present = _detect_spring_boot_present(root)
|
|
1944
2061
|
|
|
1945
2062
|
# BUG #8: classify each finding's code context (main / test / generated) so the
|
|
1946
2063
|
# report can keep test fixtures and autogenerated sources out of blocking_count.
|
|
@@ -1965,6 +2082,7 @@ def run_migrate_check(
|
|
|
1965
2082
|
spring_boot_2_detected=spring_boot_2,
|
|
1966
2083
|
spring_boot_version_detected=spring_boot_version,
|
|
1967
2084
|
spring_present=spring_present,
|
|
2085
|
+
spring_boot_present=spring_boot_present,
|
|
1968
2086
|
spring_test_only=spring_test_only,
|
|
1969
2087
|
jakarta_namespace_adopted=jakarta_import_count > 0,
|
|
1970
2088
|
findings=all_findings,
|
|
@@ -2065,6 +2183,46 @@ def _extract_boot_versions(text: str) -> tuple[set[int], Optional[str]]:
|
|
|
2065
2183
|
return majors, full
|
|
2066
2184
|
|
|
2067
2185
|
|
|
2186
|
+
# BUG #2 (Jenkins field test): strip XML comments and exclusion/exclude blocks
|
|
2187
|
+
# before scanning for a spring-boot coordinate, so a `<exclude>...:spring-boot</…>`
|
|
2188
|
+
# or a comment never reads as "this repo uses Spring Boot" — the same exclusion
|
|
2189
|
+
# poison that produced phantom Spring MVC/AOP in framework detection.
|
|
2190
|
+
_XML_COMMENT_RE: re.Pattern = re.compile(r"<!--.*?-->", re.DOTALL)
|
|
2191
|
+
_XML_EXCLUSION_EL_RE: re.Pattern = re.compile(r"<exclusion\b.*?</exclusion\s*>", re.DOTALL | re.IGNORECASE)
|
|
2192
|
+
_XML_EXCLUDE_EL_RE: re.Pattern = re.compile(r"<exclude\b.*?</exclude\s*>", re.DOTALL | re.IGNORECASE)
|
|
2193
|
+
# A spring-boot* Maven/Gradle artifact coordinate (starter, BOM, dependency, plugin).
|
|
2194
|
+
_SPRING_BOOT_ARTIFACT_RE: re.Pattern = re.compile(r"\bspring-boot(?:-[a-z0-9.\-]+)?\b", re.IGNORECASE)
|
|
2195
|
+
|
|
2196
|
+
|
|
2197
|
+
def _strip_xml_exclusions(text: str) -> str:
|
|
2198
|
+
"""Remove XML comments and exclusion/exclude elements so their coordinates are
|
|
2199
|
+
not mistaken for declared dependencies."""
|
|
2200
|
+
text = _XML_COMMENT_RE.sub(" ", text)
|
|
2201
|
+
text = _XML_EXCLUSION_EL_RE.sub(" ", text)
|
|
2202
|
+
text = _XML_EXCLUDE_EL_RE.sub(" ", text)
|
|
2203
|
+
return text
|
|
2204
|
+
|
|
2205
|
+
|
|
2206
|
+
def _detect_spring_boot_present(root: Path) -> bool:
|
|
2207
|
+
"""True iff there is POSITIVE Spring Boot evidence — a spring-boot* build
|
|
2208
|
+
coordinate (parent / BOM / dependency / plugin) or the Spring Boot Gradle
|
|
2209
|
+
plugin. Distinguishes a Spring Boot application from a repo that merely has a
|
|
2210
|
+
Spring library on the classpath (e.g. Jenkins' spring-security-web). Gates the
|
|
2211
|
+
boot3 (Boot 2→3) migration dimension. @SpringBootApplication is not scanned
|
|
2212
|
+
separately: its annotation class ships in spring-boot-autoconfigure, so a Boot
|
|
2213
|
+
app always carries a spring-boot* coordinate."""
|
|
2214
|
+
for abs_path, _rel in _find_build_files(root):
|
|
2215
|
+
try:
|
|
2216
|
+
text = abs_path.read_text(encoding="utf-8", errors="replace")
|
|
2217
|
+
except OSError:
|
|
2218
|
+
continue
|
|
2219
|
+
if _SPRING_BOOT_PLUGIN_RE.search(text):
|
|
2220
|
+
return True
|
|
2221
|
+
if _SPRING_BOOT_ARTIFACT_RE.search(_strip_xml_exclusions(text)):
|
|
2222
|
+
return True
|
|
2223
|
+
return False
|
|
2224
|
+
|
|
2225
|
+
|
|
2068
2226
|
def _detect_spring_boot(root: Path, jakarta_import_count: int) -> tuple[Optional[bool], Optional[str]]:
|
|
2069
2227
|
"""Tri-state Spring Boot 2 detection. Returns (spring_boot_2_detected, version).
|
|
2070
2228
|
|