sourcecode 1.65.0__py3-none-any.whl → 1.67.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.65.0"
3
+ __version__ = "1.67.0"
sourcecode/cli.py CHANGED
@@ -4896,7 +4896,8 @@ def migrate_check_cmd(
4896
4896
  output, output_path, copy,
4897
4897
  success_msg=(
4898
4898
  f"Migration check written to {output_path} "
4899
- f"(score: {report.readiness_score}/100, {_total} findings)"
4899
+ f"(score: {report.readiness_score if report.readiness_score is not None else 'N/A'}"
4900
+ f"{'/100' if report.readiness_score is not None else ''}, {_total} findings)"
4900
4901
  ),
4901
4902
  )
4902
4903
 
@@ -5524,6 +5525,91 @@ def fix_bug_cmd(
5524
5525
  )
5525
5526
 
5526
5527
 
5528
+ # Method signatures that are almost always FRAMEWORK ENTRY POINTS, not dead code —
5529
+ # invoked by a dispatcher (reflection / XML / SPI), invisible to a static Java
5530
+ # call-graph. Generalizes beyond any one framework.
5531
+ _DYNAMIC_ENTRY_SIGNATURE_RE = __import__("re").compile(
5532
+ r"\(\s*DispatchContext\b" # OFBiz Service Engine service
5533
+ r"|HttpServletRequest\s+\w+\s*,\s*HttpServletResponse" # OFBiz event / servlet handler
5534
+ r"|@(?:Scheduled|PostConstruct|PreDestroy|EventListener|Bean|"
5535
+ r"RequestMapping|GetMapping|PostMapping|Path|GET|POST|Provider|"
5536
+ r"ApplicationScoped|Singleton)\b", # annotation-dispatched entry
5537
+ )
5538
+ # Config file extensions where a framework wires classes by name (XML/props/yaml).
5539
+ _CONFIG_REF_EXTS: frozenset = frozenset({".xml", ".properties", ".yml", ".yaml", ".groovy"})
5540
+ _CONFIG_SCAN_MAX_FILES: int = 12000
5541
+ _CONFIG_SCAN_MAX_BYTES: int = 256 * 1024
5542
+
5543
+
5544
+ def _partition_static_unreferenced(nodes: list[dict], root: Path) -> tuple[list[dict], list[dict]]:
5545
+ """Split zero-degree classes into (truly_unreferenced, framework_dispatched).
5546
+
5547
+ A class with no static callers is NOT necessarily dead: frameworks invoke
5548
+ classes via reflection, XML/SPI config, or annotations that a static call-graph
5549
+ cannot see (e.g. Apache OFBiz Service Engine services, JAX-RS resources,
5550
+ ServiceLoader providers, scheduled beans). We exclude a candidate when EITHER:
5551
+ 1. its source declares a dynamic-entry method signature, OR
5552
+ 2. its simple name / FQN is referenced from a non-Java config file.
5553
+ Whatever survives is reported as *statically_unreferenced* — never a confident
5554
+ "dead zone".
5555
+ """
5556
+ import os
5557
+ if not nodes:
5558
+ return [], []
5559
+ by_simple: dict[str, list[dict]] = {}
5560
+ for n in nodes:
5561
+ simple = (n.get("fqn") or "").rsplit(".", 1)[-1]
5562
+ if simple:
5563
+ by_simple.setdefault(simple, []).append(n)
5564
+
5565
+ dispatched_fqns: set[str] = set()
5566
+
5567
+ # 1. Source-signature allowlist (bounded — candidate set is small).
5568
+ for n in nodes:
5569
+ src = n.get("source_file")
5570
+ if not src:
5571
+ continue
5572
+ try:
5573
+ txt = (root / src).read_text(encoding="utf-8", errors="replace")
5574
+ except OSError:
5575
+ continue
5576
+ if _DYNAMIC_ENTRY_SIGNATURE_RE.search(txt):
5577
+ dispatched_fqns.add(n["fqn"])
5578
+
5579
+ # 2. Config-reference scan — find candidate names wired from XML/props/yaml.
5580
+ unresolved_simple = {s for s, ns in by_simple.items()
5581
+ if any(x["fqn"] not in dispatched_fqns for x in ns)}
5582
+ if unresolved_simple:
5583
+ files_scanned = 0
5584
+ for dirpath, dirnames, filenames in os.walk(root):
5585
+ dirnames[:] = [d for d in dirnames
5586
+ if d not in {".git", "build", "out", "target", "node_modules", ".gradle"}]
5587
+ for fname in filenames:
5588
+ ext = os.path.splitext(fname)[1].lower()
5589
+ if ext not in _CONFIG_REF_EXTS:
5590
+ continue
5591
+ if files_scanned >= _CONFIG_SCAN_MAX_FILES or not unresolved_simple:
5592
+ break
5593
+ fpath = os.path.join(dirpath, fname)
5594
+ try:
5595
+ with open(fpath, "r", encoding="utf-8", errors="replace") as fh:
5596
+ text = fh.read(_CONFIG_SCAN_MAX_BYTES)
5597
+ except OSError:
5598
+ continue
5599
+ files_scanned += 1
5600
+ for simple in list(unresolved_simple):
5601
+ if simple in text:
5602
+ for x in by_simple.get(simple, []):
5603
+ dispatched_fqns.add(x["fqn"])
5604
+ unresolved_simple.discard(simple)
5605
+ if files_scanned >= _CONFIG_SCAN_MAX_FILES or not unresolved_simple:
5606
+ break
5607
+
5608
+ unreferenced = [n for n in nodes if n["fqn"] not in dispatched_fqns]
5609
+ dispatched = [n for n in nodes if n["fqn"] in dispatched_fqns]
5610
+ return unreferenced, dispatched
5611
+
5612
+
5527
5613
  @app.command("modernize")
