sourcecode 1.60.0__py3-none-any.whl → 1.62.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.60.0"
3
+ __version__ = "1.62.0"
sourcecode/cli.py CHANGED
@@ -4838,6 +4838,13 @@ def migrate_check_cmd(
4838
4838
  MIG-007 javax.inject import (MEDIUM)
4839
4839
  MIG-008 javax.ws.rs import (MEDIUM — JAX-RS changed)
4840
4840
 
4841
+ \b
4842
+ Hibernate 5→6 stratification (in the 'hibernate' output section):
4843
+ 4 independent layers (JPA annotations / Criteria / HQL / SPI), a per-layer
4844
+ risk matrix, a module exposure map, critical call-chain detection, and an
4845
+ UPGRADE vs REWRITE verdict (dynamic Criteria, custom SPI, reflection-built
4846
+ queries, or concatenated query strings force the REWRITE classification).
4847
+
4841
4848
  \b
4842
4849
  Examples:
4843
4850
  sourcecode migrate-check .
@@ -0,0 +1,599 @@
1
+ """hibernate_strat.py — Hibernate 5.x → 6.x migration stratification model.
2
+
3
+ A Hibernate major upgrade is NOT a single dependency bump for enterprise systems
4
+ that use dynamic persistence. This module splits Hibernate exposure into four
5
+ independent migration domains and classifies each one on its own risk axis:
6
+
7
+ 1. JPA Annotation Layer — LOW (unless deprecated annotations exist)
8
+ 2. Criteria API Layer — HIGH, escalates to CRITICAL when built
9
+ dynamically via reflection / abstraction DAOs
10
+ 3. HQL / String-based Queries — MEDIUM, escalates to HIGH on concatenation
11
+ 4. Hibernate SPI / Internal — CRITICAL BLOCKER (UserType, Interceptor, SPI)
12
+
13
+ It also produces:
14
+ - a module-level Hibernate exposure map,
15
+ - critical call-chain detection (DynamicEntityDao, BasicPersistenceModule, …),
16
+ - stop-condition logic that decides UPGRADE ZONE vs REWRITE ZONE,
17
+ - separation of observable code risk from inferred runtime risk.
18
+
19
+ Entry point: analyze_hibernate(file_paths, root) → HibernateStratification
20
+
21
+ This is purely additive: it does not aggregate Hibernate into a single score, and
22
+ it does not assume that a Spring Boot upgrade resolves ORM risk.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import re
27
+ from dataclasses import dataclass, field
28
+ from pathlib import Path
29
+ from typing import Optional
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Layer identifiers
34
+ # ---------------------------------------------------------------------------
35
+
36
+ LAYER_JPA = "jpa_annotations"
37
+ LAYER_CRITERIA = "criteria_api"
38
+ LAYER_HQL = "hql_string_queries"
39
+ LAYER_SPI = "hibernate_spi_internal"
40
+
41
+ _LAYER_TITLES: dict[str, str] = {
42
+ LAYER_JPA: "JPA Annotation Layer",
43
+ LAYER_CRITERIA: "Criteria API Layer",
44
+ LAYER_HQL: "HQL / String-based Queries",
45
+ LAYER_SPI: "Hibernate SPI / Internal API",
46
+ }
47
+
48
+ # Classification verdicts
49
+ CLASS_NONE = "none"
50
+ CLASS_UPGRADE = "upgrade_zone"
51
+ CLASS_UPGRADE_CARE = "upgrade_with_care"
52
+ CLASS_REWRITE = "rewrite_zone"
53
+
54
+ _CLASS_LABELS: dict[str, str] = {
55
+ CLASS_NONE: "NO HIBERNATE USAGE DETECTED",
56
+ CLASS_UPGRADE: "HIBERNATE 6 MIGRATION IS UPGRADE ZONE (dependency bump + spot fixes)",
57
+ CLASS_UPGRADE_CARE: "HIBERNATE 6 MIGRATION IS UPGRADE-WITH-CARE ZONE (static queries to revalidate)",
58
+ CLASS_REWRITE: "HIBERNATE 6 MIGRATION IS HIGH RISK REWRITE ZONE (NOT UPGRADE ZONE)",
59
+ }
60
+
61
+ _SEVERITY_RANK: dict[str, int] = {"critical": 0, "high": 1, "medium": 2, "low": 3}
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Detection patterns
66
+ # ---------------------------------------------------------------------------
67
+
68
+ # Layer 1 — standard JPA annotations (LOW baseline).
69
+ _JPA_ANNOTATION_RE = re.compile(
70
+ r"@(Entity|Table|OneToMany|ManyToOne|OneToOne|ManyToMany|Column|"
71
+ r"Id|GeneratedValue|MappedSuperclass|Embeddable|Embedded|JoinColumn|"
72
+ r"JoinTable|Inheritance|DiscriminatorColumn)\b"
73
+ )
74
+ # Hibernate / JPA annotations deprecated or reworked in Hibernate 6 (escalate).
75
+ _JPA_DEPRECATED_RE = re.compile(
76
+ r"@TypeDef\b|@TypeDefs\b|"
77
+ r"org\.hibernate\.annotations\.Type\b|"
78
+ r"@Type\s*\(\s*type\s*=|"
79
+ r"@GenericGenerator\b|"
80
+ r"@org\.hibernate\.annotations\.Entity\b"
81
+ )
82
+
83
+ # Layer 2 — JPA Criteria API + legacy Hibernate Criteria.
84
+ _CRITERIA_JPA_RE = re.compile(
85
+ r"\bCriteriaBuilder\b|\bCriteriaQuery\b|\bCriteriaUpdate\b|"
86
+ r"\bCriteriaDelete\b|\bRoot\s*<|\bPredicate\b|\.criteria\b|"
87
+ r"persistence\.criteria"
88
+ )
89
+ _CRITERIA_LEGACY_RE = re.compile(
90
+ r"org\.hibernate\.Criteria\b|\.createCriteria\s*\(|\bDetachedCriteria\b|"
91
+ r"\bRestrictions\.|\bProjections\.|\bCriterion\b|"
92
+ r"\bConjunction\b|\bDisjunction\b"
93
+ )
94
+
95
+ # Layer 3 — HQL / native string queries. createQuery requires a string literal so
96
+ # CriteriaBuilder.createQuery(Class) (Layer 2) is not double-counted here.
97
+ _HQL_RE = re.compile(
98
+ r"\.createQuery\s*\(\s*\"|"
99
+ r"\.(createSQLQuery|createNativeQuery|getNamedQuery|createNamedQuery)\s*\("
100
+ )
101
+ # String concatenation inside a query call → dynamic SQL shape (HIGH).
102
+ _HQL_CONCAT_RE = re.compile(
103
+ r"\.(?:createQuery|createSQLQuery|createNativeQuery)\s*\("
104
+ r"[^;]{0,400}?(?:\"[^\"]*\"\s*\+|\+\s*\"[^\"]*\"|\+\s*\w+\s*\+)",
105
+ re.DOTALL,
106
+ )
107
+
108
+ # Layer 4 — Hibernate SPI / internal API (CRITICAL blocker).
109
+ _SPI_RE = re.compile(
110
+ r"\b(?:implements|extends)\s+\w*(?:UserType|CompositeUserType|UserCollectionType)\b|"
111
+ r"\bimplements\s+\w*UserType\b|"
112
+ r"\borg\.hibernate\.(?:type|engine|internal|persister|metamodel|"
113
+ r"boot\.spi|boot\.internal|event|tuple|property\.access|loader|sql\.ast)\b|"
114
+ r"\bEmptyInterceptor\b|"
115
+ r"\bimplements\s+\w*Interceptor\b|"
116
+ r"\b(?:implements|extends)\s+\w*EventListener\b|"
117
+ r"\bSessionFactoryImpl\b|\bSessionImplementor\b|"
118
+ r"\bSharedSessionContractImplementor\b|"
119
+ r"\bsetPropertyAccessStrategy\b|\bPropertyAccessStrategy\b|"
120
+ r"\bImplicitNamingStrategy\b|\bPhysicalNamingStrategy\b"
121
+ )
122
+
123
+ # Escalation markers — dynamic / reflection-based persistence construction.
124
+ _ABSTRACTION_CLASS_RE = re.compile(
125
+ r"\b(DynamicEntityDao|DynamicEntityDaoImpl|BasicPersistenceModule|"
126
+ r"PersistenceModule|GenericDao|GenericEntityDao|GenericEntityService|"
127
+ r"DynamicDaoHelper|CriteriaTranslator|FieldManager|EntityMetadata)\w*\b"
128
+ )
129
+ _REFLECTION_RE = re.compile(
130
+ r"Class\.forName\s*\(|\.getDeclaredField|\.getDeclaredMethod|"
131
+ r"\.getDeclaredFields\s*\(|\.newInstance\s*\(|Method\.invoke|"
132
+ r"java\.lang\.reflect|\bField\[\]|metadata\.get|buildCriteria|"
133
+ r"\.getMetamodel\s*\("
134
+ )
135
+
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # Data model
139
+ # ---------------------------------------------------------------------------
140
+
141
+ @dataclass
142
+ class HibernateFinding:
143
+ layer: str
144
+ severity: str
145
+ pattern: str # short human label of what matched
146
+ source_file: str
147
+ first_line: int
148
+ module: str
149
+ occurrences: int = 1
150
+ dynamic: bool = False # escalated via reflection / abstraction DAO context
151
+
152
+ def to_dict(self) -> dict:
153
+ return {
154
+ "layer": self.layer,
155
+ "severity": self.severity,
156
+ "pattern": self.pattern,
157
+ "source_file": self.source_file,
158
+ "first_line": self.first_line,
159
+ "module": self.module,
160
+ "occurrences": self.occurrences,
161
+ "dynamic": self.dynamic,
162
+ }
163
+
164
+
165
+ @dataclass
166
+ class HibernateLayerRisk:
167
+ layer: str
168
+ risk: str
169
+ reason: str
170
+ estimated_effort: str
171
+ file_count: int
172
+ occurrence_count: int
173
+
174
+ def to_dict(self) -> dict:
175
+ return {
176
+ "layer": self.layer,
177
+ "layer_title": _LAYER_TITLES.get(self.layer, self.layer),
178
+ "risk": self.risk,
179
+ "reason": self.reason,
180
+ "estimated_effort": self.estimated_effort,
181
+ "file_count": self.file_count,
182
+ "occurrence_count": self.occurrence_count,
183
+ }
184
+
185
+
186
+ @dataclass
187
+ class HibernateStratification:
188
+ detected: bool = False
189
+ classification: str = CLASS_NONE
190
+ classification_label: str = _CLASS_LABELS[CLASS_NONE]
191
+ risk_matrix: list[HibernateLayerRisk] = field(default_factory=list)
192
+ module_exposure: dict[str, dict] = field(default_factory=dict)
193
+ incompatible_patterns: list[str] = field(default_factory=list)
194
+ critical_call_chains: list[dict] = field(default_factory=list)
195
+ stop_conditions_triggered: list[str] = field(default_factory=list)
196
+ observable_risk: list[str] = field(default_factory=list)
197
+ inferred_runtime_risk: list[str] = field(default_factory=list)
198
+ findings: list[HibernateFinding] = field(default_factory=list)
199
+
200
+ def to_dict(self) -> dict:
201
+ return {
202
+ "detected": self.detected,
203
+ "classification": self.classification,
204
+ "classification_label": self.classification_label,
205
+ "stratified": True,
206
+ "risk_matrix": [r.to_dict() for r in self.risk_matrix],
207
+ "module_exposure_map": self.module_exposure,
208
+ "incompatible_patterns": self.incompatible_patterns,
209
+ "critical_call_chains": self.critical_call_chains,
210
+ "stop_conditions_triggered": self.stop_conditions_triggered,
211
+ "risk_separation": {
212
+ "observable_code_risk": self.observable_risk,
213
+ "inferred_runtime_risk": self.inferred_runtime_risk,
214
+ },
215
+ "findings": [f.to_dict() for f in self.findings],
216
+ }
217
+
218
+ def to_text(self) -> str:
219
+ if not self.detected:
220
+ return "Hibernate Migration: no Hibernate/JPA persistence usage detected."
221
+
222
+ lines: list[str] = [
223
+ "── Hibernate 5.x → 6.x Migration Stratification ──",
224
+ f"Classification: {self.classification_label}",
225
+ "",
226
+ "Hibernate Migration Impact Matrix:",
227
+ f" {'Layer':<30} {'Risk':<9} Effort Reason",
228
+ ]
229
+ for r in self.risk_matrix:
230
+ lines.append(
231
+ f" {_LAYER_TITLES.get(r.layer, r.layer):<30} "
232
+ f"{r.risk.upper():<9} {r.estimated_effort:<13} {r.reason}"
233
+ )
234
+
235
+ lines.append("")
236
+ lines.append("Module Hibernate Exposure Map:")
237
+ if self.module_exposure:
238
+ for mod, info in sorted(
239
+ self.module_exposure.items(),
240
+ key=lambda kv: _SEVERITY_RANK.get(kv[1].get("max_risk", "low"), 3),
241
+ ):
242
+ tags = []
243
+ if info.get("criteria_dynamic"):
244
+ tags.append("dynamic-criteria")
245
+ if info.get("has_spi"):
246
+ tags.append("custom-SPI")
247
+ if info.get("has_reflection"):
248
+ tags.append("reflection")
249
+ tag_str = f" [{', '.join(tags)}]" if tags else ""
250
+ layers = ", ".join(sorted(info.get("layers", {}).keys()))
251
+ lines.append(
252
+ f" {mod:<40} {info.get('max_risk', 'low').upper():<9} "
253
+ f"{layers}{tag_str}"
254
+ )
255
+ else:
256
+ lines.append(" (no module attribution)")
257
+
258
+ if self.critical_call_chains:
259
+ lines.append("")
260
+ lines.append("Critical Call Chains (dynamic / reflection-based persistence):")
261
+ for cc in self.critical_call_chains:
262
+ lines.append(
263
+ f" {cc['class']} ({cc['source_file']}:{cc['first_line']}) — {cc['reason']}"
264
+ )
265
+
266
+ if self.incompatible_patterns:
267
+ lines.append("")
268
+ lines.append("Incompatible / High-risk Patterns Found:")
269
+ for p in self.incompatible_patterns:
270
+ lines.append(f" - {p}")
271
+
272
+ if self.stop_conditions_triggered:
273
+ lines.append("")
274
+ lines.append("Stop Conditions Triggered (force REWRITE classification):")
275
+ for s in self.stop_conditions_triggered:
276
+ lines.append(f" ! {s}")
277
+
278
+ lines.append("")
279
+ lines.append("Risk Separation:")
280
+ lines.append(" Observable code risk:")
281
+ for o in (self.observable_risk or [" (none)"]):
282
+ lines.append(f" - {o}")
283
+ lines.append(" Inferred runtime risk:")
284
+ for o in (self.inferred_runtime_risk or [" (none)"]):
285
+ lines.append(f" - {o}")
286
+
287
+ return "\n".join(lines)
288
+
289
+
290
+ # ---------------------------------------------------------------------------
291
+ # Helpers
292
+ # ---------------------------------------------------------------------------
293
+
294
+ def _module_of(rel_path: str) -> str:
295
+ """Derive a Maven/Gradle module name from a source path.
296
+
297
+ Uses the directory segment immediately before '/src/' (the module root). Falls
298
+ back to the first path segment, or 'root' for top-level files.
299
+ """
300
+ norm = rel_path.replace("\\", "/")
301
+ parts = norm.split("/")
302
+ for i, seg in enumerate(parts):
303
+ if seg == "src" and i > 0:
304
+ return parts[i - 1]
305
+ if len(parts) > 1:
306
+ return parts[0]
307
+ return "root"
308
+
309
+
310
+ def _first_line(source: str, match: re.Match) -> int:
311
+ return source[: match.start()].count("\n") + 1
312
+
313
+
314
+ def _count(pattern: re.Pattern, source: str) -> int:
315
+ return sum(1 for _ in pattern.finditer(source))
316
+
317
+
318
+ # ---------------------------------------------------------------------------
319
+ # Effort heuristics
320
+ # ---------------------------------------------------------------------------
321
+
322
+ def _effort_bucket(file_count: int, base_days_per_file: float, floor: float = 0.5) -> str:
323
+ days = round(max(floor, file_count * base_days_per_file), 1) if file_count else 0.0
324
+ return f"~{days}d"
325
+
326
+
327
+ # ---------------------------------------------------------------------------
328
+ # Public entry point
329
+ # ---------------------------------------------------------------------------
330
+
331
+ def analyze_hibernate(file_paths: list[str], root: Path) -> HibernateStratification:
332
+ """Scan Java sources for stratified Hibernate 5→6 migration risk."""
333
+ findings: list[HibernateFinding] = []
334
+ # module -> aggregation accumulator
335
+ modules: dict[str, dict] = {}
336
+ call_chains: list[dict] = []
337
+
338
+ layer_files: dict[str, set[str]] = {
339
+ LAYER_JPA: set(), LAYER_CRITERIA: set(), LAYER_HQL: set(), LAYER_SPI: set()
340
+ }
341
+ layer_occ: dict[str, int] = {
342
+ LAYER_JPA: 0, LAYER_CRITERIA: 0, LAYER_HQL: 0, LAYER_SPI: 0
343
+ }
344
+ jpa_deprecated_seen = False
345
+ criteria_dynamic_global = False
346
+ spi_global = False
347
+ reflection_query_global = False
348
+ concat_query_global = False
349
+
350
+ incompatible: set[str] = set()
351
+
352
+ for rel_path in file_paths:
353
+ if not rel_path.endswith(".java"):
354
+ continue
355
+ abs_path = root / rel_path
356
+ try:
357
+ source = abs_path.read_text(encoding="utf-8", errors="replace")
358
+ except OSError:
359
+ continue
360
+
361
+ # Per-file escalation context.
362
+ has_abstraction = bool(_ABSTRACTION_CLASS_RE.search(source))
363
+ has_reflection = bool(_REFLECTION_RE.search(source))
364
+ module = _module_of(rel_path)
365
+ mod = modules.setdefault(
366
+ module,
367
+ {"layers": {}, "max_risk": "low", "criteria_dynamic": False,
368
+ "has_spi": False, "has_reflection": False, "files": set()},
369
+ )
370
+
371
+ def _record(layer: str, severity: str, pattern_label: str,
372
+ match: re.Match, occ: int, dynamic: bool = False) -> None:
373
+ findings.append(HibernateFinding(
374
+ layer=layer, severity=severity, pattern=pattern_label,
375
+ source_file=rel_path, first_line=_first_line(source, match),
376
+ module=module, occurrences=occ, dynamic=dynamic,
377
+ ))
378
+ layer_files[layer].add(rel_path)
379
+ layer_occ[layer] += occ
380
+ ml = mod["layers"].setdefault(layer, {"severity": severity, "occurrences": 0})
381
+ ml["occurrences"] += occ
382
+ if _SEVERITY_RANK[severity] < _SEVERITY_RANK[ml["severity"]]:
383
+ ml["severity"] = severity
384
+ if _SEVERITY_RANK[severity] < _SEVERITY_RANK[mod["max_risk"]]:
385
+ mod["max_risk"] = severity
386
+ mod["files"].add(rel_path)
387
+
388
+ if has_reflection:
389
+ mod["has_reflection"] = True
390
+
391
+ # ── Layer 1: JPA annotations ───────────────────────────────────────
392
+ m = _JPA_ANNOTATION_RE.search(source)
393
+ if m:
394
+ _record(LAYER_JPA, "low", "JPA mapping annotations",
395
+ m, _count(_JPA_ANNOTATION_RE, source))
396
+ dm = _JPA_DEPRECATED_RE.search(source)
397
+ if dm:
398
+ jpa_deprecated_seen = True
399
+ _record(LAYER_JPA, "high", "Deprecated Hibernate annotation (@Type/@TypeDef/@GenericGenerator)",
400
+ dm, _count(_JPA_DEPRECATED_RE, source))
401
+ incompatible.add("Deprecated Hibernate annotations (@Type(type=)/@TypeDef) — reworked in Hibernate 6")
402
+
403
+ # ── Layer 2: Criteria API ──────────────────────────────────────────
404
+ crit_m = _CRITERIA_JPA_RE.search(source)
405
+ legacy_m = _CRITERIA_LEGACY_RE.search(source)
406
+ crit_hit = crit_m or legacy_m
407
+ if crit_hit:
408
+ dynamic = has_abstraction or has_reflection
409
+ severity = "critical" if dynamic else "high"
410
+ occ = _count(_CRITERIA_JPA_RE, source) + _count(_CRITERIA_LEGACY_RE, source)
411
+ label = ("Dynamic Criteria construction (reflection/abstraction DAO)"
412
+ if dynamic else "Criteria API usage")
413
+ _record(LAYER_CRITERIA, severity, label, crit_hit, occ, dynamic=dynamic)
414
+ if dynamic:
415
+ criteria_dynamic_global = True
416
+ mod["criteria_dynamic"] = True
417
+ incompatible.add("Criteria built dynamically via reflection/abstraction layer "
418
+ "(Hibernate 6 incompatible patterns likely)")
419
+ if legacy_m:
420
+ incompatible.add("Legacy org.hibernate.Criteria / Restrictions / Projections "
421
+ "(removed in Hibernate 6 — must move to JPA CriteriaBuilder)")
422
+
423
+ # ── Layer 3: HQL / string queries ──────────────────────────────────
424
+ hql_m = _HQL_RE.search(source)
425
+ if hql_m:
426
+ concat_m = _HQL_CONCAT_RE.search(source)
427
+ if concat_m:
428
+ concat_query_global = True
429
+ _record(LAYER_HQL, "high",
430
+ "String-concatenated HQL/native query (dynamic SQL shape)",
431
+ concat_m, _count(_HQL_RE, source))
432
+ incompatible.add("String-concatenated HQL/native queries — HIGH risk under "
433
+ "Hibernate 6 ORM parser changes")
434
+ else:
435
+ _record(LAYER_HQL, "medium", "HQL / native string query",
436
+ hql_m, _count(_HQL_RE, source))
437
+
438
+ # ── Layer 4: Hibernate SPI / internal ──────────────────────────────
439
+ spi_m = _SPI_RE.search(source)
440
+ if spi_m:
441
+ spi_global = True
442
+ mod["has_spi"] = True
443
+ _record(LAYER_SPI, "critical", "Hibernate SPI / internal API (UserType/Interceptor/SPI)",
444
+ spi_m, _count(_SPI_RE, source))
445
+ incompatible.add("Custom Hibernate SPI (UserType/CompositeUserType/Interceptor/"
446
+ "EventListener) — primary Hibernate 5→6 breakage source")
447
+
448
+ # ── Critical call-chain detection ──────────────────────────────────
449
+ if has_abstraction and (crit_hit or has_reflection):
450
+ am = _ABSTRACTION_CLASS_RE.search(source)
451
+ reason_bits = []
452
+ if crit_hit:
453
+ reason_bits.append("Criteria construction")
454
+ if has_reflection:
455
+ reason_bits.append("reflection-based entity metadata")
456
+ reflection_query_global = True
457
+ call_chains.append({
458
+ "class": am.group(0),
459
+ "source_file": rel_path,
460
+ "first_line": _first_line(source, am),
461
+ "reason": "dynamic query generation: " + " + ".join(reason_bits),
462
+ "module": module,
463
+ })
464
+
465
+ strat = HibernateStratification()
466
+ strat.findings = findings
467
+
468
+ if not findings:
469
+ strat.detected = False
470
+ strat.classification = CLASS_NONE
471
+ strat.classification_label = _CLASS_LABELS[CLASS_NONE]
472
+ return strat
473
+
474
+ strat.detected = True
475
+ strat.critical_call_chains = call_chains
476
+ strat.incompatible_patterns = sorted(incompatible)
477
+
478
+ # ── Build per-layer risk matrix ────────────────────────────────────────
479
+ matrix: list[HibernateLayerRisk] = []
480
+
481
+ # Layer 1
482
+ if layer_files[LAYER_JPA]:
483
+ if jpa_deprecated_seen:
484
+ risk, reason = "high", ("Deprecated Hibernate annotations present "
485
+ "(@Type(type=)/@TypeDef/@GenericGenerator reworked in H6)")
486
+ effort = _effort_bucket(len(layer_files[LAYER_JPA]), 0.25)
487
+ else:
488
+ risk, reason = "low", ("Standard JPA mapping only — namespace handled by "
489
+ "jakarta migration, no Hibernate-6 annotation breakage")
490
+ effort = _effort_bucket(len(layer_files[LAYER_JPA]), 0.02, floor=0.2)
491
+ matrix.append(HibernateLayerRisk(
492
+ LAYER_JPA, risk, reason, effort,
493
+ len(layer_files[LAYER_JPA]), layer_occ[LAYER_JPA]))
494
+
495
+ # Layer 2
496
+ if layer_files[LAYER_CRITERIA]:
497
+ if criteria_dynamic_global:
498
+ risk, reason = "critical", ("Criteria constructed dynamically via reflection / "
499
+ "abstraction DAOs — Hibernate 6 rewrite zone")
500
+ effort = _effort_bucket(len(layer_files[LAYER_CRITERIA]), 0.75)
501
+ else:
502
+ risk, reason = "high", ("Criteria API in use — JPA Criteria semantics changed; "
503
+ "legacy org.hibernate.Criteria removed in H6")
504
+ effort = _effort_bucket(len(layer_files[LAYER_CRITERIA]), 0.4)
505
+ matrix.append(HibernateLayerRisk(
506
+ LAYER_CRITERIA, risk, reason, effort,
507
+ len(layer_files[LAYER_CRITERIA]), layer_occ[LAYER_CRITERIA]))
508
+
509
+ # Layer 3
510
+ if layer_files[LAYER_HQL]:
511
+ if concat_query_global:
512
+ risk, reason = "high", ("String-concatenated / dynamic queries — HIGH risk under "
513
+ "Hibernate 6 HQL parser changes")
514
+ effort = _effort_bucket(len(layer_files[LAYER_HQL]), 0.3)
515
+ else:
516
+ risk, reason = "medium", ("Static HQL / native queries — revalidate against "
517
+ "Hibernate 6 parser; mostly mechanical")
518
+ effort = _effort_bucket(len(layer_files[LAYER_HQL]), 0.15)
519
+ matrix.append(HibernateLayerRisk(
520
+ LAYER_HQL, risk, reason, effort,
521
+ len(layer_files[LAYER_HQL]), layer_occ[LAYER_HQL]))
522
+
523
+ # Layer 4
524
+ if layer_files[LAYER_SPI]:
525
+ risk, reason = "critical", ("Custom Hibernate SPI/internal API (UserType, Interceptor, "
526
+ "EventListener, engine.spi) — primary H5→H6 breakage")
527
+ effort = _effort_bucket(len(layer_files[LAYER_SPI]), 1.0)
528
+ matrix.append(HibernateLayerRisk(
529
+ LAYER_SPI, risk, reason, effort,
530
+ len(layer_files[LAYER_SPI]), layer_occ[LAYER_SPI]))
531
+
532
+ strat.risk_matrix = matrix
533
+
534
+ # ── Module exposure map: serialize sets ────────────────────────────────
535
+ exposure: dict[str, dict] = {}
536
+ for mod_name, info in modules.items():
537
+ exposure[mod_name] = {
538
+ "max_risk": info["max_risk"],
539
+ "criteria_dynamic": info["criteria_dynamic"],
540
+ "has_spi": info["has_spi"],
541
+ "has_reflection": info["has_reflection"],
542
+ "file_count": len(info["files"]),
543
+ "layers": {
544
+ layer: {"severity": d["severity"], "occurrences": d["occurrences"]}
545
+ for layer, d in info["layers"].items()
546
+ },
547
+ }
548
+ strat.module_exposure = exposure
549
+
550
+ # ── Stop-condition logic → upgrade vs rewrite classification ────────────
551
+ stops: list[str] = []
552
+ if criteria_dynamic_global:
553
+ stops.append("Criteria API used dynamically / via abstraction layers "
554
+ "(DynamicEntityDao, GenericDao, reflection)")
555
+ if spi_global:
556
+ stops.append("Custom Hibernate SPI present (UserType / Interceptor / EventListener / "
557
+ "engine.spi internals)")
558
+ if reflection_query_global:
559
+ stops.append("Queries generated via reflection / runtime entity metadata")
560
+ if concat_query_global:
561
+ stops.append("SQL/HQL shape not statically inferable (string concatenation)")
562
+ strat.stop_conditions_triggered = stops
563
+
564
+ if stops:
565
+ strat.classification = CLASS_REWRITE
566
+ elif layer_files[LAYER_CRITERIA] or concat_query_global or jpa_deprecated_seen:
567
+ strat.classification = CLASS_UPGRADE_CARE
568
+ elif layer_files[LAYER_HQL] or layer_files[LAYER_JPA]:
569
+ strat.classification = CLASS_UPGRADE
570
+ else:
571
+ strat.classification = CLASS_UPGRADE
572
+ strat.classification_label = _CLASS_LABELS[strat.classification]
573
+
574
+ # ── Risk separation: observable vs inferred ─────────────────────────────
575
+ observable: list[str] = []
576
+ inferred: list[str] = []
577
+ if layer_files[LAYER_JPA]:
578
+ observable.append(f"JPA mapping annotations in {len(layer_files[LAYER_JPA])} file(s)")
579
+ if layer_files[LAYER_CRITERIA]:
580
+ observable.append(f"Criteria API references in {len(layer_files[LAYER_CRITERIA])} file(s)")
581
+ if layer_files[LAYER_HQL]:
582
+ observable.append(f"HQL/native query calls in {len(layer_files[LAYER_HQL])} file(s)")
583
+ if layer_files[LAYER_SPI]:
584
+ observable.append(f"Hibernate SPI/internal API in {len(layer_files[LAYER_SPI])} file(s)")
585
+
586
+ if criteria_dynamic_global or reflection_query_global:
587
+ inferred.append("Runtime-generated query shapes (reflection/metadata) — exact SQL "
588
+ "cannot be statically enumerated; breakage surface is larger than "
589
+ "observable call sites")
590
+ if spi_global:
591
+ inferred.append("Custom SPI hooks fire at runtime across the persistence lifecycle — "
592
+ "behavioural breakage may not be visible at the call site")
593
+ if concat_query_global:
594
+ inferred.append("Concatenated query strings resolve at runtime — H6 HQL parser changes "
595
+ "may break queries not reachable by static inspection")
596
+ strat.observable_risk = observable
597
+ strat.inferred_runtime_risk = inferred
598
+
599
+ return strat
@@ -1415,9 +1415,12 @@ Rules detected:
1415
1415
  MIG-007 medium — javax.inject imports (DI annotations)
1416
1416
  MIG-008 medium — javax.ws.rs imports (JAX-RS API)
1417
1417
 
1418
- Returns: schema_version, readiness_score (0–100; 100=ready to migrate), blocking_count,
1419
- estimated_effort_days, spring_boot_2_detected, summary (total_findings, affected_files,
1420
- by_severity, by_rule), findings[], limitations, metadata.
1418
+ Returns: schema_version, readiness_score (0–100; 100=ready to migrate),
1419
+ jakarta_readiness / boot3_readiness / jdk_modernization (per-dimension 0–100),
1420
+ blocking_count, estimated_effort_days, spring_boot_2_detected (true|false|null
1421
+ null=undetermined, never assumed true), spring_boot_version_detected,
1422
+ summary (total_findings, affected_files, by_severity, by_rule), findings[],
1423
+ limitations, metadata.
1421
1424
  findings fields: id, rule_id, severity, title, source_file, first_line,
1422
1425
  imports_found, explanation, fix_hint.
1423
1426
 
sourcecode/mcp/server.py CHANGED
@@ -792,7 +792,10 @@ def get_migration_readiness(repo_path: str = ".", min_severity: str = "low") ->
792
792
 
793
793
  Maps to: sourcecode migrate-check <repo_path> --min-severity <min_severity>
794
794
  Returns: MigrationReport with schema_version, readiness_score (0–100; 100=ready to migrate),
795
- blocking_count, estimated_effort_days, spring_boot_2_detected,
795
+ jakarta_readiness / boot3_readiness / jdk_modernization (per-dimension 0–100),
796
+ blocking_count, estimated_effort_days,
797
+ spring_boot_2_detected (true|false|null — null=undetermined, never assumed true),
798
+ spring_boot_version_detected,
796
799
  summary (total_findings, affected_files, by_severity, by_rule),
797
800
  findings[], limitations, metadata.
798
801
  findings fields: id, rule_id, severity, title, source_file, first_line,
@@ -6,6 +6,8 @@ that must be addressed when migrating:
6
6
  - Java 8 → 17 / 21 (SecurityManager, Nashorn, Unsafe, reflection, etc.)
7
7
  - XML Spring config (applicationContext.xml, web.xml, security XML)
8
8
  - Dependency incompatibilities (SpringFox, Hibernate 5, ByteBuddy old)
9
+ - Hibernate 5→6 stratified migration model (see hibernate_strat) — 4 independent
10
+ layers, module exposure map, call-chain detection, upgrade-vs-rewrite verdict
9
11
 
10
12
  Entry point: run_migrate_check(file_paths, root) → MigrationReport
11
13
  """
@@ -18,7 +20,10 @@ import re
18
20
  from dataclasses import dataclass, field
19
21
  from datetime import datetime, timezone
20
22
  from pathlib import Path
21
- from typing import Optional
23
+ from typing import Optional, TYPE_CHECKING
24
+
25
+ if TYPE_CHECKING:
26
+ from sourcecode.hibernate_strat import HibernateStratification
22
27
 
23
28
 
24
29
  # ---------------------------------------------------------------------------
@@ -541,6 +546,26 @@ SEVERITY_ORDER: dict[str, int] = {"critical": 0, "high": 1, "medium": 2, "low":
541
546
  # headline on a repo with zero blockers. See MigrationReport.finalize.
542
547
  _LOW_SEVERITY_DEDUCTION_CAP: int = 15
543
548
 
549
+ # Migration targets that block a Spring Boot 2→3 upgrade (namespace + security).
550
+ # Everything else (java_8_best_practice, java_9_plus, java_11/15/17/18+) is
551
+ # orthogonal JDK modernization debt and must not sink the migration headline.
552
+ _JAKARTA_TARGETS: frozenset[str] = frozenset({"jakarta"})
553
+ _BOOT3_MIGRATION_TARGETS: frozenset[str] = frozenset(
554
+ {"jakarta", "spring_boot_3", "spring_security_6"}
555
+ )
556
+ # Cap on total readiness deduction from JDK-modernization findings (medium/low),
557
+ # so reflection/date cleanups cannot collapse a jakarta-ready repo's headline.
558
+ _JDK_ADVISORY_DEDUCTION_CAP: int = 15
559
+
560
+ # Jakarta EE 9+ namespace imports — strong evidence the repo is already on
561
+ # Boot 3 / Jakarta. Used to veto a false spring_boot_2_detected verdict.
562
+ _JAKARTA_IMPORT_RE: re.Pattern = re.compile(
563
+ r"^[ \t]*import\s+jakarta\.(?:persistence|servlet|validation|annotation|"
564
+ r"transaction|inject|ws\.rs|jms|ejb|mail|websocket|faces|enterprise|batch|"
565
+ r"json|el|security)\b",
566
+ re.MULTILINE,
567
+ )
568
+
544
569
 
545
570
  # ---------------------------------------------------------------------------
546
571
  # XML config rules (applied to Spring XML config files)
@@ -979,22 +1004,72 @@ class MigrationFinding:
979
1004
  # Report
980
1005
  # ---------------------------------------------------------------------------
981
1006
 
1007
+ def _dimension_score(
1008
+ findings: list["MigrationFinding"],
1009
+ targets: "Optional[frozenset[str]]",
1010
+ ) -> int:
1011
+ """0-100 readiness for one migration dimension.
1012
+
1013
+ targets=None scores the JDK-modernization dimension (every target NOT in the
1014
+ Boot 2→3 migration set). Otherwise scores only findings whose migration_target
1015
+ is in `targets`. Severity-weighted by distinct file (low capped, G-1).
1016
+ """
1017
+ crit: set[str] = set()
1018
+ high: set[str] = set()
1019
+ med: set[str] = set()
1020
+ low: set[str] = set()
1021
+ for f in findings:
1022
+ if targets is None:
1023
+ if f.migration_target in _BOOT3_MIGRATION_TARGETS:
1024
+ continue
1025
+ elif f.migration_target not in targets:
1026
+ continue
1027
+ if f.severity == "critical":
1028
+ crit.add(f.source_file)
1029
+ elif f.severity == "high":
1030
+ high.add(f.source_file)
1031
+ elif f.severity == "medium":
1032
+ med.add(f.source_file)
1033
+ else:
1034
+ low.add(f.source_file)
1035
+ deduction = (
1036
+ len(crit) * 15
1037
+ + len(high) * 8
1038
+ + len(med) * 3
1039
+ + min(len(low) * 1, _LOW_SEVERITY_DEDUCTION_CAP)
1040
+ )
1041
+ return max(0, 100 - deduction)
1042
+
1043
+
982
1044
  @dataclass
983
1045
  class MigrationReport:
984
- schema_version: str = "1.2"
1046
+ schema_version: str = "1.4"
985
1047
  generated_at: str = ""
986
1048
  repo_id: str = ""
987
1049
  git_head: str = ""
988
1050
 
989
1051
  readiness_score: int = 100
1052
+ # Per-dimension readiness (0-100). javax→jakarta namespace, full Boot 2→3
1053
+ # migration, and orthogonal JDK modernization are scored independently so
1054
+ # JDK debt (java.util.Date, reflection) does not sink a jakarta-ready repo.
1055
+ jakarta_readiness: int = 100
1056
+ boot3_readiness: int = 100
1057
+ jdk_modernization: int = 100
990
1058
  blocking_count: int = 0
991
1059
  estimated_effort_days: float = 0.0
992
- spring_boot_2_detected: bool = False
1060
+ # Tri-state: True = Boot 2 confirmed, False = Boot 3+ confirmed,
1061
+ # None = could not determine. Absence of evidence is never reported as True.
1062
+ spring_boot_2_detected: Optional[bool] = None
1063
+ spring_boot_version_detected: Optional[str] = None
993
1064
 
994
1065
  findings: list[MigrationFinding] = field(default_factory=list)
995
1066
  limitations: list[str] = field(default_factory=list)
996
1067
  summary: dict = field(default_factory=dict)
997
1068
  metadata: dict = field(default_factory=dict)
1069
+ # Hibernate 5→6 stratified migration model (4 independent layers, module
1070
+ # exposure map, call-chain detection, upgrade-vs-rewrite verdict). Attached
1071
+ # by run_migrate_check; None when not computed.
1072
+ hibernate: Optional["HibernateStratification"] = None
998
1073
 
999
1074
  def finalize(self) -> "MigrationReport":
1000
1075
  if not self.generated_at:
@@ -1027,20 +1102,37 @@ class MigrationReport:
1027
1102
  else:
1028
1103
  low_files.add(f.source_file)
1029
1104
 
1030
- # G-1: low-severity findings are advisory (e.g. java.time modernization),
1031
- # not Boot-2→3 blockers. Cap their total contribution so a repo with zero
1032
- # blockers cannot score near zero on optional cleanups alone (mall: 96 low
1033
- # findings dragged readiness to 4/100 despite 0 blocking, already on Boot 3).
1034
- # Blockers (critical/high) stay uncapped a genuinely blocked repo still
1035
- # floors at 0 (shopizer: 301 blocking 0/100, unchanged).
1105
+ # BUG-2: aggregate readiness no longer collapses on raw JDK-modernization
1106
+ # volume. Blockers (critical/high, any target) stay uncapped so a genuinely
1107
+ # blocked repo floors at 0 (shopizer: 301 blocking 0). Medium/low are
1108
+ # split by target: migration-blocking (jakarta/boot3/security) count in
1109
+ # full (low capped, G-1); orthogonal JDK debt (Date/reflection) is capped
1110
+ # so it cannot sink a jakarta-ready repo (Broadleaf: 144 Date + 25 reflection
1111
+ # no longer force 0/100).
1112
+ mig_med = {f.source_file for f in self.findings
1113
+ if f.severity == "medium" and f.migration_target in _BOOT3_MIGRATION_TARGETS}
1114
+ mig_low = {f.source_file for f in self.findings
1115
+ if f.severity == "low" and f.migration_target in _BOOT3_MIGRATION_TARGETS}
1116
+ jdk_med = {f.source_file for f in self.findings
1117
+ if f.severity == "medium" and f.migration_target not in _BOOT3_MIGRATION_TARGETS}
1118
+ jdk_low = {f.source_file for f in self.findings
1119
+ if f.severity == "low" and f.migration_target not in _BOOT3_MIGRATION_TARGETS}
1120
+
1036
1121
  deduction = (
1037
1122
  len(critical_files) * 15
1038
1123
  + len(high_files) * 8
1039
- + len(medium_files) * 3
1040
- + min(len(low_files) * 1, _LOW_SEVERITY_DEDUCTION_CAP)
1124
+ + len(mig_med) * 3
1125
+ + min(len(mig_low) * 1, _LOW_SEVERITY_DEDUCTION_CAP)
1126
+ + min(len(jdk_med) * 3 + len(jdk_low) * 1, _JDK_ADVISORY_DEDUCTION_CAP)
1041
1127
  )
1042
1128
  self.readiness_score = max(0, 100 - deduction)
1043
1129
 
1130
+ # Per-dimension readiness — independent severity-weighted scores so the
1131
+ # output reveals that jakarta/Boot3 may be complete even with JDK debt.
1132
+ self.jakarta_readiness = _dimension_score(self.findings, _JAKARTA_TARGETS)
1133
+ self.boot3_readiness = _dimension_score(self.findings, _BOOT3_MIGRATION_TARGETS)
1134
+ self.jdk_modernization = _dimension_score(self.findings, None)
1135
+
1044
1136
  self.estimated_effort_days = round(
1045
1137
  len(critical_files) * 0.5
1046
1138
  + len(high_files) * 0.25
@@ -1065,11 +1157,16 @@ class MigrationReport:
1065
1157
  "repo_id": self.repo_id,
1066
1158
  "git_head": self.git_head,
1067
1159
  "readiness_score": self.readiness_score,
1160
+ "jakarta_readiness": self.jakarta_readiness,
1161
+ "boot3_readiness": self.boot3_readiness,
1162
+ "jdk_modernization": self.jdk_modernization,
1068
1163
  "blocking_count": self.blocking_count,
1069
1164
  "estimated_effort_days": self.estimated_effort_days,
1070
1165
  "spring_boot_2_detected": self.spring_boot_2_detected,
1166
+ "spring_boot_version_detected": self.spring_boot_version_detected,
1071
1167
  "summary": self.summary,
1072
1168
  "findings": [f.to_dict() for f in self.findings],
1169
+ "hibernate": self.hibernate.to_dict() if self.hibernate is not None else None,
1073
1170
  "limitations": self.limitations,
1074
1171
  "metadata": self.metadata,
1075
1172
  }
@@ -1078,8 +1175,18 @@ class MigrationReport:
1078
1175
  min_order = SEVERITY_ORDER.get(min_severity, 3)
1079
1176
  visible = [f for f in self.findings if SEVERITY_ORDER.get(f.severity, 3) <= min_order]
1080
1177
 
1178
+ if self.spring_boot_2_detected is True:
1179
+ _boot = "Boot 2 (migration target)"
1180
+ elif self.spring_boot_2_detected is False:
1181
+ _boot = f"Boot {self.spring_boot_version_detected or '3+'} detected"
1182
+ else:
1183
+ _boot = "unknown"
1081
1184
  lines: list[str] = [
1082
1185
  f"Migration Readiness: {self.readiness_score}/100",
1186
+ f" jakarta: {self.jakarta_readiness}/100 "
1187
+ f"boot3: {self.boot3_readiness}/100 "
1188
+ f"jdk-modernization: {self.jdk_modernization}/100",
1189
+ f"Spring Boot 2 detected: {_boot}",
1083
1190
  f"Blocking issues: {self.blocking_count} "
1084
1191
  f"(critical: {self.summary.get('by_severity', {}).get('critical', 0)}, "
1085
1192
  f"high: {self.summary.get('by_severity', {}).get('high', 0)})",
@@ -1088,6 +1195,10 @@ class MigrationReport:
1088
1195
  "",
1089
1196
  ]
1090
1197
 
1198
+ if self.hibernate is not None and self.hibernate.detected:
1199
+ lines.append(self.hibernate.to_text())
1200
+ lines.append("")
1201
+
1091
1202
  if not visible:
1092
1203
  lines.append("No findings at or above selected severity.")
1093
1204
  return "\n".join(lines)
@@ -1200,6 +1311,7 @@ def run_migrate_check(
1200
1311
  all_findings: list[MigrationFinding] = []
1201
1312
  limitations: list[str] = []
1202
1313
  read_errors = 0
1314
+ jakarta_import_count = 0
1203
1315
 
1204
1316
  # ── Java source scan ────────────────────────────────────────────────────
1205
1317
  for rel_path in file_paths:
@@ -1210,6 +1322,9 @@ def run_migrate_check(
1210
1322
  read_errors += 1
1211
1323
  continue
1212
1324
 
1325
+ # Jakarta EE 9+ namespace adoption signal (vetoes a false Boot-2 verdict).
1326
+ jakarta_import_count += len(_JAKARTA_IMPORT_RE.findall(source))
1327
+
1213
1328
  file_findings = _scan_file(source, rel_path, _ALL_RULES)
1214
1329
  filtered = [f for f in file_findings if SEVERITY_ORDER.get(f.severity, 3) <= min_order]
1215
1330
  all_findings.extend(filtered)
@@ -1263,11 +1378,18 @@ def run_migrate_check(
1263
1378
 
1264
1379
  limitations.extend(_STATIC_LIMITATIONS)
1265
1380
 
1266
- spring_boot_2 = _detect_spring_boot_2(root)
1381
+ spring_boot_2, spring_boot_version = _detect_spring_boot(root, jakarta_import_count)
1382
+
1383
+ # Hibernate 5→6 stratification (independent of min_severity — it is its own
1384
+ # risk model, not a severity-filtered finding stream).
1385
+ from sourcecode.hibernate_strat import analyze_hibernate
1386
+ hibernate_strat = analyze_hibernate(file_paths, root)
1267
1387
 
1268
1388
  report = MigrationReport(
1269
1389
  spring_boot_2_detected=spring_boot_2,
1390
+ spring_boot_version_detected=spring_boot_version,
1270
1391
  findings=all_findings,
1392
+ hibernate=hibernate_strat,
1271
1393
  limitations=limitations,
1272
1394
  metadata={
1273
1395
  "java_files_scanned": len(file_paths),
@@ -1309,36 +1431,105 @@ _STATIC_LIMITATIONS: list[str] = [
1309
1431
  ]
1310
1432
 
1311
1433
 
1312
- def _detect_spring_boot_2(root: Path) -> bool:
1313
- """Return True if any build file in the repo declares spring-boot 2.x.
1434
+ # Spring Boot version captured ONLY in an authoritative context (parent,
1435
+ # managed BOM, an explicit spring-boot* dependency version, a spring.boot.version
1436
+ # property, or the Gradle plugin) — NOT any stray 2.x library version elsewhere
1437
+ # in the pom. These run on property-resolved text so ${spring.boot.version}
1438
+ # is already substituted.
1439
+ _BOOT_VERSION_PATTERNS: tuple[re.Pattern, ...] = (
1440
+ # <spring.boot.version>3.5.14</...> / <spring-boot.version> property declaration
1441
+ re.compile(r"<spring[.\-_]?boot[.\-_]?version>\s*(\d+)\.\d", re.IGNORECASE),
1442
+ # gradle.properties: springBootVersion=3.5.14 / spring.boot.version=2.7.18
1443
+ re.compile(r"spring[.\-_]?boot[.\-_]?version\s*[=:]\s*[\"']?(\d+)\.\d", re.IGNORECASE),
1444
+ # <artifactId>spring-boot-*</artifactId> immediately followed by <version>
1445
+ re.compile(
1446
+ r"<artifactId>\s*spring-boot[\w-]*\s*</artifactId>\s*<version>\s*(\d+)\.\d",
1447
+ re.IGNORECASE,
1448
+ ),
1449
+ # <version> immediately followed by <artifactId>spring-boot-*</artifactId>
1450
+ re.compile(
1451
+ r"<version>\s*(\d+)\.\d[^<]*</version>\s*<artifactId>\s*spring-boot[\w-]*\s*</artifactId>",
1452
+ re.IGNORECASE,
1453
+ ),
1454
+ # Gradle plugin: id 'org.springframework.boot' version '3.1.0'
1455
+ re.compile(
1456
+ r"org\.springframework\.boot[\"']?[^\n]*?version[^\n]*?[\"'](\d+)\.\d",
1457
+ re.IGNORECASE,
1458
+ ),
1459
+ )
1460
+
1461
+ _BOOT_FULL_VERSION_PATTERNS: tuple[re.Pattern, ...] = (
1462
+ re.compile(r"<spring[.\-_]?boot[.\-_]?version>\s*(\d+\.\d[\w.\-]*)", re.IGNORECASE),
1463
+ re.compile(r"spring[.\-_]?boot[.\-_]?version\s*[=:]\s*[\"']?(\d+\.\d[\w.\-]*)", re.IGNORECASE),
1464
+ re.compile(
1465
+ r"<artifactId>\s*spring-boot[\w-]*\s*</artifactId>\s*<version>\s*(\d+\.\d[\w.\-]*)",
1466
+ re.IGNORECASE,
1467
+ ),
1468
+ )
1469
+
1314
1470
 
1315
- Checks root build files plus one level of child modules to handle monorepos
1316
- where the Spring Boot parent version lives in a submodule pom.xml.
1471
+ def _extract_boot_versions(text: str) -> tuple[set[int], Optional[str]]:
1472
+ """Return (set of detected Spring Boot major versions, first full version string)."""
1473
+ majors: set[int] = set()
1474
+ for pat in _BOOT_VERSION_PATTERNS:
1475
+ for m in pat.finditer(text):
1476
+ try:
1477
+ majors.add(int(m.group(1)))
1478
+ except (ValueError, IndexError):
1479
+ pass
1480
+ full: Optional[str] = None
1481
+ for pat in _BOOT_FULL_VERSION_PATTERNS:
1482
+ m = pat.search(text)
1483
+ if m:
1484
+ full = m.group(1).strip()
1485
+ break
1486
+ return majors, full
1487
+
1488
+
1489
+ def _detect_spring_boot(root: Path, jakarta_import_count: int) -> tuple[Optional[bool], Optional[str]]:
1490
+ """Tri-state Spring Boot 2 detection. Returns (spring_boot_2_detected, version).
1491
+
1492
+ - True → Spring Boot 2.x confirmed by build evidence (migration target).
1493
+ - False → Boot 3+ confirmed, OR jakarta.* namespace already adopted en masse.
1494
+ - None → version could not be determined. Absence of evidence is never True.
1495
+
1496
+ Resolves Maven ${properties} so version-by-property (no starter-parent) works,
1497
+ and detects Boot via parent, managed BOM, spring-boot* dependency, property,
1498
+ or the Gradle plugin. Massive jakarta.* imports veto a Boot-2 verdict.
1317
1499
  """
1318
- _SB2 = re.compile(
1319
- # Maven: <parent>...<artifactId>spring-boot-*</artifactId>...<version>2.x
1320
- r"spring[.\-]?boot.*?<version>\s*2\.\d+|"
1321
- r"<version>\s*2\.\d+[\.\d]*\s*</version>.*?spring[.\-]?boot|"
1322
- # Maven properties: <spring.boot.version>2.x or spring-boot.version=2.x
1323
- r"spring[.\-_]?boot[.\-_]?version\s*[=>\"'\s]+2\.\d+|"
1324
- # Gradle plugin: id 'org.springframework.boot' version '2.x'
1325
- r"org\.springframework\.boot[^\n]*?['\"']2\.\d+",
1326
- re.IGNORECASE | re.DOTALL,
1327
- )
1328
- # Candidate build files: root + one level deep (child modules in monorepos)
1329
1500
  root_files = [
1330
1501
  root / name
1331
1502
  for name in ("pom.xml", "build.gradle", "build.gradle.kts", "gradle.properties")
1332
1503
  ]
1333
1504
  child_poms = list(root.glob("*/pom.xml"))
1334
1505
  child_gradle = list(root.glob("*/build.gradle")) + list(root.glob("*/build.gradle.kts"))
1335
- # Limit child scan to 30 files to stay fast on large monorepos
1506
+ # Limit child scan to 30 files to stay fast on large monorepos.
1336
1507
  candidates = root_files + (child_poms + child_gradle)[:30]
1508
+
1509
+ majors: set[int] = set()
1510
+ full_version: Optional[str] = None
1337
1511
  for candidate in candidates:
1338
1512
  try:
1339
1513
  text = candidate.read_text(encoding="utf-8", errors="replace")
1340
- if _SB2.search(text):
1341
- return True
1342
1514
  except OSError:
1343
- pass
1344
- return False
1515
+ continue
1516
+ if candidate.name == "pom.xml":
1517
+ text = _resolve_maven_properties(text)
1518
+ file_majors, file_full = _extract_boot_versions(text)
1519
+ majors |= file_majors
1520
+ if full_version is None and file_full is not None:
1521
+ full_version = file_full
1522
+
1523
+ # Boot 3+ explicitly declared → not a Boot 2 repo.
1524
+ if any(m >= 3 for m in majors):
1525
+ return False, full_version
1526
+ # Boot 2 declared. jakarta.* adoption en masse contradicts a literal Boot-2
1527
+ # verdict (already mid/post namespace migration) → report unknown, never True.
1528
+ if 2 in majors:
1529
+ if jakarta_import_count > 0:
1530
+ return None, full_version
1531
+ return True, full_version
1532
+ # No version evidence. jakarta.* present ⇒ Boot 3 (Jakarta EE 9+).
1533
+ if jakarta_import_count > 0:
1534
+ return False, full_version
1535
+ return None, full_version
sourcecode/serializer.py CHANGED
@@ -261,6 +261,49 @@ def _compact_git_context(sm: "SourceMap") -> "Optional[dict[str, Any]]":
261
261
  return ctx if ctx else None
262
262
 
263
263
 
264
+ # Java EE namespaces that were renamed javax.* → jakarta.* in Jakarta EE 9.
265
+ # Only these carry javax→jakarta migration risk. JDK / JSR namespaces that keep
266
+ # the javax.* prefix forever (javax.cache=JSR-107, javax.sql, javax.xml JAXP,
267
+ # javax.naming, javax.crypto, …) are deliberately NOT listed and must not flag.
268
+ _JAKARTA_RENAMED_NAMESPACES: tuple[str, ...] = (
269
+ "javax.servlet", # also covers javax.servlet.jsp
270
+ "javax.persistence",
271
+ "javax.validation",
272
+ "javax.transaction",
273
+ "javax.ws.rs",
274
+ "javax.ejb",
275
+ "javax.jms",
276
+ "javax.mail",
277
+ "javax.xml.bind", # JAXB — moved; plain javax.xml (JAXP) does not
278
+ "javax.xml.ws",
279
+ "javax.xml.soap",
280
+ "javax.activation",
281
+ "javax.faces",
282
+ "javax.enterprise",
283
+ "javax.inject",
284
+ "javax.batch",
285
+ "javax.json",
286
+ "javax.websocket",
287
+ "javax.el",
288
+ "javax.security.enterprise",
289
+ "javax.security.jacc",
290
+ "javax.annotation", # ambiguous: JSR-250 EE subset moved to jakarta
291
+ )
292
+
293
+
294
+ def _is_jakarta_renamed_namespace(nl: str) -> bool:
295
+ """True only for javax.* namespaces actually renamed to jakarta.* in Jakarta EE 9.
296
+
297
+ Matches exact namespace or a sub-package (e.g. javax.servlet → javax.servlet.http).
298
+ JDK/JSR namespaces that keep javax.* forever (javax.cache, javax.sql, javax.xml
299
+ JAXP, javax.naming, …) return False.
300
+ """
301
+ for ns in _JAKARTA_RENAMED_NAMESPACES:
302
+ if nl == ns or nl.startswith(ns + "."):
303
+ return True
304
+ return False
305
+
306
+
264
307
  def _dep_risk_flags(name: str, version: "Optional[str]") -> list[str]:
265
308
  """Static heuristic risk flags for a single dependency. No external lookups."""
266
309
  flags: list[str] = []
@@ -268,7 +311,7 @@ def _dep_risk_flags(name: str, version: "Optional[str]") -> list[str]:
268
311
  if "spring-boot" in nl or "spring.boot" in nl:
269
312
  if version and version.startswith("2."):
270
313
  flags.append("spring-boot-2.x-eol")
271
- if nl.startswith("javax.") or nl == "javax":
314
+ if _is_jakarta_renamed_namespace(nl):
272
315
  flags.append("javax-to-jakarta-migration-risk")
273
316
  if "ojdbc" in nl or nl in {"com.oracle.database.jdbc", "oracle.jdbc.driver.oracledriver"}:
274
317
  flags.append("oracle-vendor-lock")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 1.60.0
3
+ Version: 1.62.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
@@ -1,4 +1,4 @@
1
- sourcecode/__init__.py,sha256=K2X8G7LVjuV1hmr12r2Qonlfz5_JfYRcVzSPr6Z564k,103
1
+ sourcecode/__init__.py,sha256=M2ctLJu2Bc_7rapOGaPAxxYG50XM5jz5dbisyWgh-sE,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
@@ -7,7 +7,7 @@ sourcecode/cache.py,sha256=1V3vsaODAa2UBJAC0xpvxpmRdriCezQx5Q8JCcfgziE,31892
7
7
  sourcecode/canonical_ir.py,sha256=DEwucOPJguLsVtg5cV8mWXNi112l5jmBhv73KGGebVk,24849
8
8
  sourcecode/cir_graphs.py,sha256=9G0HHj1kw2325IDyzo2OpX73BNswEckecf4MZUXB4JM,12078
9
9
  sourcecode/classifier.py,sha256=hKzg-nQ47htqqIUzSGvYxv15cXrA3KgICTwJmdqal0o,8095
10
- sourcecode/cli.py,sha256=9JLQ0OgRBwWa4Cf2ufpk9gKOpI3mx6t8yZSxqpF_kXw,275902
10
+ sourcecode/cli.py,sha256=bEArsMTzwzag8xk-d2AkQk2I3WiZmVyoILRu_PtYBY0,276304
11
11
  sourcecode/code_notes_analyzer.py,sha256=EJemNCNc9Dn-1RZYu-aNbK0ELzmsyC4s6FdHi3XyNEI,9392
12
12
  sourcecode/confidence_analyzer.py,sha256=_jckZSxksV-OU38vbkxfVNBnWCtlCq8Vwfg23x1uspA,19054
13
13
  sourcecode/context_scorer.py,sha256=QpChSpsmaAYz91rXA4Ue5xzQmNz_ZboZN09YOHScq1U,14679
@@ -28,11 +28,12 @@ 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=Q_IJpO2jZEDaFPyp0v9svlcB3LetOu56R8Mv7S2F3aU,26165
31
32
  sourcecode/integration_detector.py,sha256=ZJqrGwvZ4ee2JTGhlazKk67aZi173HxkhNpl8Yntpd8,6503
32
33
  sourcecode/license.py,sha256=wckiLuiwaE35KMCStUf1gYzleJuFe6qsSyxUQJvit3s,23500
33
34
  sourcecode/mcp_nudge.py,sha256=5ELU_ixzh6uA83NXLOZT8h00OhL53okfQdji3jyKOjg,2917
34
35
  sourcecode/metrics_analyzer.py,sha256=m0ENgtqKeBL17kUIK3fmGkgo7UfXBNHxCMj0H_Y5K7c,22750
35
- sourcecode/migrate_check.py,sha256=H8iy7Vk8cGL0dnR3ZkFPS20CtfF5LJWuzQVQE4awQ9s,56192
36
+ sourcecode/migrate_check.py,sha256=2f7f2NCrBmIqh4MzAHthaZ4UQTyAgjIkRPV9Fdi4Nic,65166
36
37
  sourcecode/openapi_surface.py,sha256=BTt0K-woZbkbWTN77IkqeBm_Okag9owR0848fmot8sk,16207
37
38
  sourcecode/output_budget.py,sha256=Js9yUlfQtPhqBl9R6wn_9UHVjjJc3GtLcqyfjf5t50Q,9869
38
39
  sourcecode/path_filters.py,sha256=EN1RGZRvLq5EcPgpjYV_IyCKVlAQQn2bbpEisQ5LpGg,3780
@@ -52,7 +53,7 @@ sourcecode/scanner.py,sha256=WdOQ78mMzjR1NjmKTlbxdgwinnCTfAhxCVLBEFQiFHU,8899
52
53
  sourcecode/schema.py,sha256=aHNXDf8LGyUC8ZDE_VS9kiskC2-Oswhi_WnpdGy6HDw,24897
53
54
  sourcecode/security_config.py,sha256=_m8oQAy2gP7Ho2I-IIMTFFjFVWbJIGlLEOiAWK2TDjs,3503
54
55
  sourcecode/semantic_analyzer.py,sha256=4OdG6tTSnTvq3_dSWMbQu8Ad1ndSCKeG-b9qM4hIxkw,89176
55
- sourcecode/serializer.py,sha256=TGzftrSKitZrtl6Hh-R05s4KdTOxwTmph_lGDbo2Wzg,125015
56
+ sourcecode/serializer.py,sha256=6YoGyRqkjtp34ELwgRGCZ1r1b6zu6SqiAWT8ySjs-M8,126516
56
57
  sourcecode/spring_event_topology.py,sha256=5_ON_21Le5zbG-1GRc5GLIi5HJfy_QjcXLVPC5WeUGQ,18055
57
58
  sourcecode/spring_findings.py,sha256=G7Or2lKBUQbcTDqudLvSs9XvNg_YoAa-_lBOG_ULs8E,5457
58
59
  sourcecode/spring_impact.py,sha256=qLwLfItX_o9LU-k_qjhD2hFpTX3PpEQ85TsYTAArzvg,56016
@@ -88,9 +89,9 @@ sourcecode/detectors/terraform.py,sha256=cxORPR_zVLOJpHlh4e9JnFpkQsn_UnqMMom5yG6
88
89
  sourcecode/detectors/tooling.py,sha256=8CKbtxwQoABP-WyBRNmdAmHDOvAH57AR1cF4UKuWEdQ,2074
89
90
  sourcecode/mcp/__init__.py,sha256=XU4HfRGbdid8wdUA0x_4f7uKZD1z3mv_XUY_WU_T9Mw,179
90
91
  sourcecode/mcp/orchestrator.py,sha256=kT7IssYNyQXVtDf2Q69qrMSTuyJlJ1Rhkp6_EHqc_38,36520
91
- sourcecode/mcp/registry.py,sha256=8LxxalpJy1L_BrEfwiVfywFVOcJJMXLGusJaUGGH7Y4,65663
92
+ sourcecode/mcp/registry.py,sha256=8ot8A9_ccPNUvHgFkUO8lIlbp4XT_UcHR101nc71Gjc,65840
92
93
  sourcecode/mcp/runner.py,sha256=-Dp2qPGRkfNTVen6bKh7WtzQqpcEtsrXoiuajvshlKk,2866
93
- sourcecode/mcp/server.py,sha256=6Q98B1JkgBR5pZx3JyyXZosazpPX7FSBDqfG9nlZaII,63114
94
+ sourcecode/mcp/server.py,sha256=AQN076joOHyKoU7HaISpzPIIN-5271DDn5DqbQsnTaI,63324
94
95
  sourcecode/mcp/onboarding/__init__.py,sha256=sj2PWqEBmMc4zBNkomg89WtL0M6S7A9yb7_wAuSWNP4,66
95
96
  sourcecode/mcp/onboarding/applier.py,sha256=B9CneieWTpaDSDIyW3S5nrlRlBpvfqUcgi93-mm_ApQ,2135
96
97
  sourcecode/mcp/onboarding/backup.py,sha256=ihqGOR8QTX8HASRSEDyfFyXr5bkXrygPHamv4p9KTmk,1452
@@ -102,8 +103,8 @@ sourcecode/telemetry/consent.py,sha256=H5z2Wu63pZqbaKucRPoJQJ0zCo4cke9ZlBrJC-MaW
102
103
  sourcecode/telemetry/events.py,sha256=LtzYfaX9Ilckj5PTvAcTpDa9mLqDsYPDUiDkRa58piY,2580
103
104
  sourcecode/telemetry/filters.py,sha256=NHa5T-6DaZduQPFuC34jOqHWQgSizM-Ygq8aZ4j19ng,5834
104
105
  sourcecode/telemetry/transport.py,sha256=4gGHsq0WeY9VywEZXA3vUxykfiYnw9uuqfjAAec7F8o,1681
105
- sourcecode-1.60.0.dist-info/METADATA,sha256=13oyunu_mO7yr8DwHg2AwrNQ4tW0tHWydwzUEoOBnss,38941
106
- sourcecode-1.60.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
107
- sourcecode-1.60.0.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
108
- sourcecode-1.60.0.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
109
- sourcecode-1.60.0.dist-info/RECORD,,
106
+ sourcecode-1.62.0.dist-info/METADATA,sha256=pj1Wd3yqoYh6bYXMq-w9Z4ZF4bqAAAfyfMo4sWaMkzQ,38941
107
+ sourcecode-1.62.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
108
+ sourcecode-1.62.0.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
109
+ sourcecode-1.62.0.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
110
+ sourcecode-1.62.0.dist-info/RECORD,,