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 CHANGED
@@ -1,3 +1,3 @@
1
1
  """sourcecode — Deterministic codebase context maps for AI coding agents."""
2
2
 
3
- __version__ = "1.70.0"
3
+ __version__ = "1.72.0"
@@ -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",
@@ -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: subsystems with high member count
5768
- tangle_modules = sorted(
5769
- [s for s in subsystems if len(s.get("members") or []) >= 5],
5770
- key=lambda s: -len(s.get("members") or []),
5771
- )[:10]
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
- "label": s.get("label") or s.get("name") or "",
5859
- "class_count": _class_count(s.get("members") or []),
5860
- "member_count": len(s.get("members") or []),
5861
- }
5862
- for s in tangle_modules
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
  (
@@ -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
- text = ElementTree.tostring(root_elem, encoding="unicode").lower()
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 "spring-boot-starter-security" in text or "spring-security-core" in text:
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-degree ({in_degree})")
307
+ signals.append(f"high fan-in ({in_degree} distinct caller classes)")
303
308
  elif in_degree:
304
- signals.append(f"in-degree {in_degree}")
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, "
@@ -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=rule.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
- if not self.spring_present:
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": self.spring_present,
1446
- "score": self.boot3_readiness if self.spring_present else None,
1447
- "reason": ("Spring Boot 2→3 / Security 6 migration"
1448
- if self.spring_present
1449
- else ("N/A — Spring present only as a TEST dependency "
1450
- "(spring-test); no runtime Spring"
1451
- if self.spring_test_only
1452
- else "N/A — no Spring usage detected (non-Spring stack)")),
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 or autogenerated sources. "
1546
- "Excluded from blocking_count, readiness, and effort."),
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