5528
5614
  def modernize_cmd(
5529
5615
  path: Path = typer.Argument(
@@ -5625,13 +5711,21 @@ def modernize_cmd(
5625
5711
  key=lambda n: (-n.get("in_degree", 0), n.get("fqn", "")),
5626
5712
  )[:20]
5627
5713
 
5628
- # Dead zones: symbols with zero in-degree AND zero out-degree (isolated)
5629
- dead_zones = sorted(
5714
+ # Statically-unreferenced zones: classes with zero in-degree AND zero out-degree
5715
+ # in the Java call-graph. These are NOT necessarily dead — framework dispatch
5716
+ # (reflection / XML / SPI / annotations) is invisible to a static graph — so we
5717
+ # partition out framework-dispatched entry points before reporting, and never
5718
+ # call the survivors "dead". (Defect 5: OFBiz Service-Engine services and event
5719
+ # handlers were false-positive "dead zones".)
5720
+ _zero_degree = sorted(
5630
5721
  [n for n in graph_nodes
5631
5722
  if n.get("in_degree", 0) == 0 and n.get("out_degree", 0) == 0
5632
5723
  and n.get("type") in ("class", "interface")],
5633
5724
  key=lambda n: n.get("fqn", ""),
5634
- )[:20]
5725
+ )
5726
+ dead_zones, framework_dispatched = _partition_static_unreferenced(_zero_degree, root)
5727
+ dead_zones = dead_zones[:20]
5728
+ framework_dispatched = framework_dispatched[:20]
5635
5729
 
5636
5730
  # Hotspot candidates: high in-degree service/repository/controller nodes,
5637
5731
  # ranked by composite score (in_degree × 2 + git_churn) for volatility signal.
@@ -5680,7 +5774,8 @@ def modernize_cmd(
5680
5774
  "total_classes": len([n for n in graph_nodes if n.get("type") in ("class", "interface")]),
5681
5775
  "total_subsystems": len(subsystems),
5682
5776
  "high_coupling_nodes": len(coupling_nodes),
5683
- "dead_zone_candidates": len(dead_zones),
5777
+ "statically_unreferenced": len(dead_zones),
5778
+ "framework_dispatched": len(framework_dispatched),
5684
5779
  }
5685
5780
  _subsystem_summary = [
5686
5781
  {
@@ -5723,10 +5818,20 @@ def modernize_cmd(
5723
5818
  {"fqn": n["fqn"], "in_degree": n.get("in_degree", 0), "role": n.get("role", "other")}
5724
5819
  for n in coupling_nodes
5725
5820
  ],
5726
- "dead_zone_candidates": [
5821
+ "statically_unreferenced": [
5727
5822
  {"fqn": n["fqn"], "type": n.get("type", ""), "role": n.get("role", "other")}
5728
5823
  for n in dead_zones
5729
5824
  ],
5825
+ "statically_unreferenced_note": (
5826
+ "Zero static callers in the Java call-graph. NOT confirmed dead: verify "
5827
+ "no framework dispatch (reflection, XML/SPI config, annotations) before "
5828
+ "removing. Classes detected as framework-dispatched are listed separately "
5829
+ "under framework_dispatched and excluded from this list."
5830
+ ),
5831
+ "framework_dispatched": [
5832
+ {"fqn": n["fqn"], "type": n.get("type", ""), "role": n.get("role", "other")}
5833
+ for n in framework_dispatched
5834
+ ],
5730
5835
  "subsystem_summary": _subsystem_summary,
5731
5836
  "cross_module_tangles": [
5732
5837
  {
@@ -5742,7 +5847,8 @@ def modernize_cmd(
5742
5847
  if hotspots else
5743
5848
  "high_coupling_nodes shows the most-referenced classes — start there. "
5744
5849
  )
5745
- + "Dead zones are safe to remove or refactor. "
5850
+ + "statically_unreferenced lists classes with no Java callers review "
5851
+ + "for framework dispatch (XML/reflection/SPI) before removing. "
5746
5852
  + "Cross-module tangles indicate coupling worth decomposing."
5747
5853
  ),
5748
5854
  }
@@ -28,7 +28,11 @@ _STACK_LABELS: dict[str, str] = {
28
28
 
29
29
  _TYPE_LABELS: dict[str, str] = {
30
30
  "api": "REST API",
31
- "web_mvc": "Spring MVC web app",
31
+ # Vendor-neutral: web_mvc is triggered by server-side template engines
32
+ # (Thymeleaf/FreeMarker), which are NOT exclusive to Spring (e.g. Apache OFBiz
33
+ # uses FreeMarker with its own framework). The detected frameworks list carries
34
+ # the actual stack; the type label must not assert "Spring".
35
+ "web_mvc": "Server-side MVC web app",
32
36
  "webapp": "Web app",
33
37
  "fullstack": "Full-stack app",
34
38
  "cli": "CLI tool",
@@ -1278,9 +1278,42 @@ class DependencyAnalyzer:
1278
1278
  return records, limitations
1279
1279
 
1280
1280
  def _strip_gradle_comments(self, content: str) -> str:
1281
- content = re.sub(r"/\*.*?\*/", "", content, flags=re.DOTALL)
1282
- content = re.sub(r"//[^\n]*", "", content)
1283
- return content
1281
+ """Strip // and /* */ comments WITHOUT touching string literals.
1282
+
1283
+ A naive regex treats `/*` inside a string as a block-comment open. Gradle
1284
+ build files routinely embed `/*` in Ant-style glob strings (e.g.
1285
+ '**/*.java') and `//` in URL strings — a regex stripper then eats the entire
1286
+ dependencies block up to the next literal `*/` (real defect on Apache OFBiz,
1287
+ where ~100 deps vanished). This scanner skips quoted strings (single, double,
1288
+ and triple-quoted, with backslash escapes) so only genuine comments are removed.
1289
+ """
1290
+ out: list[str] = []
1291
+ i, n = 0, len(content)
1292
+ quote: Optional[str] = None # active string delimiter
1293
+ while i < n:
1294
+ if quote is not None:
1295
+ # Inside a string literal — copy verbatim until the matching delimiter.
1296
+ if len(quote) == 1 and content[i] == "\\" and i + 1 < n:
1297
+ out.append(content[i:i + 2]); i += 2; continue
1298
+ if content.startswith(quote, i):
1299
+ out.append(quote); i += len(quote); quote = None; continue
1300
+ out.append(content[i]); i += 1; continue
1301
+ if content.startswith("//", i):
1302
+ j = content.find("\n", i)
1303
+ if j == -1:
1304
+ break
1305
+ i = j; continue
1306
+ if content.startswith("/*", i):
1307
+ j = content.find("*/", i + 2)
1308
+ if j == -1:
1309
+ break
1310
+ i = j + 2; continue
1311
+ for q in ('"""', "'''", '"', "'"):
1312
+ if content.startswith(q, i):
1313
+ quote = q; out.append(q); i += len(q); break
1314
+ else:
1315
+ out.append(content[i]); i += 1
1316
+ return "".join(out)
1284
1317
 
1285
1318
  def _analyze_gradle(self, root: Path) -> tuple[list[DependencyRecord], list[str]]:
1286
1319
  for filename in ("build.gradle", "build.gradle.kts"):
@@ -1307,7 +1340,20 @@ class DependencyAnalyzer:
1307
1340
  except OSError:
1308
1341
  pass
1309
1342
 
1310
- return self._dedupe(records), ["gradle: no compatible lockfile found; transitive dependencies unavailable"]
1343
+ deduped = self._dedupe(records)
1344
+ gradle_limitations = [
1345
+ "gradle: no compatible lockfile found; transitive dependencies unavailable"
1346
+ ]
1347
+ # Honest-zero guard: if the build file declares a dependencies block but
1348
+ # nothing was resolved, report a GAP rather than a confident "0 deps".
1349
+ if not deduped and re.search(r"\bdependencies\s*\{", content):
1350
+ jar_count = sum(1 for _ in root.glob("lib/**/*.jar"))
1351
+ gap = ("gradle: dependencies declared but none resolved by static "
1352
+ "parsing (non-standard declaration syntax)")
1353
+ if jar_count:
1354
+ gap += f"; {jar_count} jar(s) present under lib/ (not parsed)"
1355
+ gradle_limitations.append(gap)
1356
+ return deduped, gradle_limitations
1311
1357
  return [], []
1312
1358
 
1313
1359
  def _parse_gradle_properties(self, root: Path, content: str) -> dict[str, str]:
@@ -774,7 +774,7 @@ def run_migrate_flow_impl(repo_path: str, min_severity: str = "low") -> dict[str
774
774
  if not _is_java_repo(repo_path):
775
775
  quality_warnings.append(
776
776
  "not_a_java_repo: migration analysis targets Spring Boot / Java repos. "
777
- "Result will be empty (readiness_score=100, no findings)."
777
+ "Result will be empty (readiness_score=null/N/A, no findings)."
778
778
  )
779
779
 
780
780
  report = _exec(["migrate-check", repo_path, "--min-severity", min_severity])
@@ -798,7 +798,7 @@ depth: BFS depth for indirect caller traversal (1–8, default: 4).
798
798
  Analyzes codebase for modernization opportunities: dead zones, hotspot scores, upgrade candidates.
799
799
 
800
800
  Maps to: sourcecode modernize <repo_path>
801
- Returns: hotspot_candidates (high fan-in + git churn), dead_zone_candidates (isolated classes),
801
+ Returns: hotspot_candidates (high fan-in + git churn), statically_unreferenced (zero-caller classes — NOT confirmed dead; verify no framework dispatch) + framework_dispatched,
802
802
  high_coupling_nodes, subsystem_summary, cross_module_tangles, recommendation.
803
803
 
804
804
  Best for: refactor planning, identifying where to start, finding safe removal candidates.
@@ -1403,7 +1403,7 @@ Spring Boot 2→3 migration readiness: javax→jakarta namespace blockers. JAVA
1403
1403
  When to call: when asked about Spring Boot migration readiness, javax vs jakarta imports,
1404
1404
  or upgrading from Spring Boot 2.x to 3.x. Use BEFORE get_spring_audit when the goal
1405
1405
  is migration planning rather than ongoing Spring semantic audit.
1406
- Do NOT call on non-Java repositories — returns readiness_score=100 with no findings.
1406
+ Do NOT call on non-Java repositories — returns readiness_score=null (N/A) with no findings.
1407
1407
 
1408
1408
  Rules detected:
1409
1409
  MIG-001 critical — javax.persistence imports (JPA; will not compile after migration)
@@ -1415,7 +1415,7 @@ 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),
1418
+ Returns: schema_version, readiness_score (0–100, or null=N/A when no migration target applies; 100=ready to migrate),
1419
1419
  jakarta_readiness / boot3_readiness / jdk_modernization (per-dimension 0–100),
1420
1420
  blocking_count, estimated_effort_days, spring_boot_2_detected (true|false|null —
1421
1421
  null=undetermined, never assumed true), spring_boot_version_detected,
sourcecode/mcp/server.py CHANGED
@@ -517,7 +517,7 @@ def run_migrate_flow(repo_path: str = ".", min_severity: str = "low") -> dict:
517
517
  Primary high-value entry point for migration planning. Wraps migrate-check and
518
518
  lifts the headline numbers to the top level so the agent can plan a 2→3 upgrade
519
519
  without parsing the full report:
520
- - readiness_score (0–100; 100 = ready), blocking_count, estimated_effort_days
520
+ - readiness_score (0–100, or null=N/A when no migration target applies; 100 = ready), blocking_count, estimated_effort_days
521
521
  - by_severity and by_target breakdown (jakarta / spring_security_6 / java_11)
522
522
 
523
523
  Use this instead of calling get_migration_readiness and interpreting it by hand.
@@ -788,10 +788,10 @@ def get_migration_readiness(repo_path: str = ".", min_severity: str = "low") ->
788
788
  When to call: when asked about Spring Boot migration readiness, javax vs jakarta imports,
789
789
  or upgrading from Spring Boot 2.x to 3.x. Call this BEFORE get_spring_audit when
790
790
  the goal is migration planning — not ongoing audit.
791
- Do NOT call on non-Java repositories — returns readiness_score=100 with no findings.
791
+ Do NOT call on non-Java repositories — returns readiness_score=null (N/A) with no findings.
792
792
 
793
793
  Maps to: sourcecode migrate-check <repo_path> --min-severity <min_severity>
794
- Returns: MigrationReport with schema_version, readiness_score (0–100; 100=ready to migrate),
794
+ Returns: MigrationReport with schema_version, readiness_score (0–100, or null=N/A when no migration target applies; 100=ready to migrate),
795
795
  jakarta_readiness / boot3_readiness / jdk_modernization / hibernate_readiness
796
796
  (per-dimension 0–100), headline_blocker (e.g. "hibernate_rewrite" or null),
797
797
  hibernate (Hibernate 5→6 stratified model: 4-layer risk_matrix, rewrite_targets[]
@@ -1335,7 +1335,7 @@ def modernize_context(repo_path: str = ".", format: str = "json") -> dict:
1335
1335
  """Analyzes codebase for modernization opportunities: dead zones, hotspot scores, upgrade candidates.
1336
1336
 
1337
1337
  Maps to: sourcecode modernize <repo_path>
1338
- Returns: hotspot_candidates (high fan-in + git churn), dead_zone_candidates (isolated classes),
1338
+ Returns: hotspot_candidates (high fan-in + git churn), statically_unreferenced (zero-caller classes — NOT confirmed dead; verify no framework dispatch) + framework_dispatched,
1339
1339
  high_coupling_nodes, subsystem_summary, cross_module_tangles, recommendation.
1340
1340
 
1341
1341
  Best for: refactor planning, identifying where to start, finding safe removal candidates.
@@ -608,29 +608,79 @@ def _classify_code_context(finding: "MigrationFinding") -> str:
608
608
  return "main"
609
609
 
610
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
611
+ # BUG #1/#2: Spring presence must be SCOPE- and ARTIFACT-aware. The Boot 2→3 axis
612
+ # only applies to repos that use Spring AT RUNTIME — a Quarkus/Micronaut/Jakarta-pure
613
+ # repo (or one like Apache OFBiz whose ONLY Spring coordinate is spring-test, a TEST
614
+ # support library declared under a legacy `compile` block) has no Spring Boot axis.
615
+ # We therefore split Spring usage into runtime vs test-only and NEVER let a test
616
+ # artifact poison spring_present (which gates the boot3 readiness dimension).
617
+
618
+ # Test-only Spring artifacts: their presence — even when declared in a compile/
619
+ # implementation block — never implies Spring at runtime. The ARTIFACT itself is a
620
+ # test library, regardless of the declared scope.
621
+ _SPRING_TEST_ARTIFACT_RE: re.Pattern = re.compile(
622
+ r"\bspring-(?:test|boot-test(?:-autoconfigure)?|boot-starter-test|security-test)\b",
623
+ re.IGNORECASE,
616
624
  )
625
+ # Any `spring-<artifact>` coordinate token (matches both Gradle `org.springframework:
626
+ # spring-core:…` strings and Maven `<artifactId>spring-core</artifactId>` text).
627
+ _SPRING_ARTIFACT_TOKEN_RE: re.Pattern = re.compile(
628
+ r"\bspring-[a-z][a-z0-9]*(?:-[a-z0-9]+)*\b", re.IGNORECASE
629
+ )
630
+ # Spring Boot Gradle plugin / BOM group — an unambiguous runtime signal.
631
+ _SPRING_BOOT_PLUGIN_RE: re.Pattern = re.compile(
632
+ r"""(?:id\s*['"]\s*org\.springframework\.boot|"""
633
+ r"""org\.springframework\.boot\s*[:'"])""",
634
+ re.IGNORECASE,
635
+ )
636
+ # Import-side split: a Spring import in a TEST package (org.springframework.*.test
637
+ # or org.springframework.test / .boot.test) is a test-only signal; any other
638
+ # org.springframework import in MAIN sources is a runtime signal.
617
639
  _SPRING_IMPORT_RE: re.Pattern = re.compile(
618
- r"^[ \t]*import\s+org\.springframework\.", re.MULTILINE
640
+ r"^[ \t]*import\s+(?:static\s+)?org\.springframework\.([\w.]+)", re.MULTILINE
619
641
  )
620
642
 
621
643
 
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
644
+ def _build_text_spring_signals(text: str) -> tuple[bool, bool]:
645
+ """(runtime, any_spring) for one build-file's text artifact/scope aware.
646
+
647
+ A `spring-*` artifact token that is not a test library, or the Spring Boot
648
+ plugin/BOM group, counts as runtime. spring-test (and friends) alone count as
649
+ "spring present but test-only" — never runtime.
650
+ """
651
+ runtime = False
652
+ any_spring = False
653
+ if _SPRING_BOOT_PLUGIN_RE.search(text):
654
+ runtime = True
655
+ any_spring = True
656
+ for m in _SPRING_ARTIFACT_TOKEN_RE.finditer(text):
657
+ any_spring = True
658
+ if not _SPRING_TEST_ARTIFACT_RE.match(m.group(0)):
659
+ runtime = True
660
+ return runtime, any_spring
661
+
662
+
663
+ def _detect_spring_usage(
664
+ root: Path, runtime_import_seen: bool, test_import_seen: bool
665
+ ) -> tuple[bool, bool]:
666
+ """Return (runtime_present, test_only).
667
+
668
+ runtime_present — Spring is on the runtime/compile path (build coordinate or a
669
+ MAIN-source org.springframework import that is not a test pkg).
670
+ test_only — Spring appears ONLY as a test dependency (e.g. spring-test);
671
+ the Boot 2→3 migration axis is N/A.
672
+ """
673
+ runtime = runtime_import_seen
674
+ any_spring = runtime_import_seen or test_import_seen
626
675
  for abs_path, _rel in _find_build_files(root):
627
676
  try:
628
677
  text = abs_path.read_text(encoding="utf-8", errors="replace")
629
678
  except OSError:
630
679
  continue
631
- if _SPRING_BUILD_RE.search(text):
632
- return True
633
- return False
680
+ r, s = _build_text_spring_signals(text)
681
+ runtime = runtime or r
682
+ any_spring = any_spring or s
683
+ return runtime, (any_spring and not runtime)
634
684
 
635
685
  # G-1: cap on total readiness deduction from low-severity (advisory, non-blocking)
636
686
  # findings, so optional modernization cleanups cannot collapse the migration-readiness
@@ -648,6 +698,19 @@ _BOOT3_MIGRATION_TARGETS: frozenset[str] = frozenset(
648
698
  # upgrade. It is advisory only and must never sink a readiness dimension to 0 —
649
699
  # reported as a separate hygiene metric, excluded from JDK-modernization scoring.
650
700
  _BEST_PRACTICE_TARGETS: frozenset[str] = frozenset({"java_8_best_practice"})
701
+
702
+ # BUG #3: the migration dimensions that feed the readiness_score aggregate, in
703
+ # order. jdk_modernization is deliberately NOT here — it is orthogonal upkeep debt
704
+ # reported on its own axis, never folded into the migration headline.
705
+ _MIGRATION_DIMENSIONS: tuple[str, ...] = ("jakarta", "boot3", "hibernate")
706
+
707
+
708
+ def _parse_major(version: "Optional[str]") -> "Optional[int]":
709
+ """Leading integer of a version string ('4.0.3' → 4); None when not parseable."""
710
+ if not version:
711
+ return None
712
+ m = re.match(r"\s*(\d+)", version)
713
+ return int(m.group(1)) if m else None
651
714
  # Cap on total readiness deduction from JDK-modernization findings (medium/low),
652
715
  # so reflection/date cleanups cannot collapse a jakarta-ready repo's headline.
653
716
  _JDK_ADVISORY_DEDUCTION_CAP: int = 15
@@ -1152,7 +1215,9 @@ class MigrationReport:
1152
1215
  repo_id: str = ""
1153
1216
  git_head: str = ""
1154
1217
 
1155
- readiness_score: int = 100
1218
+ # Optional[int]: None == N/A (no applicable migration dimension), never a
1219
+ # manufactured 100 on a repo with nothing to migrate.
1220
+ readiness_score: Optional[int] = 100
1156
1221
  # Per-dimension readiness (0-100). javax→jakarta namespace, full Boot 2→3
1157
1222
  # migration, and orthogonal JDK modernization are scored independently so
1158
1223
  # JDK debt (java.util.Date, reflection) does not sink a jakarta-ready repo.
@@ -1170,14 +1235,25 @@ class MigrationReport:
1170
1235
  # is excluded from the aggregate — it is never counted as 0. Maps dimension →
1171
1236
  # {"applicable": bool, "score": int|None, "reason": str}.
1172
1237
  applicable_dimensions: dict = field(default_factory=dict)
1238
+ # BUG #3: how readiness_score was derived (method + the exact applicable
1239
+ # dimension scores it aggregates) so the headline number is fully traceable.
1240
+ readiness_aggregate: dict = field(default_factory=dict)
1173
1241
  blocking_count: int = 0
1174
1242
  estimated_effort_days: float = 0.0
1175
1243
  # Tri-state: True = Boot 2 confirmed, False = Boot 3+ confirmed,
1176
1244
  # None = could not determine. Absence of evidence is never reported as True.
1177
1245
  spring_boot_2_detected: Optional[bool] = None
1178
1246
  spring_boot_version_detected: Optional[str] = None
1179
- # BUG #2: whether Spring is used at all. The Boot3 dimension is N/A without it.
1247
+ # BUG #2: whether Spring is used AT RUNTIME. The Boot3 dimension is N/A without it.
1180
1248
  spring_present: bool = True
1249
+ # BUG #1/#2: Spring appears ONLY as a test dependency (e.g. spring-test). Reported
1250
+ # so a consumer can see WHY boot3 is N/A despite an org.springframework coordinate.
1251
+ spring_test_only: bool = False
1252
+ # BUG #3/#4: the repo imports the jakarta.* namespace (already on Jakarta EE 9+).
1253
+ # Evidence that the namespace axis is RELEVANT (and complete) — distinguishes an
1254
+ # already-migrated Jakarta repo (jakarta applicable, 100) from a repo that was
1255
+ # never Java EE at all (jakarta N/A).
1256
+ jakarta_namespace_adopted: bool = False
1181
1257
  # BUG #6 / #8: findings that do NOT count toward blocking_count or readiness —
1182
1258
  # best-practice hygiene (java.util.Date…) and test/fixture/generated buckets,
1183
1259
  # surfaced separately so the headline reflects real product migration risk.
@@ -1238,90 +1314,142 @@ class MigrationReport:
1238
1314
  1 for f in main_findings if f.severity in ("critical", "high")
1239
1315
  )
1240
1316
 
1241
- # BUG #4 / #6: readiness deduction. FRAMEWORK blockers (jakarta / Boot3 /
1242
- # Security — critical+high) are UNCAPPED so a genuinely blocked repo floors
1243
- # at 0. Orthogonal JDK-modernization debt (SecurityManager, sun.*, reflection)
1244
- # is advisory-CAPPED so it can never sink the framework headline. Best-practice
1245
- # hygiene (java.util.Date) is EXCLUDED entirely — it blocks no version upgrade.
1246
- fw = _BOOT3_MIGRATION_TARGETS
1247
- fw_crit = _files_by_sev(main_findings, "critical", targets=fw)
1248
- fw_high = _files_by_sev(main_findings, "high", targets=fw)
1249
- fw_med = _files_by_sev(main_findings, "medium", targets=fw)
1250
- fw_low = _files_by_sev(main_findings, "low", targets=fw)
1251
- _jdk_exclude = fw | _BEST_PRACTICE_TARGETS
1252
- jdk_crit = {f.source_file for f in main_findings
1253
- if f.severity == "critical" and f.migration_target not in _jdk_exclude}
1254
- jdk_high = {f.source_file for f in main_findings
1255
- if f.severity == "high" and f.migration_target not in _jdk_exclude}
1256
- jdk_med = {f.source_file for f in main_findings
1257
- if f.severity == "medium" and f.migration_target not in _jdk_exclude}
1258
- jdk_low = {f.source_file for f in main_findings
1259
- if f.severity == "low" and f.migration_target not in _jdk_exclude}
1260
- jdk_raw = len(jdk_crit) * 15 + len(jdk_high) * 8 + len(jdk_med) * 3 + len(jdk_low) * 1
1261
-
1262
- deduction = (
1263
- len(fw_crit) * 15
1264
- + len(fw_high) * 8
1265
- + len(fw_med) * 3
1266
- + min(len(fw_low) * 1, _LOW_SEVERITY_DEDUCTION_CAP)
1267
- + min(jdk_raw, _JDK_ADVISORY_DEDUCTION_CAP)
1268
- )
1269
- self.readiness_score = max(0, 100 - deduction)
1270
-
1271
1317
  # Per-dimension readiness — independent severity-weighted scores (MAIN only).
1272
1318
  self.jakarta_readiness = _dimension_score(main_findings, _JAKARTA_TARGETS)
1273
1319
  self.boot3_readiness = _dimension_score(main_findings, _BOOT3_MIGRATION_TARGETS)
1274
1320
  self.jdk_modernization = _dimension_score(main_findings, None)
1321
+ if not self.spring_present:
1322
+ self.boot3_readiness = 100
1275
1323
 
1276
- # BUG #1: Hibernate 5→6 is its own rewrite axis and applies ONLY when the
1277
- # repo is actually on Hibernate < 6. A resolved Hibernate 6+ (Keycloak:
1278
- # 6.2.13) means the migration is already done → N/A, never a blocker. An
1279
- # unresolved version degrades to a low-confidence hypothesis (no headline).
1280
- hib = self.hibernate
1281
- _hibernate_applies = (
1282
- hib is not None and hib.detected and hib.migration_applicable
1324
+ # BUG #3/#4: the jakarta dimension is applicable ONLY with POSITIVE evidence the
1325
+ # namespace axis is relevant either migratable javax.* code (a javax→jakarta
1326
+ # finding, axis in-progress) OR the jakarta.* namespace already adopted (axis
1327
+ # complete 100). A repo with neither was never Java EE: jakarta is N/A, never a
1328
+ # manufactured 100 folded into the headline.
1329
+ _jakarta_applies = (
1330
+ any(f.migration_target in _JAKARTA_TARGETS for f in main_findings)
1331
+ or self.jakarta_namespace_adopted
1283
1332
  )
1284
- if hib is not None and hib.detected:
1285
- # Already on H6+ nothing to migrate 100; else the rewrite score.
1286
- self.hibernate_readiness = hib.readiness if hib.migration_applicable else 100
1287
- if _hibernate_applies and hib.classification == "rewrite_zone" \
1333
+
1334
+ # ── BUG #2: Hibernate applicability version-driven, never heuristic ────
1335
+ # The 5→6 axis is applicable ONLY when the repo is actually on Hibernate < 6.
1336
+ # resolved < 6 → applicable (rewrite score is a measurement)
1337
+ # resolved ≥ 6 → N/A (already migrated)
1338
+ # unresolved, Boot≥3 → N/A (Boot 3/4 BOM manages Hibernate ORM ≥6)
1339
+ # unresolved, Boot==2 → applicable (Boot 2 BOM manages Hibernate 5.x)
1340
+ # unresolved, no BOM → N/A, status "unresolved" (NEVER a heuristic number
1341
+ # that looks like a measurement on absent data)
1342
+ hib = self.hibernate
1343
+ hib_detected = hib is not None and hib.detected
1344
+ boot_major = _parse_major(self.spring_boot_version_detected)
1345
+ _hibernate_applies = False
1346
+ _hib_score: Optional[int] = None
1347
+ hib_status = "not_detected"
1348
+ _hib_reason = "N/A — no Hibernate dependency or import detected"
1349
+ if hib_detected:
1350
+ if hib.version_confidence == "high":
1351
+ if hib.migration_applicable: # resolved major < 6
1352
+ _hibernate_applies, _hib_score = True, hib.readiness
1353
+ hib_status = "resolved_h5"
1354
+ _hib_reason = f"Hibernate 5→6 rewrite axis (resolved {hib.effective_version})"
1355
+ else: # resolved major ≥ 6
1356
+ hib_status = "managed_ge6"
1357
+ _hib_reason = (f"N/A — resolved Hibernate {hib.effective_version} (≥6); "
1358
+ f"no 5→6 migration pending")
1359
+ elif boot_major is not None and boot_major >= 3:
1360
+ hib_status = "managed_ge6"
1361
+ _hib_reason = (f"N/A — Hibernate version managed by Spring Boot "
1362
+ f"{self.spring_boot_version_detected} BOM (Hibernate ORM ≥6); "
1363
+ f"5→6 axis inapplicable")
1364
+ elif boot_major == 2:
1365
+ _hibernate_applies, _hib_score = True, hib.readiness
1366
+ hib_status = "managed_h5"
1367
+ _hib_reason = (f"Hibernate 5→6 rewrite axis (Hibernate 5.x managed by Spring "
1368
+ f"Boot {self.spring_boot_version_detected} BOM)")
1369
+ else:
1370
+ hib_status = "unresolved"
1371
+ _hib_reason = ("N/A — Hibernate version unresolved (not declared and no Spring "
1372
+ "Boot BOM to infer from); not penalized on absent data")
1373
+ # Top-level scalar: the rewrite score only when applicable; else 100 (nothing
1374
+ # pending) — never a heuristic penalty on an inapplicable/unresolved axis.
1375
+ self.hibernate_readiness = _hib_score if _hibernate_applies else 100
1376
+ # Headline blocker only on a DIRECTLY-resolved Hibernate-5 rewrite zone.
1377
+ if _hibernate_applies and hib is not None and hib.classification == "rewrite_zone" \
1288
1378
  and hib.version_confidence == "high":
1289
- # Only a CONFIRMED Hibernate-5 rewrite zone earns the headline blocker.
1290
1379
  self.headline_blocker = "hibernate_rewrite"
1291
1380
 
1292
- # BUG #2 / #3: declare which dimensions apply. jakarta + JDK are always
1293
- # applicable to a Java repo; boot3 applies only when Spring is actually used
1294
- # (Quarkus/Micronaut/Jakarta-pure repos have no Boot 2→3 axis); hibernate
1295
- # applies only for a real Hibernate < 6. N/A dimensions carry score=None and
1296
- # are excluded from the aggregate never scored as 0.
1297
- if not self.spring_present:
1298
- self.boot3_readiness = 100
1299
- if _hibernate_applies:
1300
- _hib_reason = f"Hibernate 5→6 rewrite axis (detected {hib.effective_version or 'version unknown'})"
1301
- elif hib is not None and hib.detected and not hib.migration_applicable:
1302
- _hib_reason = (f"N/A — already on Hibernate {hib.version_major} "
1303
- f"({hib.effective_version}); no 5→6 migration pending")
1304
- else:
1305
- _hib_reason = "N/A — no Hibernate dependency or import detected"
1381
+ # BUG #3: declare which dimensions apply, then derive readiness_score as a
1382
+ # DOCUMENTED aggregate over the applicable MIGRATION dimensions only. jakarta
1383
+ # is always applicable; boot3 only with Spring; hibernate only for a real
1384
+ # Hibernate < 6. jdk_modernization is an orthogonal upkeep axis (SecurityManager,
1385
+ # reflection, java.time) — reported but EXCLUDED from the headline so JDK debt
1386
+ # cannot sink a framework-complete repo. N/A dimensions carry score=None and
1387
+ # never enter the aggregate.
1306
1388
  self.applicable_dimensions = {
1307
- "jakarta": {"applicable": True, "score": self.jakarta_readiness,
1308
- "reason": "javax→jakarta namespace migration"},
1389
+ "jakarta": {
1390
+ "applicable": _jakarta_applies,
1391
+ "score": self.jakarta_readiness if _jakarta_applies else None,
1392
+ "reason": ("javax→jakarta namespace migration"
1393
+ if _jakarta_applies
1394
+ else "N/A — no migratable javax.* imports detected"),
1395
+ },
1309
1396
  "boot3": {
1310
1397
  "applicable": self.spring_present,
1311
1398
  "score": self.boot3_readiness if self.spring_present else None,
1312
1399
  "reason": ("Spring Boot 2→3 / Security 6 migration"
1313
1400
  if self.spring_present
1314
- else "N/A — no Spring usage detected (non-Spring stack)"),
1401
+ else ("N/A — Spring present only as a TEST dependency "
1402
+ "(spring-test); no runtime Spring"
1403
+ if self.spring_test_only
1404
+ else "N/A — no Spring usage detected (non-Spring stack)")),
1315
1405
  },
1316
1406
  "jdk_modernization": {"applicable": True, "score": self.jdk_modernization,
1317
- "reason": "orthogonal JDK modernization debt"},
1407
+ "reason": "orthogonal JDK modernization debt (excluded from "
1408
+ "the readiness_score aggregate)",
1409
+ "in_aggregate": False},
1318
1410
  "hibernate": {
1319
1411
  "applicable": _hibernate_applies,
1320
1412
  "score": self.hibernate_readiness if _hibernate_applies else None,
1321
1413
  "reason": _hib_reason,
1414
+ "status": hib_status,
1322
1415
  },
1323
1416
  }
1324
1417
 
1418
+ # ── BUG #3: aggregate invariant ─────────────────────────────────────────
1419
+ # readiness_score == min(score of every applicable migration dimension).
1420
+ # MIN (not mean): a migration is only as ready as its weakest applicable
1421
+ # axis. _MIGRATION_DIMENSIONS lists exactly which dimensions feed it; the
1422
+ # consistency invariant is asserted in finalize and covered by a unit test.
1423
+ agg_inputs = {
1424
+ name: self.applicable_dimensions[name]["score"]
1425
+ for name in _MIGRATION_DIMENSIONS
1426
+ if self.applicable_dimensions[name]["applicable"]
1427
+ }
1428
+ # BUG #4: when NO migration dimension applies (non-Spring repo, no migratable
1429
+ # javax.*, no Hibernate 5), readiness is N/A (None) — NOT a manufactured 100.
1430
+ # Absence of a migration target is "not applicable", never "100% ready".
1431
+ self.readiness_score = min(agg_inputs.values()) if agg_inputs else None
1432
+ self.readiness_aggregate = {
1433
+ "method": "min",
1434
+ "inputs": agg_inputs,
1435
+ "excluded": ["jdk_modernization"],
1436
+ "applicable": bool(agg_inputs),
1437
+ "note": ("readiness_score = min over applicable migration dimensions "
1438
+ "(jakarta / boot3 / hibernate). jdk_modernization is an orthogonal "
1439
+ "upkeep axis and is intentionally excluded."
1440
+ if agg_inputs else
1441
+ "N/A — no migration target detected (no migratable javax.*, no "
1442
+ "runtime Spring, no Hibernate 5). JDK-modernization findings, if "
1443
+ "any, are reported on their own axis."),
1444
+ }
1445
+ # Internal consistency guard — the headline cannot diverge from the dimensions
1446
+ # it claims to summarize (catches a future scorer change that breaks the model).
1447
+ if agg_inputs:
1448
+ assert self.readiness_score == min(agg_inputs.values()), (
1449
+ "readiness_score must equal min(applicable migration dimensions); "
1450
+ f"got {self.readiness_score} vs inputs {agg_inputs}"
1451
+ )
1452
+
1325
1453
  # BUG #5: effort over MAIN findings only — N/A axes (Hibernate-6 phantom,
1326
1454
  # test fixtures) no longer pad the estimate.
1327
1455
  self.estimated_effort_days = round(
@@ -1375,10 +1503,13 @@ class MigrationReport:
1375
1503
  "jdk_modernization": self.jdk_modernization,
1376
1504
  "hibernate_readiness": self.hibernate_readiness,
1377
1505
  "applicable_dimensions": self.applicable_dimensions,
1506
+ "readiness_aggregate": self.readiness_aggregate,
1378
1507
  "readiness_note": (
1379
- "readiness_score is a DERIVED aggregate over applicable dimensions only "
1380
- "(N/A dimensions excluded, never counted as 0). For migration decisions read "
1381
- "the per-dimension breakdown + blocking_count, not the single number."
1508
+ "readiness_score = min over applicable MIGRATION dimensions "
1509
+ "(jakarta / boot3 / hibernate); see readiness_aggregate for the exact "
1510
+ "inputs. N/A dimensions are excluded (never counted as 0); "
1511
+ "jdk_modernization is orthogonal upkeep and is NOT in the aggregate. "
1512
+ "For decisions read the per-dimension breakdown + blocking_count."
1382
1513
  ),
1383
1514
  "headline_blocker": self.headline_blocker,
1384
1515
  "blocking_count": self.blocking_count,
@@ -1386,6 +1517,7 @@ class MigrationReport:
1386
1517
  "hygiene_findings": self.hygiene_findings,
1387
1518
  "non_blocking": self.non_blocking,
1388
1519
  "spring_present": self.spring_present,
1520
+ "spring_test_only": self.spring_test_only,
1389
1521
  "spring_boot_2_detected": self.spring_boot_2_detected,
1390
1522
  "spring_boot_version_detected": self.spring_boot_version_detected,
1391
1523
  "summary": self.summary,
@@ -1419,8 +1551,10 @@ class MigrationReport:
1419
1551
  main_high = sum(1 for f in self.findings
1420
1552
  if f.code_context == "main" and f.severity == "high")
1421
1553
  nb = self.non_blocking.get("count", 0)
1554
+ _headline = (f"{self.readiness_score}/100" if self.readiness_score is not None
1555
+ else "N/A (no migration target detected)")
1422
1556
  lines: list[str] = [
1423
- f"Migration Readiness: {self.readiness_score}/100",
1557
+ f"Migration Readiness: {_headline}",
1424
1558
  f" {_dim('jakarta')} {_dim('boot3')} "
1425
1559
  f"{_dim('jdk_modernization')} {_dim('hibernate')}",
1426
1560
  *([f" ⚠ Headline blocker: {self.headline_blocker} "
@@ -1598,7 +1732,12 @@ def run_migrate_check(
1598
1732
  limitations: list[str] = []
1599
1733
  read_errors = 0
1600
1734
  jakarta_import_count = 0
1601
- spring_import_seen = False
1735
+ # BUG #1/#2: split Spring imports into runtime vs test. A test-tree file, or a
1736
+ # test-only Spring package (org.springframework.test / .boot.test), is a
1737
+ # test-only signal and must NOT mark the repo as runtime-Spring.
1738
+ spring_runtime_import_seen = False
1739
+ spring_test_import_seen = False
1740
+ from sourcecode.path_filters import is_test_path as _is_test_path
1602
1741
 
1603
1742
  # ── Java source scan ────────────────────────────────────────────────────
1604
1743
  for rel_path in file_paths:
@@ -1611,8 +1750,14 @@ def run_migrate_check(
1611
1750
 
1612
1751
  # Jakarta EE 9+ namespace adoption signal (vetoes a false Boot-2 verdict).
1613
1752
  jakarta_import_count += len(_JAKARTA_IMPORT_RE.findall(source))
1614
- if not spring_import_seen and _SPRING_IMPORT_RE.search(source):
1615
- spring_import_seen = True
1753
+ _in_test = _is_test_path(rel_path)
1754
+ for _m in _SPRING_IMPORT_RE.finditer(source):
1755
+ _sub = _m.group(1)
1756
+ _is_test_pkg = _sub.startswith(("test.", "boot.test.")) or _sub in ("test", "boot.test")
1757
+ if _in_test or _is_test_pkg:
1758
+ spring_test_import_seen = True
1759
+ else:
1760
+ spring_runtime_import_seen = True
1616
1761
 
1617
1762
  file_findings = _scan_file(source, rel_path, _ALL_RULES)
1618
1763
  filtered = [f for f in file_findings if SEVERITY_ORDER.get(f.severity, 3) <= min_order]
@@ -1668,7 +1813,9 @@ def run_migrate_check(
1668
1813
  limitations.extend(_STATIC_LIMITATIONS)
1669
1814
 
1670
1815
  spring_boot_2, spring_boot_version = _detect_spring_boot(root, jakarta_import_count)
1671
- spring_present = _detect_spring_present(root, spring_import_seen)
1816
+ spring_present, spring_test_only = _detect_spring_usage(
1817
+ root, spring_runtime_import_seen, spring_test_import_seen
1818
+ )
1672
1819
 
1673
1820
  # BUG #8: classify each finding's code context (main / test / generated) so the
1674
1821
  # report can keep test fixtures and autogenerated sources out of blocking_count.
@@ -1684,6 +1831,8 @@ def run_migrate_check(
1684
1831
  spring_boot_2_detected=spring_boot_2,
1685
1832
  spring_boot_version_detected=spring_boot_version,
1686
1833
  spring_present=spring_present,
1834
+ spring_test_only=spring_test_only,
1835
+ jakarta_namespace_adopted=jakarta_import_count > 0,
1687
1836
  findings=all_findings,
1688
1837
  hibernate=hibernate_strat,
1689
1838
  limitations=limitations,
@@ -154,5 +154,36 @@ def is_vendor_path(path: str) -> bool:
154
154
  for part in dir_parts:
155
155
  if part in _LIB_SEGMENTS:
156
156
  return True
157
+ # Vendored web libraries shipped into a theme/webapp by file name, e.g.
158
+ # jquery-3.5.1.js, jquery-migrate-1.2.1.js, bootstrap.bundle.js. These carry
159
+ # third-party comments (jQuery's own "BUG:" notes) that must not be reported
160
+ # as the project's own code notes.
161
+ if _is_vendored_web_lib(filename) or any(part in _VENDOR_WEB_LIB_DIRS for part in dir_parts):
162
+ return True
163
+
164
+ return False
165
+
157
166
 
167
+ # Stems of common vendored JS/CSS libraries. A web-asset file whose name starts with
168
+ # one of these (optionally followed by a version/qualifier) is third-party, not the
169
+ # project's own source — wherever it physically sits in the tree.
170
+ _VENDOR_WEB_LIB_STEMS: tuple[str, ...] = (
171
+ "jquery", "bootstrap", "angular", "react", "vue", "svelte", "ember",
172
+ "lodash", "underscore", "moment", "d3", "three", "chart", "popper",
173
+ "modernizr", "select2", "datatables", "ckeditor", "tinymce", "fontawesome",
174
+ "font-awesome", "backbone", "knockout", "prototype", "scriptaculous",
175
+ "highcharts", "leaflet", "axios", "polyfill", "babel", "require", "requirejs",
176
+ "handlebars", "mustache", "dojo", "extjs", "swiper", "slick", "fullcalendar",
177
+ )
178
+ # Directory names that hold only vendored web assets.
179
+ _VENDOR_WEB_LIB_DIRS: frozenset[str] = frozenset({"jquery", "bootstrap", "webjars"})
180
+
181
+
182
+ def _is_vendored_web_lib(filename: str) -> bool:
183
+ base = filename.lower()
184
+ for stem in _VENDOR_WEB_LIB_STEMS:
185
+ s = stem.strip()
186
+ # match "jquery.js", "jquery-3.5.1.js", "jquery.min.js", "jquery-ui.js"
187
+ if base == f"{s}.js" or base == f"{s}.css" or base.startswith((f"{s}-", f"{s}.")):
188
+ return True
158
189
  return False
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]:
sourcecode/summarizer.py CHANGED
@@ -296,7 +296,7 @@ class ProjectSummarizer:
296
296
  _TYPE_LABELS: dict[str, str] = {
297
297
  "cli": "CLI",
298
298
  "api": "API",
299
- "web_mvc": "Spring MVC web app",
299
+ "web_mvc": "Server-side MVC web app",
300
300
  "webapp": "Web application",
301
301
  "library": "Library",
302
302
  "monorepo": "Monorepo",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 1.65.0
3
+ Version: 1.67.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
@@ -738,9 +738,21 @@ evidence. `migrate-check` enforces:
738
738
  usage; Quarkus / Micronaut / Helidon / Jakarta-pure repos report `boot3` as N/A
739
739
  (`spring_present: false`), not a contradictory `applicable: true`.
740
740
  - **Permanent `javax.*` are never jakarta debt.** JDK/JSR namespaces
741
- (`javax.xml.*`, `javax.crypto.*`, `javax.naming.*`, `javax.sql.*`,
742
- `javax.management.*`, `javax.security.auth.*`, …) are allowlisted across every
743
- jakarta scorer.
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).
744
756
  - **Three buckets: blocker ≠ hygiene ≠ test.** Only product (`main`) code counts
745
757
  toward `blocking_count`, readiness, and effort. Test harnesses
746
758
  (`testsuite/`, `test-framework/`, `integration-arquillian/`, `*-test*`,
@@ -1,4 +1,4 @@
1
- sourcecode/__init__.py,sha256=p8-GKiFQV7kVIfsCmKNXmTPDXKUpvscoIAbYoLSteFs,103
1
+ sourcecode/__init__.py,sha256=PwGPkSaAcBTadA2mRPMQcETHoR9cW1WtQrx9s5Clpqw,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,15 +7,15 @@ 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=PwLLelj1ACLkWQBCEThD6vhn4sGMn5kEg-6gRX1IAb8,276536
10
+ sourcecode/cli.py,sha256=lVkoLKPfPPJT5YGr1_IskcAKfQ0Qvfc-zRF5xF8WTjc,281985
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
14
- sourcecode/context_summarizer.py,sha256=zlbm8ytdvJToohU108-dwBmEl52xl0gXpf6PZBOW_2A,6540
14
+ sourcecode/context_summarizer.py,sha256=cI2TZMvEhl0BEma12VtPaX6z03ZVBAetVzuK5GaCOvg,6852
15
15
  sourcecode/contract_model.py,sha256=nRxJKPMs1VHwFTa8AVXhGmaLjti3Lr2sjHDpWgv1bfE,3917
16
16
  sourcecode/contract_pipeline.py,sha256=bNn9SYtwfI1CGKlYGxewexlp7_IWhpwSCae-BMuuVwQ,28895
17
17
  sourcecode/coverage_parser.py,sha256=q0LeZJaX1bnntLu-ImksdBsMlpsVmk_iUfSaB4eaJGo,19702
18
- sourcecode/dependency_analyzer.py,sha256=qEnRiKFkleZJyLf_DyznJbWD1GJ881iG4RRDqH9oGQ4,61524
18
+ sourcecode/dependency_analyzer.py,sha256=fQCaWQ7_BNcVgZv8gweJCwJ1CGlXsz8nw_tqN3aHJNE,63952
19
19
  sourcecode/doc_analyzer.py,sha256=05bjTUbDbmnbajD_cgRnACzS8T7xxBKVX4CjkJlhZg8,24411
20
20
  sourcecode/entrypoint_classifier.py,sha256=jhTYlyqDJH2AtdEcLVaRU3lYRTJuF8DkxVzl4-W3zWE,5322
21
21
  sourcecode/env_analyzer.py,sha256=aNTyYgQk5noJDfJU6FmasmESOHfiomyJw5EvZqjy6qc,22213
@@ -33,10 +33,10 @@ sourcecode/integration_detector.py,sha256=mQulsXN1P-4V_3ueh3xy_N9x3Aqlvm7GQ-3YGq
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=pv7jZGhHRaw160sWYk0NWR-jWZERl_2S_gFAJ72maFI,80133
36
+ sourcecode/migrate_check.py,sha256=psBbL8P7vz8LUtWr0ykoKfUhDb9ss8_J3SB6UUHbMHo,88702
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=DMea0KIlmj2pzTkuy-3YYFF4ktP0q5zP9mH4v4uXh84,5237
39
+ sourcecode/path_filters.py,sha256=VnaD9jxZVNzluackNSTCdIddwzAqIseuSCs_A-gpCDM,6898
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
@@ -61,7 +61,7 @@ sourcecode/spring_model.py,sha256=zOAgFmrRbG4a6KLm1TJl55aWMyPNsz3OS3FSczqPG6A,16
61
61
  sourcecode/spring_security_audit.py,sha256=XtPJ1SXlZJ8k6VYmaWuAp7Bbir4UmreAL7doIGQ5I7o,20595
62
62
  sourcecode/spring_semantic.py,sha256=O1nKSGVzlukuxLHQVuCPxc-XrcrMFxwlHA20_dmEGgM,13307
63
63
  sourcecode/spring_tx_analyzer.py,sha256=FdFcyqPp3aT9oJ-PKrnXcTA6s69wdvzG-NBm0GMGPTU,30717
64
- sourcecode/summarizer.py,sha256=zgdps7yS2IktAbWe7IWz0oUcr3QIuNPRGrsScbZ4R1g,21797
64
+ sourcecode/summarizer.py,sha256=hV-kPFXXh15GnIvmLZnmVWTEbmr35C-yKVtPiR25U2g,21802
65
65
  sourcecode/tree_utils.py,sha256=8GAkIfQAsvtEudIeW1l4ooH_oRtrWR8cpJQJsEa_Pfw,2093
66
66
  sourcecode/validation_surface.py,sha256=2Ojfzhw9R4XpemAFqb9RXf4FKyI3DYjGo4Dp0AHZMQo,22600
67
67
  sourcecode/version_check.py,sha256=CHp6ZxTIfo8kyHPCBgJA1uFC0xQCoXMuuOfrW8QTL8o,4942
@@ -88,10 +88,10 @@ sourcecode/detectors/systems.py,sha256=nYaKbGDFu0EOXFcd_1doWFT3tTUdkbxc2DjHUF5Tc
88
88
  sourcecode/detectors/terraform.py,sha256=cxORPR_zVLOJpHlh4e9JnFpkQsn_UnqMMom5yG65hZ4,1693
89
89
  sourcecode/detectors/tooling.py,sha256=8CKbtxwQoABP-WyBRNmdAmHDOvAH57AR1cF4UKuWEdQ,2074
90
90
  sourcecode/mcp/__init__.py,sha256=XU4HfRGbdid8wdUA0x_4f7uKZD1z3mv_XUY_WU_T9Mw,179
91
- sourcecode/mcp/orchestrator.py,sha256=kT7IssYNyQXVtDf2Q69qrMSTuyJlJ1Rhkp6_EHqc_38,36520
92
- sourcecode/mcp/registry.py,sha256=8ot8A9_ccPNUvHgFkUO8lIlbp4XT_UcHR101nc71Gjc,65840
91
+ sourcecode/mcp/orchestrator.py,sha256=diVoQgn24QmgPL3Ev8Sp6hsvh02OqY3MktHXOzrlodo,36525
92
+ sourcecode/mcp/registry.py,sha256=jLCUG7-m53Fbxqjzpt_OWu6NUp7b46S2ixTDixsTRyo,65975
93
93
  sourcecode/mcp/runner.py,sha256=-Dp2qPGRkfNTVen6bKh7WtzQqpcEtsrXoiuajvshlKk,2866
94
- sourcecode/mcp/server.py,sha256=DV7aiUObBGekShSDc7Iu67ISdesByutto3JOFbcYBFk,63693
94
+ sourcecode/mcp/server.py,sha256=1jwMR0OwJcLi1GoAKT300RSQAhfd_nYLrvABG7s0pUU,63874
95
95
  sourcecode/mcp/onboarding/__init__.py,sha256=sj2PWqEBmMc4zBNkomg89WtL0M6S7A9yb7_wAuSWNP4,66
96
96
  sourcecode/mcp/onboarding/applier.py,sha256=B9CneieWTpaDSDIyW3S5nrlRlBpvfqUcgi93-mm_ApQ,2135
97
97
  sourcecode/mcp/onboarding/backup.py,sha256=ihqGOR8QTX8HASRSEDyfFyXr5bkXrygPHamv4p9KTmk,1452
@@ -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.65.0.dist-info/METADATA,sha256=tuUMAq5_nLV0J0Zrsl8TAS1NdxLSgunoAtnhlFoYv2U,46300
107
- sourcecode-1.65.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
108
- sourcecode-1.65.0.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
109
- sourcecode-1.65.0.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
110
- sourcecode-1.65.0.dist-info/RECORD,,
106
+ sourcecode-1.67.0.dist-info/METADATA,sha256=2UUp86qduBBVjyFuifvdUOmahDSseKPyZ8rsy7j2P8g,47341
107
+ sourcecode-1.67.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
108
+ sourcecode-1.67.0.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
109
+ sourcecode-1.67.0.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
110
+ sourcecode-1.67.0.dist-info/RECORD,,