sourcecode 1.64.0__py3-none-any.whl → 1.66.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.64.0"
3
+ __version__ = "1.66.0"
@@ -253,6 +253,75 @@ def _scan_build_dependency(root: Path) -> tuple[bool, list[str]]:
253
253
  samples.append(snippet)
254
254
  return found, samples
255
255
 
256
+
257
+ # ── BUG #1: effective Hibernate version resolution ─────────────────────────
258
+ # A 5→6 rewrite verdict is only valid when the project is actually on Hibernate 5.
259
+ # Resolve the effective major version from build descriptors, following Maven
260
+ # ${properties} (incl. BOM-managed values declared as <hibernate*.version>) and
261
+ # explicit hibernate-core / hibernate-orm coordinates. When the resolved major is
262
+ # ≥ 6 the 5→6 axis does NOT apply (the migration is already done). When it cannot
263
+ # be resolved the axis degrades to a low-confidence hypothesis (no headline).
264
+ _HIB_VER = r"(\d+\.\d+(?:\.\d+)?(?:[.\-][\w]+)*)" # 6.2.13.Final, 5.6.15, 6.2
265
+ _HIB_PROP_RE = re.compile(
266
+ r"<((?:[\w.\-]*hibernate[\w.\-]*?)\.?version)>\s*" + _HIB_VER + r"\s*</\1>",
267
+ re.IGNORECASE,
268
+ )
269
+ _HIB_COORD_VERSION_RE = re.compile(
270
+ r"<artifactId>\s*hibernate-(?:core|orm)\s*</artifactId>\s*"
271
+ r"(?:<[^>]+>[^<]*</[^>]+>\s*)*?<version>\s*" + _HIB_VER,
272
+ re.IGNORECASE | re.DOTALL,
273
+ )
274
+ _HIB_GRADLE_VERSION_RE = re.compile(
275
+ r"org\.hibernate(?:\.orm)?:hibernate-(?:core|orm):" + _HIB_VER,
276
+ re.IGNORECASE,
277
+ )
278
+
279
+
280
+ def _ver_key(full: str) -> tuple[int, int]:
281
+ parts = full.split(".")
282
+ try:
283
+ major = int(parts[0])
284
+ except (ValueError, IndexError):
285
+ return (0, 0)
286
+ try:
287
+ minor = int(parts[1]) if len(parts) > 1 else 0
288
+ except ValueError:
289
+ minor = 0
290
+ return (major, minor)
291
+
292
+
293
+ def _resolve_hibernate_version(root: Path) -> tuple[Optional[str], Optional[int], str]:
294
+ """Return (full_version_string, major, confidence) for the effective Hibernate.
295
+
296
+ confidence is "high" when a version was resolved from a build descriptor,
297
+ "none" when none could be determined (degrade to hypothesis). The newest
298
+ declared Hibernate wins in a multi-module build.
299
+ """
300
+ import os
301
+ best_full: Optional[str] = None
302
+ for dirpath, dirnames, filenames in os.walk(root):
303
+ dirnames[:] = [d for d in dirnames if d not in _SKIP_BUILD_SCAN_DIRS]
304
+ for fname in filenames:
305
+ if fname not in _BUILD_FILE_NAMES:
306
+ continue
307
+ try:
308
+ text = (Path(dirpath) / fname).read_text(encoding="utf-8", errors="replace")
309
+ except OSError:
310
+ continue
311
+ fulls: list[str] = []
312
+ for m in _HIB_PROP_RE.finditer(text):
313
+ fulls.append(m.group(2))
314
+ for m in _HIB_COORD_VERSION_RE.finditer(text):
315
+ fulls.append(m.group(1))
316
+ for m in _HIB_GRADLE_VERSION_RE.finditer(text):
317
+ fulls.append(m.group(1))
318
+ for full in fulls:
319
+ if best_full is None or _ver_key(full) > _ver_key(best_full):
320
+ best_full = full
321
+ if best_full is None:
322
+ return None, None, "none"
323
+ return best_full, _ver_key(best_full)[0], "high"
324
+
256
325
  # Escalation markers — dynamic / reflection-based persistence construction.
257
326
  _ABSTRACTION_CLASS_RE = re.compile(
258
327
  r"\b(DynamicEntityDao|DynamicEntityDaoImpl|BasicPersistenceModule|"
@@ -406,12 +475,23 @@ class HibernateStratification:
406
475
  # when backed by a build dependency or a parsed org.hibernate import, "none"
407
476
  # when detection was vetoed for lack of evidence.
408
477
  evidence: dict = field(default_factory=dict)
478
+ # BUG #1: effective Hibernate version + whether the 5→6 axis applies at all.
479
+ # When the resolved major is ≥ 6 the migration is already done → not applicable
480
+ # (never a blocker). When unresolved, the axis is a low-confidence hypothesis.
481
+ effective_version: Optional[str] = None
482
+ version_major: Optional[int] = None
483
+ version_confidence: str = "none"
484
+ migration_applicable: bool = True
409
485
 
410
486
  def to_dict(self) -> dict:
411
487
  return {
412
488
  "schema_version": HIBERNATE_SCHEMA_VERSION,
413
489
  "detected": self.detected,
414
490
  "evidence": self.evidence,
491
+ "effective_version": self.effective_version,
492
+ "version_major": self.version_major,
493
+ "version_confidence": self.version_confidence,
494
+ "migration_applicable": self.migration_applicable,
415
495
  "classification": self.classification,
416
496
  "classification_label": self.classification_label,
417
497
  "stratified": True,
@@ -763,6 +843,7 @@ def analyze_hibernate(file_paths: list[str], root: Path) -> HibernateStratificat
763
843
  return strat
764
844
 
765
845
  dep_present, dep_samples = _scan_build_dependency(root)
846
+ eff_version, version_major, version_conf = _resolve_hibernate_version(root)
766
847
 
767
848
  has_evidence = dep_present or any_hibernate_import or any_jpa_import
768
849
  evidence: dict = {
@@ -771,6 +852,9 @@ def analyze_hibernate(file_paths: list[str], root: Path) -> HibernateStratificat
771
852
  "hibernate_import_present": any_hibernate_import,
772
853
  "hibernate_import_sample": hibernate_import_sample,
773
854
  "jpa_import_present": any_jpa_import,
855
+ "effective_version": eff_version,
856
+ "version_major": version_major,
857
+ "version_confidence": version_conf,
774
858
  "confidence": "high" if has_evidence else "none",
775
859
  }
776
860
 
@@ -959,6 +1043,12 @@ def analyze_hibernate(file_paths: list[str], root: Path) -> HibernateStratificat
959
1043
  strat = HibernateStratification()
960
1044
  strat.findings = findings
961
1045
  strat.evidence = evidence
1046
+ strat.effective_version = eff_version
1047
+ strat.version_major = version_major
1048
+ strat.version_confidence = version_conf
1049
+ # BUG #1: the 5→6 axis only applies when NOT already on Hibernate 6+. A
1050
+ # resolved major ≥ 6 means the migration is complete — never a blocker.
1051
+ strat.migration_applicable = not (version_major is not None and version_major >= 6)
962
1052
  strat.rewrite_targets = sorted(
963
1053
  targets, key=lambda t: (t.source_file, t.line_start, t.layer, t.id)
964
1054
  )
@@ -1126,13 +1216,23 @@ def analyze_hibernate(file_paths: list[str], root: Path) -> HibernateStratificat
1126
1216
  stops.append("SQL/HQL shape not statically inferable (string concatenation)")
1127
1217
  strat.stop_conditions_triggered = stops
1128
1218
 
1129
- if stops:
1219
+ if not strat.migration_applicable:
1220
+ # Already on Hibernate 6+ — the 5→6 stratification below is retained as an
1221
+ # informational exposure map, but it is NOT a migration and NOT a blocker.
1222
+ strat.classification = CLASS_NONE
1223
+ strat.classification_label = (
1224
+ f"ALREADY ON HIBERNATE {strat.version_major} "
1225
+ f"({strat.effective_version}) — NO 5→6 MIGRATION PENDING"
1226
+ )
1227
+ strat.stop_conditions_triggered = []
1228
+ elif stops:
1130
1229
  strat.classification = CLASS_REWRITE
1131
1230
  elif layer_files[LAYER_CRITERIA] or concat_query_global or jpa_deprecated_seen:
1132
1231
  strat.classification = CLASS_UPGRADE_CARE
1133
1232
  else:
1134
1233
  strat.classification = CLASS_UPGRADE
1135
- strat.classification_label = _CLASS_LABELS[strat.classification]
1234
+ if strat.migration_applicable:
1235
+ strat.classification_label = _CLASS_LABELS[strat.classification]
1136
1236
 
1137
1237
  # ── Risk separation: observable vs inferred ─────────────────────────────
1138
1238
  observable: list[str] = []
@@ -35,10 +35,28 @@ _ATTR_NAME_RE = re.compile(r'(?:name|value)\s*=\s*"([^"]*)"')
35
35
  _FIRST_LITERAL_RE = re.compile(r'^\s*"([^"]*)"')
36
36
 
37
37
  # token -> (kind, client). Matched as whole-word usage outside imports/comments.
38
+ # Covers Spring AND plain-Java/Jakarta stacks (Quarkus, Micronaut, Keycloak SPI):
39
+ # the detector must not be Spring-centric, or a non-Spring repo with real LDAP /
40
+ # SMTP / HTTP integrations falsely reports "0 integrations".
38
41
  _TOKEN_CLIENTS: "tuple[tuple[str, str, str], ...]" = (
42
+ # Spring HTTP clients
39
43
  ("RestTemplate", "http", "resttemplate"),
40
44
  ("WebClient", "http", "webclient"),
45
+ # Plain-Java / third-party HTTP clients
46
+ ("HttpClient", "http", "jdk-httpclient"), # java.net.http.HttpClient
47
+ ("CloseableHttpClient", "http", "apache-httpclient"),
48
+ ("HttpClients", "http", "apache-httpclient"),
49
+ ("OkHttpClient", "http", "okhttp"),
50
+ # LDAP / directory (Spring + JNDI)
41
51
  ("LdapTemplate", "ldap", "ldaptemplate"),
52
+ ("InitialLdapContext", "ldap", "jndi-ldap"),
53
+ ("InitialDirContext", "ldap", "jndi-ldap"),
54
+ ("LdapContext", "ldap", "jndi-ldap"),
55
+ # Mail / SMTP (JavaMail / Jakarta Mail)
56
+ ("JavaMailSender", "smtp", "spring-mail"),
57
+ ("MimeMessage", "smtp", "javamail"),
58
+ ("Transport", "smtp", "javamail"),
59
+ # JMS / messaging
42
60
  ("JmsTemplate", "jms", "jmstemplate"),
43
61
  ("ActiveMQConnectionFactory", "jms", "activemq"),
44
62
  )
@@ -156,8 +174,21 @@ def detect_integrations(file_paths: "list[str]", root: Path) -> dict:
156
174
  for r in records:
157
175
  by_kind[r["kind"]] = by_kind.get(r["kind"], 0) + 1
158
176
 
177
+ # BUG #9: honest confidence. A zero count means "no detectable client
178
+ # construct was found", NOT "this system has no integrations" — runtime-wired
179
+ # clients (DI, config-driven endpoints, JCA connectors) are invisible to static
180
+ # text matching. Report that explicitly instead of an authoritative "0".
181
+ confidence = "observed" if records else "not_analyzed"
159
182
  return {
160
183
  "integrations": records,
161
184
  "by_kind": {k: by_kind[k] for k in sorted(by_kind)},
162
185
  "count": len(records),
186
+ "confidence": confidence,
187
+ "coverage_note": (
188
+ "Detects HTTP (RestTemplate/WebClient/JDK/Apache/OkHttp), LDAP (Spring "
189
+ "+ JNDI), SMTP (JavaMail), and JMS client constructs by source-text "
190
+ "matching. A count of 0 means no such construct was found, not that the "
191
+ "system has no outbound integrations — runtime/DI-wired clients are not "
192
+ "statically visible."
193
+ ),
163
194
  }
@@ -22,6 +22,8 @@ from datetime import datetime, timezone
22
22
  from pathlib import Path
23
23
  from typing import Optional, TYPE_CHECKING
24
24
 
25
+ from sourcecode.path_filters import is_test_or_fixture_path
26
+
25
27
  if TYPE_CHECKING:
26
28
  from sourcecode.hibernate_strat import HibernateStratification
27
29
 
@@ -582,6 +584,54 @@ def _is_no_migrate_javax(fqn: str) -> bool:
582
584
  """True if a javax FQN belongs to a JDK/permanent namespace (no jakarta move)."""
583
585
  return any(fqn.startswith(p) for p in _JAKARTA_NO_MIGRATE_PREFIXES)
584
586
 
587
+
588
+ # BUG #8: autogenerated source markers — path fragments and the JSR-250 marker.
589
+ _GENERATED_PATH_FRAGMENTS: tuple[str, ...] = (
590
+ "/generated-sources/", "/generated/", "/target/generated",
591
+ "/build/generated", "/.apt_generated/",
592
+ )
593
+
594
+
595
+ def _classify_code_context(finding: "MigrationFinding") -> str:
596
+ """Bucket a finding as main / test / generated for blocking-count segregation."""
597
+ path = finding.source_file
598
+ if is_test_or_fixture_path(path):
599
+ return "test"
600
+ norm = path.replace("\\", "/").lower()
601
+ if any(frag in norm for frag in _GENERATED_PATH_FRAGMENTS):
602
+ return "generated"
603
+ # javax.annotation.Generated is the marker emitted into autogenerated code.
604
+ if finding.rule_id == "MIG-006" and finding.imports_found and all(
605
+ imp.rsplit(".", 1)[-1] == "Generated" for imp in finding.imports_found
606
+ ):
607
+ return "generated"
608
+ return "main"
609
+
610
+
611
+ # BUG #2: Spring presence signal. The Boot3 dimension only applies to repos that
612
+ # actually use Spring — a Quarkus/Micronaut/Helidon/Jakarta-pure repo has no
613
+ # Spring Boot 2→3 axis. Detected from build coordinates or framework imports.
614
+ _SPRING_BUILD_RE: re.Pattern = re.compile(
615
+ r"org\.springframework(?:\.boot)?\b|spring-boot-starter", re.IGNORECASE
616
+ )
617
+ _SPRING_IMPORT_RE: re.Pattern = re.compile(
618
+ r"^[ \t]*import\s+org\.springframework\.", re.MULTILINE
619
+ )
620
+
621
+
622
+ def _detect_spring_present(root: Path, spring_import_seen: bool) -> bool:
623
+ """True when Spring is actually used (build coordinate or org.springframework import)."""
624
+ if spring_import_seen:
625
+ return True
626
+ for abs_path, _rel in _find_build_files(root):
627
+ try:
628
+ text = abs_path.read_text(encoding="utf-8", errors="replace")
629
+ except OSError:
630
+ continue
631
+ if _SPRING_BUILD_RE.search(text):
632
+ return True
633
+ return False
634
+
585
635
  # G-1: cap on total readiness deduction from low-severity (advisory, non-blocking)
586
636
  # findings, so optional modernization cleanups cannot collapse the migration-readiness
587
637
  # headline on a repo with zero blockers. See MigrationReport.finalize.
@@ -594,6 +644,23 @@ _JAKARTA_TARGETS: frozenset[str] = frozenset({"jakarta"})
594
644
  _BOOT3_MIGRATION_TARGETS: frozenset[str] = frozenset(
595
645
  {"jakarta", "spring_boot_3", "spring_security_6"}
596
646
  )
647
+ # BUG #6: best-practice hygiene (java.util.Date → java.time) blocks NO version
648
+ # upgrade. It is advisory only and must never sink a readiness dimension to 0 —
649
+ # reported as a separate hygiene metric, excluded from JDK-modernization scoring.
650
+ _BEST_PRACTICE_TARGETS: frozenset[str] = frozenset({"java_8_best_practice"})
651
+
652
+ # BUG #3: the migration dimensions that feed the readiness_score aggregate, in
653
+ # order. jdk_modernization is deliberately NOT here — it is orthogonal upkeep debt
654
+ # reported on its own axis, never folded into the migration headline.
655
+ _MIGRATION_DIMENSIONS: tuple[str, ...] = ("jakarta", "boot3", "hibernate")
656
+
657
+
658
+ def _parse_major(version: "Optional[str]") -> "Optional[int]":
659
+ """Leading integer of a version string ('4.0.3' → 4); None when not parseable."""
660
+ if not version:
661
+ return None
662
+ m = re.match(r"\s*(\d+)", version)
663
+ return int(m.group(1)) if m else None
597
664
  # Cap on total readiness deduction from JDK-modernization findings (medium/low),
598
665
  # so reflection/date cleanups cannot collapse a jakarta-ready repo's headline.
599
666
  _JDK_ADVISORY_DEDUCTION_CAP: int = 15
@@ -1013,6 +1080,10 @@ class MigrationFinding:
1013
1080
  fix_hint: str = ""
1014
1081
  migration_target: str = ""
1015
1082
  openrewrite_recipe: Optional[str] = None
1083
+ # BUG #8: where the finding lives — "main" (product), "test" (test/fixture
1084
+ # harness), or "generated" (autogenerated source). Only "main" counts toward
1085
+ # blocking_count / readiness; test+generated are reported in a separate bucket.
1086
+ code_context: str = "main"
1016
1087
 
1017
1088
  @staticmethod
1018
1089
  def make_id(rule_id: str, source_file: str) -> str:
@@ -1031,6 +1102,7 @@ class MigrationFinding:
1031
1102
  "fix_hint": self.fix_hint,
1032
1103
  "migration_target": self.migration_target,
1033
1104
  "auto_fix_available": bool(self.openrewrite_recipe),
1105
+ "code_context": self.code_context,
1034
1106
  }
1035
1107
  if self.imports_found:
1036
1108
  d["imports_found"] = self.imports_found
@@ -1061,8 +1133,12 @@ def _dimension_score(
1061
1133
  low: set[str] = set()
1062
1134
  for f in findings:
1063
1135
  if targets is None:
1136
+ # JDK-modernization axis: everything NOT a Boot 2→3 blocker, EXCEPT
1137
+ # best-practice hygiene (BUG #6) which is advisory and scored separately.
1064
1138
  if f.migration_target in _BOOT3_MIGRATION_TARGETS:
1065
1139
  continue
1140
+ if f.migration_target in _BEST_PRACTICE_TARGETS:
1141
+ continue
1066
1142
  elif f.migration_target not in targets:
1067
1143
  continue
1068
1144
  if f.severity == "critical":
@@ -1107,12 +1183,22 @@ class MigrationReport:
1107
1183
  # is excluded from the aggregate — it is never counted as 0. Maps dimension →
1108
1184
  # {"applicable": bool, "score": int|None, "reason": str}.
1109
1185
  applicable_dimensions: dict = field(default_factory=dict)
1186
+ # BUG #3: how readiness_score was derived (method + the exact applicable
1187
+ # dimension scores it aggregates) so the headline number is fully traceable.
1188
+ readiness_aggregate: dict = field(default_factory=dict)
1110
1189
  blocking_count: int = 0
1111
1190
  estimated_effort_days: float = 0.0
1112
1191
  # Tri-state: True = Boot 2 confirmed, False = Boot 3+ confirmed,
1113
1192
  # None = could not determine. Absence of evidence is never reported as True.
1114
1193
  spring_boot_2_detected: Optional[bool] = None
1115
1194
  spring_boot_version_detected: Optional[str] = None
1195
+ # BUG #2: whether Spring is used at all. The Boot3 dimension is N/A without it.
1196
+ spring_present: bool = True
1197
+ # BUG #6 / #8: findings that do NOT count toward blocking_count or readiness —
1198
+ # best-practice hygiene (java.util.Date…) and test/fixture/generated buckets,
1199
+ # surfaced separately so the headline reflects real product migration risk.
1200
+ hygiene_findings: int = 0
1201
+ non_blocking: dict = field(default_factory=dict)
1116
1202
 
1117
1203
  findings: list[MigrationFinding] = field(default_factory=list)
1118
1204
  limitations: list[str] = field(default_factory=list)
@@ -1138,82 +1224,148 @@ class MigrationReport:
1138
1224
  by_target[f.migration_target] = by_target.get(f.migration_target, 0) + 1
1139
1225
  affected_files.add(f.source_file)
1140
1226
 
1141
- self.blocking_count = by_severity["critical"] + by_severity["high"]
1227
+ # BUG #8: only product ("main") code counts toward blocking_count, the
1228
+ # readiness dimensions, and effort. Test harnesses/fixtures (deprecated
1229
+ # web.xml descriptors, test-only sun.* HTTP servers) and autogenerated
1230
+ # sources are reported in a separate non-blocking bucket — counting them
1231
+ # as product blockers inflates the verdict.
1232
+ main_findings = [f for f in self.findings if f.code_context == "main"]
1233
+
1234
+ def _files_by_sev(items: list["MigrationFinding"], sev: str,
1235
+ targets: "Optional[frozenset[str]]" = None,
1236
+ exclude: "Optional[frozenset[str]]" = None) -> set[str]:
1237
+ out: set[str] = set()
1238
+ for f in items:
1239
+ if f.severity != sev:
1240
+ continue
1241
+ if targets is not None and f.migration_target not in targets:
1242
+ continue
1243
+ if exclude is not None and f.migration_target in exclude:
1244
+ continue
1245
+ out.add(f.source_file)
1246
+ return out
1247
+
1248
+ critical_files = _files_by_sev(main_findings, "critical")
1249
+ high_files = _files_by_sev(main_findings, "high")
1250
+ medium_files = _files_by_sev(main_findings, "medium")
1251
+ low_files = _files_by_sev(main_findings, "low")
1252
+
1253
+ self.blocking_count = sum(
1254
+ 1 for f in main_findings if f.severity in ("critical", "high")
1255
+ )
1142
1256
 
1143
- critical_files: set[str] = set()
1144
- high_files: set[str] = set()
1145
- medium_files: set[str] = set()
1146
- low_files: set[str] = set()
1147
- for f in self.findings:
1148
- if f.severity == "critical":
1149
- critical_files.add(f.source_file)
1150
- elif f.severity == "high":
1151
- high_files.add(f.source_file)
1152
- elif f.severity == "medium":
1153
- medium_files.add(f.source_file)
1257
+ # Per-dimension readiness — independent severity-weighted scores (MAIN only).
1258
+ self.jakarta_readiness = _dimension_score(main_findings, _JAKARTA_TARGETS)
1259
+ self.boot3_readiness = _dimension_score(main_findings, _BOOT3_MIGRATION_TARGETS)
1260
+ self.jdk_modernization = _dimension_score(main_findings, None)
1261
+ if not self.spring_present:
1262
+ self.boot3_readiness = 100
1263
+
1264
+ # ── BUG #2: Hibernate applicability — version-driven, never heuristic ────
1265
+ # The 5→6 axis is applicable ONLY when the repo is actually on Hibernate < 6.
1266
+ # resolved < 6 → applicable (rewrite score is a measurement)
1267
+ # resolved ≥ 6 → N/A (already migrated)
1268
+ # unresolved, Boot≥3 → N/A (Boot 3/4 BOM manages Hibernate ORM ≥6)
1269
+ # unresolved, Boot==2 → applicable (Boot 2 BOM manages Hibernate 5.x)
1270
+ # unresolved, no BOM → N/A, status "unresolved" (NEVER a heuristic number
1271
+ # that looks like a measurement on absent data)
1272
+ hib = self.hibernate
1273
+ hib_detected = hib is not None and hib.detected
1274
+ boot_major = _parse_major(self.spring_boot_version_detected)
1275
+ _hibernate_applies = False
1276
+ _hib_score: Optional[int] = None
1277
+ hib_status = "not_detected"
1278
+ _hib_reason = "N/A — no Hibernate dependency or import detected"
1279
+ if hib_detected:
1280
+ if hib.version_confidence == "high":
1281
+ if hib.migration_applicable: # resolved major < 6
1282
+ _hibernate_applies, _hib_score = True, hib.readiness
1283
+ hib_status = "resolved_h5"
1284
+ _hib_reason = f"Hibernate 5→6 rewrite axis (resolved {hib.effective_version})"
1285
+ else: # resolved major ≥ 6
1286
+ hib_status = "managed_ge6"
1287
+ _hib_reason = (f"N/A — resolved Hibernate {hib.effective_version} (≥6); "
1288
+ f"no 5→6 migration pending")
1289
+ elif boot_major is not None and boot_major >= 3:
1290
+ hib_status = "managed_ge6"
1291
+ _hib_reason = (f"N/A — Hibernate version managed by Spring Boot "
1292
+ f"{self.spring_boot_version_detected} BOM (Hibernate ORM ≥6); "
1293
+ f"5→6 axis inapplicable")
1294
+ elif boot_major == 2:
1295
+ _hibernate_applies, _hib_score = True, hib.readiness
1296
+ hib_status = "managed_h5"
1297
+ _hib_reason = (f"Hibernate 5→6 rewrite axis (Hibernate 5.x managed by Spring "
1298
+ f"Boot {self.spring_boot_version_detected} BOM)")
1154
1299
  else:
1155
- low_files.add(f.source_file)
1156
-
1157
- # BUG-2: aggregate readiness no longer collapses on raw JDK-modernization
1158
- # volume. Blockers (critical/high, any target) stay uncapped so a genuinely
1159
- # blocked repo floors at 0 (shopizer: 301 blocking 0). Medium/low are
1160
- # split by target: migration-blocking (jakarta/boot3/security) count in
1161
- # full (low capped, G-1); orthogonal JDK debt (Date/reflection) is capped
1162
- # so it cannot sink a jakarta-ready repo (Broadleaf: 144 Date + 25 reflection
1163
- # no longer force 0/100).
1164
- mig_med = {f.source_file for f in self.findings
1165
- if f.severity == "medium" and f.migration_target in _BOOT3_MIGRATION_TARGETS}
1166
- mig_low = {f.source_file for f in self.findings
1167
- if f.severity == "low" and f.migration_target in _BOOT3_MIGRATION_TARGETS}
1168
- jdk_med = {f.source_file for f in self.findings
1169
- if f.severity == "medium" and f.migration_target not in _BOOT3_MIGRATION_TARGETS}
1170
- jdk_low = {f.source_file for f in self.findings
1171
- if f.severity == "low" and f.migration_target not in _BOOT3_MIGRATION_TARGETS}
1172
-
1173
- deduction = (
1174
- len(critical_files) * 15
1175
- + len(high_files) * 8
1176
- + len(mig_med) * 3
1177
- + min(len(mig_low) * 1, _LOW_SEVERITY_DEDUCTION_CAP)
1178
- + min(len(jdk_med) * 3 + len(jdk_low) * 1, _JDK_ADVISORY_DEDUCTION_CAP)
1179
- )
1180
- self.readiness_score = max(0, 100 - deduction)
1181
-
1182
- # Per-dimension readiness — independent severity-weighted scores so the
1183
- # output reveals that jakarta/Boot3 may be complete even with JDK debt.
1184
- self.jakarta_readiness = _dimension_score(self.findings, _JAKARTA_TARGETS)
1185
- self.boot3_readiness = _dimension_score(self.findings, _BOOT3_MIGRATION_TARGETS)
1186
- self.jdk_modernization = _dimension_score(self.findings, None)
1187
-
1188
- # Hibernate is its own rewrite axis (orthogonal to jakarta/Boot3); it does
1189
- # NOT sink the headline readiness_score, but is surfaced as a dimension and,
1190
- # in a rewrite zone, as the headline_blocker so 62/100 is not read as "easy".
1191
- _hibernate_applies = self.hibernate is not None and self.hibernate.detected
1192
- if _hibernate_applies:
1193
- self.hibernate_readiness = self.hibernate.readiness
1194
- if self.hibernate.classification == "rewrite_zone":
1195
- self.headline_blocker = "hibernate_rewrite"
1196
-
1197
- # BUG #3: declare which dimensions apply. jakarta/boot3/jdk are always
1198
- # applicable to a Java/Spring repo; hibernate applies only when a real
1199
- # Hibernate dependency/import was found. An inapplicable dimension is N/A
1200
- # (score=None) and is excluded from the aggregate — never scored as 0.
1300
+ hib_status = "unresolved"
1301
+ _hib_reason = ("N/A — Hibernate version unresolved (not declared and no Spring "
1302
+ "Boot BOM to infer from); not penalized on absent data")
1303
+ # Top-level scalar: the rewrite score only when applicable; else 100 (nothing
1304
+ # pending) never a heuristic penalty on an inapplicable/unresolved axis.
1305
+ self.hibernate_readiness = _hib_score if _hibernate_applies else 100
1306
+ # Headline blocker only on a DIRECTLY-resolved Hibernate-5 rewrite zone.
1307
+ if _hibernate_applies and hib is not None and hib.classification == "rewrite_zone" \
1308
+ and hib.version_confidence == "high":
1309
+ self.headline_blocker = "hibernate_rewrite"
1310
+
1311
+ # BUG #3: declare which dimensions apply, then derive readiness_score as a
1312
+ # DOCUMENTED aggregate over the applicable MIGRATION dimensions only. jakarta
1313
+ # is always applicable; boot3 only with Spring; hibernate only for a real
1314
+ # Hibernate < 6. jdk_modernization is an orthogonal upkeep axis (SecurityManager,
1315
+ # reflection, java.time) reported but EXCLUDED from the headline so JDK debt
1316
+ # cannot sink a framework-complete repo. N/A dimensions carry score=None and
1317
+ # never enter the aggregate.
1201
1318
  self.applicable_dimensions = {
1202
1319
  "jakarta": {"applicable": True, "score": self.jakarta_readiness,
1203
1320
  "reason": "javax→jakarta namespace migration"},
1204
- "boot3": {"applicable": True, "score": self.boot3_readiness,
1205
- "reason": "Spring Boot 2→3 / Security 6 migration"},
1321
+ "boot3": {
1322
+ "applicable": self.spring_present,
1323
+ "score": self.boot3_readiness if self.spring_present else None,
1324
+ "reason": ("Spring Boot 2→3 / Security 6 migration"
1325
+ if self.spring_present
1326
+ else "N/A — no Spring usage detected (non-Spring stack)"),
1327
+ },
1206
1328
  "jdk_modernization": {"applicable": True, "score": self.jdk_modernization,
1207
- "reason": "orthogonal JDK modernization debt"},
1329
+ "reason": "orthogonal JDK modernization debt (excluded from "
1330
+ "the readiness_score aggregate)",
1331
+ "in_aggregate": False},
1208
1332
  "hibernate": {
1209
1333
  "applicable": _hibernate_applies,
1210
1334
  "score": self.hibernate_readiness if _hibernate_applies else None,
1211
- "reason": ("Hibernate 5→6 rewrite axis"
1212
- if _hibernate_applies
1213
- else "N/A — no Hibernate dependency or import detected"),
1335
+ "reason": _hib_reason,
1336
+ "status": hib_status,
1214
1337
  },
1215
1338
  }
1216
1339
 
1340
+ # ── BUG #3: aggregate invariant ─────────────────────────────────────────
1341
+ # readiness_score == min(score of every applicable migration dimension).
1342
+ # MIN (not mean): a migration is only as ready as its weakest applicable
1343
+ # axis. _MIGRATION_DIMENSIONS lists exactly which dimensions feed it; the
1344
+ # consistency invariant is asserted in finalize and covered by a unit test.
1345
+ agg_inputs = {
1346
+ name: self.applicable_dimensions[name]["score"]
1347
+ for name in _MIGRATION_DIMENSIONS
1348
+ if self.applicable_dimensions[name]["applicable"]
1349
+ }
1350
+ self.readiness_score = min(agg_inputs.values()) if agg_inputs else 100
1351
+ self.readiness_aggregate = {
1352
+ "method": "min",
1353
+ "inputs": agg_inputs,
1354
+ "excluded": ["jdk_modernization"],
1355
+ "note": ("readiness_score = min over applicable migration dimensions "
1356
+ "(jakarta / boot3 / hibernate). jdk_modernization is an orthogonal "
1357
+ "upkeep axis and is intentionally excluded."),
1358
+ }
1359
+ # Internal consistency guard — the headline cannot diverge from the dimensions
1360
+ # it claims to summarize (catches a future scorer change that breaks the model).
1361
+ if agg_inputs:
1362
+ assert self.readiness_score == min(agg_inputs.values()), (
1363
+ "readiness_score must equal min(applicable migration dimensions); "
1364
+ f"got {self.readiness_score} vs inputs {agg_inputs}"
1365
+ )
1366
+
1367
+ # BUG #5: effort over MAIN findings only — N/A axes (Hibernate-6 phantom,
1368
+ # test fixtures) no longer pad the estimate.
1217
1369
  self.estimated_effort_days = round(
1218
1370
  len(critical_files) * 0.5
1219
1371
  + len(high_files) * 0.25
@@ -1222,12 +1374,34 @@ class MigrationReport:
1222
1374
  1,
1223
1375
  )
1224
1376
 
1377
+ # BUG #6 / #8: hygiene + non-blocking buckets, surfaced separately.
1378
+ self.hygiene_findings = sum(
1379
+ 1 for f in main_findings if f.migration_target in _BEST_PRACTICE_TARGETS
1380
+ )
1381
+ _nb_by_ctx: dict[str, int] = {}
1382
+ _nb_by_rule: dict[str, int] = {}
1383
+ for f in self.findings:
1384
+ if f.code_context == "main":
1385
+ continue
1386
+ _nb_by_ctx[f.code_context] = _nb_by_ctx.get(f.code_context, 0) + 1
1387
+ _nb_by_rule[f.rule_id] = _nb_by_rule.get(f.rule_id, 0) + 1
1388
+ self.non_blocking = {
1389
+ "count": sum(_nb_by_ctx.values()),
1390
+ "by_context": _nb_by_ctx,
1391
+ "by_rule": _nb_by_rule,
1392
+ "note": ("Findings in test/fixture harnesses or autogenerated sources. "
1393
+ "Excluded from blocking_count, readiness, and effort."),
1394
+ }
1395
+
1225
1396
  self.summary = {
1226
1397
  "total_findings": len(self.findings),
1227
1398
  "affected_files": len(affected_files),
1228
1399
  "by_severity": by_severity,
1229
1400
  "by_rule": by_rule,
1230
1401
  "by_migration_target": by_target,
1402
+ "main_findings": len(main_findings),
1403
+ "non_blocking_findings": self.non_blocking["count"],
1404
+ "hygiene_findings": self.hygiene_findings,
1231
1405
  }
1232
1406
  return self
1233
1407
 
@@ -1243,14 +1417,20 @@ class MigrationReport:
1243
1417
  "jdk_modernization": self.jdk_modernization,
1244
1418
  "hibernate_readiness": self.hibernate_readiness,
1245
1419
  "applicable_dimensions": self.applicable_dimensions,
1420
+ "readiness_aggregate": self.readiness_aggregate,
1246
1421
  "readiness_note": (
1247
- "readiness_score is a DERIVED aggregate over applicable dimensions only "
1248
- "(N/A dimensions excluded, never counted as 0). For migration decisions read "
1249
- "the per-dimension breakdown + blocking_count, not the single number."
1422
+ "readiness_score = min over applicable MIGRATION dimensions "
1423
+ "(jakarta / boot3 / hibernate); see readiness_aggregate for the exact "
1424
+ "inputs. N/A dimensions are excluded (never counted as 0); "
1425
+ "jdk_modernization is orthogonal upkeep and is NOT in the aggregate. "
1426
+ "For decisions read the per-dimension breakdown + blocking_count."
1250
1427
  ),
1251
1428
  "headline_blocker": self.headline_blocker,
1252
1429
  "blocking_count": self.blocking_count,
1253
1430
  "estimated_effort_days": self.estimated_effort_days,
1431
+ "hygiene_findings": self.hygiene_findings,
1432
+ "non_blocking": self.non_blocking,
1433
+ "spring_present": self.spring_present,
1254
1434
  "spring_boot_2_detected": self.spring_boot_2_detected,
1255
1435
  "spring_boot_version_detected": self.spring_boot_version_detected,
1256
1436
  "summary": self.summary,
@@ -1270,19 +1450,31 @@ class MigrationReport:
1270
1450
  _boot = f"Boot {self.spring_boot_version_detected or '3+'} detected"
1271
1451
  else:
1272
1452
  _boot = "unknown"
1453
+
1454
+ def _dim(name: str) -> str:
1455
+ d = self.applicable_dimensions.get(name, {})
1456
+ if not d.get("applicable", True):
1457
+ return f"{name}: N/A"
1458
+ return f"{name}: {d.get('score')}/100"
1459
+
1460
+ # Blocking parenthetical reflects MAIN (product) findings only — matching
1461
+ # blocking_count — so the text headline cannot contradict the JSON.
1462
+ main_crit = sum(1 for f in self.findings
1463
+ if f.code_context == "main" and f.severity == "critical")
1464
+ main_high = sum(1 for f in self.findings
1465
+ if f.code_context == "main" and f.severity == "high")
1466
+ nb = self.non_blocking.get("count", 0)
1273
1467
  lines: list[str] = [
1274
1468
  f"Migration Readiness: {self.readiness_score}/100",
1275
- f" jakarta: {self.jakarta_readiness}/100 "
1276
- f"boot3: {self.boot3_readiness}/100 "
1277
- f"jdk-modernization: {self.jdk_modernization}/100 "
1278
- f"hibernate: {self.hibernate_readiness}/100",
1469
+ f" {_dim('jakarta')} {_dim('boot3')} "
1470
+ f"{_dim('jdk_modernization')} {_dim('hibernate')}",
1279
1471
  *([f" ⚠ Headline blocker: {self.headline_blocker} "
1280
1472
  f"(readiness_score reflects jakarta/Boot3 only — Hibernate is a separate rewrite axis)"]
1281
1473
  if self.headline_blocker else []),
1282
- f"Spring Boot 2 detected: {_boot}",
1283
- f"Blocking issues: {self.blocking_count} "
1284
- f"(critical: {self.summary.get('by_severity', {}).get('critical', 0)}, "
1285
- f"high: {self.summary.get('by_severity', {}).get('high', 0)})",
1474
+ f"Spring present: {self.spring_present} Spring Boot 2 detected: {_boot}",
1475
+ f"Blocking issues (product code): {self.blocking_count} "
1476
+ f"(critical: {main_crit}, high: {main_high})"
1477
+ + (f" [+{nb} in test/generated, non-blocking]" if nb else ""),
1286
1478
  f"Affected files: {self.summary.get('affected_files', 0)}",
1287
1479
  f"Estimated effort: {self.estimated_effort_days}d",
1288
1480
  "",
@@ -1314,6 +1506,42 @@ class MigrationReport:
1314
1506
  # Java source scanner
1315
1507
  # ---------------------------------------------------------------------------
1316
1508
 
1509
+ # BUG #7: javax.annotation.Generated is the JSR-250 marker emitted into
1510
+ # AUTOGENERATED sources (SCIM, JAXB, MapStruct…). It maps mechanically to
1511
+ # jakarta.annotation.Generated and carries low migration value — it must not be
1512
+ # described with the generic "@PostConstruct/@PreDestroy/@Resource" blurb, nor
1513
+ # rated medium alongside real CDI lifecycle annotations.
1514
+ _MIG006_LIFECYCLE = ("PostConstruct", "PreDestroy", "Resource",
1515
+ "ManagedBean", "Priority", "Resources")
1516
+
1517
+
1518
+ def _refine_mig006(matched_imports: list[str]) -> tuple[str, str]:
1519
+ """Return (severity, explanation) tailored to the actual javax.annotation symbols.
1520
+
1521
+ Explanation names the concrete symbols detected, not a fixed list. When the
1522
+ only symbol is `Generated`, severity degrades to low (mechanical, generated
1523
+ code); otherwise the CDI lifecycle annotations keep medium severity.
1524
+ """
1525
+ symbols = [imp.rsplit(".", 1)[-1] for imp in matched_imports]
1526
+ lifecycle = [s for s in symbols if s in _MIG006_LIFECYCLE]
1527
+ only_generated = bool(symbols) and all(s == "Generated" for s in symbols)
1528
+ sym_list = ", ".join(f"@{s}" for s in dict.fromkeys(symbols)) or "javax.annotation"
1529
+ if only_generated:
1530
+ return (
1531
+ "low",
1532
+ "javax.annotation.Generated (JSR-250) maps mechanically to "
1533
+ "jakarta.annotation.Generated. It is emitted into autogenerated sources "
1534
+ "and carries low migration value — re-run the generator on the Jakarta "
1535
+ "toolchain rather than hand-editing.",
1536
+ )
1537
+ affected = ", ".join(f"@{s}" for s in dict.fromkeys(lifecycle)) or sym_list
1538
+ return (
1539
+ "medium",
1540
+ f"jakarta.annotation replaces javax.annotation in Jakarta EE 9+. "
1541
+ f"Detected symbol(s): {sym_list}. {affected} are affected.",
1542
+ )
1543
+
1544
+
1317
1545
  def _scan_file(
1318
1546
  source: str,
1319
1547
  rel_path: str,
@@ -1360,16 +1588,21 @@ def _scan_file(
1360
1588
  first_line = min(candidate_lines)
1361
1589
  all_matches = matched_imports + code_snippets
1362
1590
 
1591
+ severity = rule.severity
1592
+ explanation = rule.explanation
1593
+ if rule.id == "MIG-006" and matched_imports:
1594
+ severity, explanation = _refine_mig006(matched_imports)
1595
+
1363
1596
  findings.append(
1364
1597
  MigrationFinding(
1365
1598
  id=MigrationFinding.make_id(rule.id, rel_path),
1366
1599
  rule_id=rule.id,
1367
- severity=rule.severity,
1600
+ severity=severity,
1368
1601
  title=rule.title,
1369
1602
  source_file=rel_path,
1370
1603
  first_line=first_line,
1371
1604
  imports_found=all_matches,
1372
- explanation=rule.explanation,
1605
+ explanation=explanation,
1373
1606
  fix_hint=rule.fix_hint,
1374
1607
  migration_target=rule.migration_target,
1375
1608
  openrewrite_recipe=rule.openrewrite_recipe,
@@ -1410,6 +1643,7 @@ def run_migrate_check(
1410
1643
  limitations: list[str] = []
1411
1644
  read_errors = 0
1412
1645
  jakarta_import_count = 0
1646
+ spring_import_seen = False
1413
1647
 
1414
1648
  # ── Java source scan ────────────────────────────────────────────────────
1415
1649
  for rel_path in file_paths:
@@ -1422,6 +1656,8 @@ def run_migrate_check(
1422
1656
 
1423
1657
  # Jakarta EE 9+ namespace adoption signal (vetoes a false Boot-2 verdict).
1424
1658
  jakarta_import_count += len(_JAKARTA_IMPORT_RE.findall(source))
1659
+ if not spring_import_seen and _SPRING_IMPORT_RE.search(source):
1660
+ spring_import_seen = True
1425
1661
 
1426
1662
  file_findings = _scan_file(source, rel_path, _ALL_RULES)
1427
1663
  filtered = [f for f in file_findings if SEVERITY_ORDER.get(f.severity, 3) <= min_order]
@@ -1477,6 +1713,12 @@ def run_migrate_check(
1477
1713
  limitations.extend(_STATIC_LIMITATIONS)
1478
1714
 
1479
1715
  spring_boot_2, spring_boot_version = _detect_spring_boot(root, jakarta_import_count)
1716
+ spring_present = _detect_spring_present(root, spring_import_seen)
1717
+
1718
+ # BUG #8: classify each finding's code context (main / test / generated) so the
1719
+ # report can keep test fixtures and autogenerated sources out of blocking_count.
1720
+ for f in all_findings:
1721
+ f.code_context = _classify_code_context(f)
1480
1722
 
1481
1723
  # Hibernate 5→6 stratification (independent of min_severity — it is its own
1482
1724
  # risk model, not a severity-filtered finding stream).
@@ -1486,6 +1728,7 @@ def run_migrate_check(
1486
1728
  report = MigrationReport(
1487
1729
  spring_boot_2_detected=spring_boot_2,
1488
1730
  spring_boot_version_detected=spring_boot_version,
1731
+ spring_present=spring_present,
1489
1732
  findings=all_findings,
1490
1733
  hibernate=hibernate_strat,
1491
1734
  limitations=limitations,
@@ -12,6 +12,16 @@ _TEST_SEGMENTS = frozenset({
12
12
  "integrationtest", "integrationtests",
13
13
  })
14
14
 
15
+ # Whole-module directories that are test harness / fixtures even though their
16
+ # code lives under src/main (e.g. a Maven module that ships a test framework).
17
+ # A finding under one of these modules is test infrastructure, not the product.
18
+ _TEST_MODULE_SEGMENTS = frozenset({
19
+ "testsuite", "test-framework", "testframework",
20
+ "integration-arquillian", "arquillian",
21
+ "test-utils", "test-util", "testutils",
22
+ "test-support", "testsupport",
23
+ })
24
+
15
25
  _VENDOR_SEGMENTS = frozenset({
16
26
  "vendor", "vendors",
17
27
  "third_party", "thirdparty",
@@ -86,6 +96,29 @@ def is_test_path(path: str) -> bool:
86
96
  return False
87
97
 
88
98
 
99
+ def is_test_or_fixture_path(path: str) -> bool:
100
+ """Return True when *path* is test code OR test-harness/fixture infrastructure.
101
+
102
+ Broader than is_test_path: also catches whole modules that are test frameworks
103
+ or integration-test harnesses (testsuite/, test-framework/, integration-arquillian/,
104
+ *-test*, *-it modules) even when their sources sit under src/main. Migration
105
+ tooling uses this to keep test fixtures (deprecated web.xml deployment
106
+ descriptors, test-only sun.* HTTP servers) out of the product's blocking count.
107
+ """
108
+ if is_test_path(path):
109
+ return True
110
+ norm = path.replace("\\", "/").lower()
111
+ parts = norm.split("/")
112
+ for part in parts[:-1]: # skip the filename
113
+ bare = part.rstrip("/")
114
+ if bare in _TEST_MODULE_SEGMENTS:
115
+ return True
116
+ # module dirs like "adapter-test", "foo-tests", "bar-itests"
117
+ if bare.endswith(("-test", "-tests", "-it", "-itest", "-itests")):
118
+ return True
119
+ return False
120
+
121
+
89
122
  def is_vendor_path(path: str) -> bool:
90
123
  """Return True when *path* is inside a vendored / third-party directory.
91
124
 
sourcecode/serializer.py CHANGED
@@ -291,17 +291,65 @@ _JAKARTA_RENAMED_NAMESPACES: tuple[str, ...] = (
291
291
  )
292
292
 
293
293
 
294
+ # Permanent JDK / JSR javax.* namespaces. These are part of the Java SE platform
295
+ # (or standalone JCP specs) and were NEVER renamed to jakarta.* — Spring Boot 3/4
296
+ # still uses them under javax.*. They must be excluded from EVERY javax→jakarta
297
+ # detection rule (dependency coordinates AND import scanning). This is the single
298
+ # source of truth for the allowlist — do NOT re-implement prefix checks elsewhere.
299
+ # Counterpart in migrate_check._JAKARTA_NO_MIGRATE_PREFIXES (import side); keep both
300
+ # in sync. NOTE: `javax.annotation.processing` is permanent (JSR-269, compiler API)
301
+ # but bare `javax.annotation` (JSR-250 EE subset) DID move — handled below.
302
+ _JAVAX_PERMANENT_NAMESPACES: tuple[str, ...] = (
303
+ "javax.cache", # JSR-107 (JCache) — never renamed
304
+ "javax.sql", # JDBC standard extension (Java SE)
305
+ "javax.xml", # JAXP (parsers/transform/xpath/stream/namespace…)
306
+ "javax.naming", # JNDI (Java SE)
307
+ "javax.management", # JMX (Java SE)
308
+ "javax.crypto", # JCE (Java SE)
309
+ "javax.net", # incl. javax.net.ssl (Java SE)
310
+ "javax.security.auth", # JAAS (Java SE)
311
+ "javax.security.cert", # Java SE
312
+ "javax.security.sasl", # Java SE
313
+ "javax.annotation.processing", # JSR-269 compiler API (NOT the JSR-250 subset)
314
+ "javax.tools", # Java SE compiler API
315
+ "javax.lang.model", # Java SE annotation-processing model
316
+ "javax.sound", # Java SE
317
+ "javax.imageio", # Java SE
318
+ "javax.print", # Java SE
319
+ "javax.accessibility", # Java SE
320
+ "javax.swing", # Java SE
321
+ "javax.smartcardio", # Java SE
322
+ )
323
+
324
+
325
+ def _longest_namespace_match(nl: str, namespaces: "tuple[str, ...]") -> int:
326
+ """Length of the longest namespace in *namespaces* that is a prefix of *nl*.
327
+
328
+ Matches an exact namespace or a dotted sub-package (javax.xml → javax.xml.bind);
329
+ returns 0 when none match.
330
+ """
331
+ best = 0
332
+ for ns in namespaces:
333
+ if (nl == ns or nl.startswith(ns + ".")) and len(ns) > best:
334
+ best = len(ns)
335
+ return best
336
+
337
+
294
338
  def _is_jakarta_renamed_namespace(nl: str) -> bool:
295
339
  """True only for javax.* namespaces actually renamed to jakarta.* in Jakarta EE 9.
296
340
 
297
- Matches exact namespace or a sub-package (e.g. javax.servlet javax.servlet.http).
341
+ Decided by LONGEST-prefix match across the renamed list and the permanent
342
+ JDK/JSR allowlist — the more specific namespace wins. This keeps both nuanced
343
+ cases correct simultaneously:
344
+ - javax.xml.bind (renamed, len 14) beats javax.xml (permanent, len 9) → flag.
345
+ - javax.annotation.processing (permanent, len 27) beats javax.annotation
346
+ (renamed, len 16) → no flag.
298
347
  JDK/JSR namespaces that keep javax.* forever (javax.cache, javax.sql, javax.xml
299
- JAXP, javax.naming, …) return False.
348
+ JAXP, javax.naming, …) never flag. A bare javax.* with no match also never flags.
300
349
  """
301
- for ns in _JAKARTA_RENAMED_NAMESPACES:
302
- if nl == ns or nl.startswith(ns + "."):
303
- return True
304
- return False
350
+ renamed = _longest_namespace_match(nl, _JAKARTA_RENAMED_NAMESPACES)
351
+ permanent = _longest_namespace_match(nl, _JAVAX_PERMANENT_NAMESPACES)
352
+ return renamed > 0 and renamed >= permanent
305
353
 
306
354
 
307
355
  def _dep_risk_flags(name: str, version: "Optional[str]") -> list[str]:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 1.64.0
3
+ Version: 1.66.0
4
4
  Summary: Persistent structural context and ultra-fast repeated analysis for AI coding agents
5
5
  License-File: LICENSE
6
6
  Keywords: agents,ai,codebase,context,developer-tools,llm
@@ -411,7 +411,7 @@ Emits **structured, tool-agnostic** codebase views as plain JSON/YAML — the ki
411
411
  |------|--------|
412
412
  | `--by-directory` | One group per source directory, each symbol with a `source_file:line` reference. |
413
413
  | `--module-graph` | `{nodes, edges, summary}` — directories as modules, inter-module dependencies rolled up from class-level relation edges with hit counts + edge types. |
414
- | `--integrations` | Outbound integrations (`RestTemplate`, `WebClient`, `@FeignClient`, `LdapTemplate`, `JmsTemplate`, ActiveMQ) with `file:line` evidence and a literal `target` URL/name when present. |
414
+ | `--integrations` | Outbound integrations across Spring **and** plain-Java/Jakarta stacks: HTTP (`RestTemplate`, `WebClient`, `@FeignClient`, JDK `HttpClient`, Apache, OkHttp), LDAP (`LdapTemplate`, JNDI `InitialDirContext`/`LdapContext`), SMTP (`JavaMailSender`, JavaMail `Transport`/`MimeMessage`), and JMS — with `file:line` evidence and a literal `target` when present. Emits an explicit `confidence` (`observed` / `not_analyzed`) + `coverage_note`: a count of `0` means "no client construct found", **not** "no integrations" (runtime/DI-wired clients are not statically visible). |
415
415
  | `--c4` | Unified document: `c4.{context, containers, components, code}` + `api_surface` + a `manifest` with per-directory content hashes for **incremental** consumers (skip directories whose hash is unchanged). `components.module_roots` rolls leaf source dirs up to architectural module roots and classifies each `layered` (DDD: ≥2 of `domain`/`application`/`infrastructure`) vs `flat` (legacy/flat package), with a verifiable `module_count` — so a consumer enumerates real modules instead of inferring boundaries from leaf directories. |
416
416
 
417
417
  The section flags compose (pass several for one multi-section document); `--c4` assembles the full export on its own. URLs assembled at runtime yield `target: null` (honest absence, never a guess); containers are derived from build files (Maven/Gradle) and reported as a limitation when none are found.
@@ -610,7 +610,7 @@ Detects migration blockers across Java source files, Spring XML config files, an
610
610
  | `MIG-002` | high | `javax.servlet` import — Servlet API changed |
611
611
  | `MIG-003` | high | `javax.validation` import — Bean Validation changed |
612
612
  | `MIG-004` | high | `javax.transaction` import — TX API changed |
613
- | `MIG-006` | medium | `javax.annotation` import — CDI annotations changed |
613
+ | `MIG-006` | medium¹ | `javax.annotation` import — CDI annotations changed (¹`javax.annotation.Generated` in autogenerated code → **low**, bucketed `generated`; explanation names the actual symbol) |
614
614
  | `MIG-007` | medium | `javax.inject` import — DI annotations changed |
615
615
  | `MIG-008` | medium | `javax.ws.rs` import — JAX-RS changed |
616
616
  | `MIG-009` | medium | `javax.jms` import — JMS API changed |
@@ -722,9 +722,51 @@ reported as **N/A** (`score: null`), never as `0`. The top-level
722
722
  states that `readiness_score` is a derived aggregate over applicable dimensions
723
723
  only — for decisions, read the per-dimension breakdown + `blocking_count`.
724
724
 
725
+ #### Reliability guarantees (no version, no verdict)
726
+
727
+ A migration verdict shown to a decision-maker must never contradict its own
728
+ evidence. `migrate-check` enforces:
729
+
730
+ - **Version-gated framework axes.** The Hibernate 5→6 axis applies only when the
731
+ effective Hibernate version (resolved from `pom.xml`/Gradle, following Maven
732
+ `${properties}` and BOM-managed `<hibernate*.version>`) is `< 6`. A resolved
733
+ Hibernate **6+** repo is reported `migration_applicable: false` (N/A) — no
734
+ `hibernate_rewrite` headline. An **unresolved** version degrades to a
735
+ low-confidence hypothesis with no headline blocker. See
736
+ `hibernate.effective_version` / `version_major` / `version_confidence`.
737
+ - **Boot3 auto-disables without Spring.** `boot3.applicable` tracks real Spring
738
+ usage; Quarkus / Micronaut / Helidon / Jakarta-pure repos report `boot3` as N/A
739
+ (`spring_present: false`), not a contradictory `applicable: true`.
740
+ - **Permanent `javax.*` are never jakarta debt.** JDK/JSR namespaces
741
+ (`javax.cache`, `javax.sql`, `javax.xml` JAXP, `javax.crypto`, `javax.naming`,
742
+ `javax.management`, `javax.security.auth`, `javax.annotation.processing`, …) are
743
+ allowlisted across every jakarta scorer (single source of truth:
744
+ `serializer._JAVAX_PERMANENT_NAMESPACES` + `migrate_check._JAKARTA_NO_MIGRATE_PREFIXES`).
745
+ The `javax→jakarta` dependency flag decides via **longest-prefix match**, so
746
+ `javax.xml.bind` (JAXB — moved) flags while `javax.xml` (JAXP) does not, and
747
+ `javax.annotation` (JSR-250 — moved) flags while `javax.annotation.processing`
748
+ (JSR-269) does not. The allowlist never silences a real migration.
749
+ - **`readiness_score` is a traceable aggregate.** It is `min` over the applicable
750
+ **migration** dimensions (`jakarta` / `boot3` / `hibernate`); `jdk_modernization`
751
+ is orthogonal upkeep and is excluded. The exact inputs are in `readiness_aggregate{}`
752
+ and an invariant (`readiness_score == min(applicable migration dims)`) is asserted in
753
+ code. When the Hibernate version is undeclared it is inferred from the Spring Boot
754
+ BOM (Boot ≥3 → Hibernate ≥6 → N/A; Boot 2 → Hibernate 5 → applicable; no BOM →
755
+ `status: unresolved`, never a heuristic score).
756
+ - **Three buckets: blocker ≠ hygiene ≠ test.** Only product (`main`) code counts
757
+ toward `blocking_count`, readiness, and effort. Test harnesses
758
+ (`testsuite/`, `test-framework/`, `integration-arquillian/`, `*-test*`,
759
+ `src/test/`) and autogenerated sources are tagged `code_context` and surfaced in
760
+ `non_blocking{}`. Best-practice hygiene (`java.util.Date`) is reported as
761
+ `hygiene_findings` and never sinks a dimension. Each finding carries
762
+ `code_context: main | test | generated`.
763
+
725
764
  ```bash
726
765
  # inspect only the Hibernate rewrite targets
727
766
  sourcecode migrate-check . --format json | jq '.hibernate.rewrite_targets[]'
767
+
768
+ # product blockers only (excludes test/fixtures + hygiene)
769
+ sourcecode migrate-check . --format json | jq '.blocking_count, .non_blocking, .hygiene_findings'
728
770
  ```
729
771
 
730
772
  ### `rename-class` — Java class rename
@@ -1,4 +1,4 @@
1
- sourcecode/__init__.py,sha256=icvkhugNj_JqAoF4YlHhszmsFC7whNNmc8xxgqsJUcI,103
1
+ sourcecode/__init__.py,sha256=_rKEwisXr1IeFjMbYXGSfdRcelQmOHaAB0XR84P9tsk,103
2
2
  sourcecode/adaptive_scanner.py,sha256=XffluXKzJUXrMtjEiAOnSNPZnztdIcts17T9ouHeID0,10521
3
3
  sourcecode/architecture_analyzer.py,sha256=liCwQmLgb5vplohy8arjYxs_HOIv5C9MjLh_gY6bc5Q,44115
4
4
  sourcecode/architecture_summary.py,sha256=z34_6v7cSwy98cof2UVciGho7SCrZ93tiqMmq5WNzRQ,20405
@@ -28,15 +28,15 @@ sourcecode/format_contract.py,sha256=1cTNqwP8geA2hbQoBHUPgX3_vSh3l8guJT_jmgEnFF8
28
28
  sourcecode/fqn_utils.py,sha256=XLU7zDkNBXz_RZkIUNfpPmp1nekWtqP-fxV92tDV1vg,2158
29
29
  sourcecode/git_analyzer.py,sha256=JStxTQXNjBWi_wLdwhsZs9mT-v50cSJIz4Agzn6Kh9I,13362
30
30
  sourcecode/graph_analyzer.py,sha256=DHR8fY69oU_Pi4SYaWboX6EoEFrctQKB9dsjpqwGMzw,62403
31
- sourcecode/hibernate_strat.py,sha256=4OKDnfdiJ38_XJIFKBXwZa4ai-Vgb54_FDlo27HgFfE,53147
32
- sourcecode/integration_detector.py,sha256=ZJqrGwvZ4ee2JTGhlazKk67aZi173HxkhNpl8Yntpd8,6503
31
+ sourcecode/hibernate_strat.py,sha256=ZyI0sikxh7IemU8uqn1Z73z-tHeFeCJuYhJnyQL8pzU,57765
32
+ sourcecode/integration_detector.py,sha256=mQulsXN1P-4V_3ueh3xy_N9x3Aqlvm7GQ-3YGqlBEYM,8223
33
33
  sourcecode/license.py,sha256=wckiLuiwaE35KMCStUf1gYzleJuFe6qsSyxUQJvit3s,23500
34
34
  sourcecode/mcp_nudge.py,sha256=5ELU_ixzh6uA83NXLOZT8h00OhL53okfQdji3jyKOjg,2917
35
35
  sourcecode/metrics_analyzer.py,sha256=m0ENgtqKeBL17kUIK3fmGkgo7UfXBNHxCMj0H_Y5K7c,22750
36
- sourcecode/migrate_check.py,sha256=iHQou8w8hAjSrHmiYpcz2NMyHSatdu-6vbHbyf-kFrs,70268
36
+ sourcecode/migrate_check.py,sha256=daZLA7KaeoWoUPSMN8cYqHYD6HBflzc-z_N4bCGVPak,82993
37
37
  sourcecode/openapi_surface.py,sha256=BTt0K-woZbkbWTN77IkqeBm_Okag9owR0848fmot8sk,16207
38
38
  sourcecode/output_budget.py,sha256=Js9yUlfQtPhqBl9R6wn_9UHVjjJc3GtLcqyfjf5t50Q,9869
39
- sourcecode/path_filters.py,sha256=EN1RGZRvLq5EcPgpjYV_IyCKVlAQQn2bbpEisQ5LpGg,3780
39
+ sourcecode/path_filters.py,sha256=DMea0KIlmj2pzTkuy-3YYFF4ktP0q5zP9mH4v4uXh84,5237
40
40
  sourcecode/pr_comment_renderer.py,sha256=smHslxiG14lrytCkq5nFrFu-qTHgA-t-LFYfdrfjz2o,14423
41
41
  sourcecode/pr_impact.py,sha256=dCDVw83EDbyVf6F9ZmEQmsFz8ruVH7d4mpeKQCIZHM0,16805
42
42
  sourcecode/prepare_context.py,sha256=PsRaKeABMh964niCS578WRgcccW9bajnm2yHS9zcKuM,222655
@@ -53,7 +53,7 @@ sourcecode/scanner.py,sha256=WdOQ78mMzjR1NjmKTlbxdgwinnCTfAhxCVLBEFQiFHU,8899
53
53
  sourcecode/schema.py,sha256=aHNXDf8LGyUC8ZDE_VS9kiskC2-Oswhi_WnpdGy6HDw,24897
54
54
  sourcecode/security_config.py,sha256=_m8oQAy2gP7Ho2I-IIMTFFjFVWbJIGlLEOiAWK2TDjs,3503
55
55
  sourcecode/semantic_analyzer.py,sha256=4OdG6tTSnTvq3_dSWMbQu8Ad1ndSCKeG-b9qM4hIxkw,89176
56
- sourcecode/serializer.py,sha256=6YoGyRqkjtp34ELwgRGCZ1r1b6zu6SqiAWT8ySjs-M8,126516
56
+ sourcecode/serializer.py,sha256=MSxYZ-_UYDPKMvg-hVk-MnKN-TfrmxXL202siCssa9U,129110
57
57
  sourcecode/spring_event_topology.py,sha256=5_ON_21Le5zbG-1GRc5GLIi5HJfy_QjcXLVPC5WeUGQ,18055
58
58
  sourcecode/spring_findings.py,sha256=G7Or2lKBUQbcTDqudLvSs9XvNg_YoAa-_lBOG_ULs8E,5457
59
59
  sourcecode/spring_impact.py,sha256=qLwLfItX_o9LU-k_qjhD2hFpTX3PpEQ85TsYTAArzvg,56016
@@ -103,8 +103,8 @@ sourcecode/telemetry/consent.py,sha256=H5z2Wu63pZqbaKucRPoJQJ0zCo4cke9ZlBrJC-MaW
103
103
  sourcecode/telemetry/events.py,sha256=LtzYfaX9Ilckj5PTvAcTpDa9mLqDsYPDUiDkRa58piY,2580
104
104
  sourcecode/telemetry/filters.py,sha256=NHa5T-6DaZduQPFuC34jOqHWQgSizM-Ygq8aZ4j19ng,5834
105
105
  sourcecode/telemetry/transport.py,sha256=4gGHsq0WeY9VywEZXA3vUxykfiYnw9uuqfjAAec7F8o,1681
106
- sourcecode-1.64.0.dist-info/METADATA,sha256=qYsbbDRRWaCjq-T81IN2j3-u0i29R6XmvuSHT0s70FE,43969
107
- sourcecode-1.64.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
108
- sourcecode-1.64.0.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
109
- sourcecode-1.64.0.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
110
- sourcecode-1.64.0.dist-info/RECORD,,
106
+ sourcecode-1.66.0.dist-info/METADATA,sha256=PVY4maYclqHt2AKj_bcGmDzF7xmKhD0i7k2MrQepPKo,47341
107
+ sourcecode-1.66.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
108
+ sourcecode-1.66.0.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
109
+ sourcecode-1.66.0.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
110
+ sourcecode-1.66.0.dist-info/RECORD,,