sourcecode 1.71.0__tar.gz → 1.72.0__tar.gz

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.
Files changed (124) hide show
  1. {sourcecode-1.71.0 → sourcecode-1.72.0}/CHANGELOG.md +62 -0
  2. {sourcecode-1.71.0 → sourcecode-1.72.0}/PKG-INFO +1 -1
  3. {sourcecode-1.71.0 → sourcecode-1.72.0}/pyproject.toml +1 -1
  4. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/__init__.py +1 -1
  5. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/canonical_ir.py +14 -0
  6. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/cli.py +77 -13
  7. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/explain.py +7 -2
  8. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/integration_detector.py +11 -0
  9. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/migrate_check.py +50 -1
  10. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/repository_ir.py +311 -10
  11. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/spring_security_audit.py +11 -0
  12. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/spring_tx_analyzer.py +70 -9
  13. {sourcecode-1.71.0 → sourcecode-1.72.0}/.github/workflows/build-windows.yml +0 -0
  14. {sourcecode-1.71.0 → sourcecode-1.72.0}/.gitignore +0 -0
  15. {sourcecode-1.71.0 → sourcecode-1.72.0}/.ruff.toml +0 -0
  16. {sourcecode-1.71.0 → sourcecode-1.72.0}/CONTRIBUTING.md +0 -0
  17. {sourcecode-1.71.0 → sourcecode-1.72.0}/LICENSE +0 -0
  18. {sourcecode-1.71.0 → sourcecode-1.72.0}/README.md +0 -0
  19. {sourcecode-1.71.0 → sourcecode-1.72.0}/SECURITY.md +0 -0
  20. {sourcecode-1.71.0 → sourcecode-1.72.0}/raw +0 -0
  21. {sourcecode-1.71.0 → sourcecode-1.72.0}/scripts/generate_jdk_exports.py +0 -0
  22. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/adaptive_scanner.py +0 -0
  23. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/architecture_analyzer.py +0 -0
  24. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/architecture_summary.py +0 -0
  25. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/ast_extractor.py +0 -0
  26. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/cache.py +0 -0
  27. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/cir_graphs.py +0 -0
  28. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/classifier.py +0 -0
  29. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/code_notes_analyzer.py +0 -0
  30. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/confidence_analyzer.py +0 -0
  31. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/context_scorer.py +0 -0
  32. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/context_summarizer.py +0 -0
  33. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/contract_model.py +0 -0
  34. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/contract_pipeline.py +0 -0
  35. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/coverage_parser.py +0 -0
  36. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/dependency_analyzer.py +0 -0
  37. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/detectors/__init__.py +0 -0
  38. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/detectors/base.py +0 -0
  39. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/detectors/csproj_parser.py +0 -0
  40. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/detectors/dart.py +0 -0
  41. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/detectors/dotnet.py +0 -0
  42. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/detectors/elixir.py +0 -0
  43. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/detectors/go.py +0 -0
  44. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/detectors/heuristic.py +0 -0
  45. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/detectors/hybrid.py +0 -0
  46. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/detectors/java.py +0 -0
  47. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/detectors/jvm_ext.py +0 -0
  48. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/detectors/nodejs.py +0 -0
  49. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/detectors/parsers.py +0 -0
  50. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/detectors/php.py +0 -0
  51. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/detectors/project.py +0 -0
  52. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/detectors/python.py +0 -0
  53. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/detectors/ruby.py +0 -0
  54. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/detectors/rust.py +0 -0
  55. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/detectors/systems.py +0 -0
  56. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/detectors/terraform.py +0 -0
  57. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/detectors/tooling.py +0 -0
  58. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/doc_analyzer.py +0 -0
  59. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/entrypoint_classifier.py +0 -0
  60. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/env_analyzer.py +0 -0
  61. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/error_schema.py +0 -0
  62. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/file_chunker.py +0 -0
  63. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/file_classifier.py +0 -0
  64. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/flow_analyzer.py +0 -0
  65. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/format_contract.py +0 -0
  66. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/fqn_utils.py +0 -0
  67. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/git_analyzer.py +0 -0
  68. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/graph_analyzer.py +0 -0
  69. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/hibernate_strat.py +0 -0
  70. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/jdk_exports.py +0 -0
  71. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/license.py +0 -0
  72. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/mcp/__init__.py +0 -0
  73. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/mcp/onboarding/__init__.py +0 -0
  74. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/mcp/onboarding/applier.py +0 -0
  75. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/mcp/onboarding/backup.py +0 -0
  76. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/mcp/onboarding/detector.py +0 -0
  77. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/mcp/onboarding/planner.py +0 -0
  78. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/mcp/orchestrator.py +0 -0
  79. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/mcp/registry.py +0 -0
  80. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/mcp/runner.py +0 -0
  81. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/mcp/server.py +0 -0
  82. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/mcp_nudge.py +0 -0
  83. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/metrics_analyzer.py +0 -0
  84. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/openapi_surface.py +0 -0
  85. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/output_budget.py +0 -0
  86. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/path_filters.py +0 -0
  87. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/pr_comment_renderer.py +0 -0
  88. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/pr_impact.py +0 -0
  89. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/prepare_context.py +0 -0
  90. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/progress.py +0 -0
  91. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/ranking_engine.py +0 -0
  92. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/redactor.py +0 -0
  93. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/relevance_scorer.py +0 -0
  94. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/rename_refactor.py +0 -0
  95. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/repo_classifier.py +0 -0
  96. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/ris.py +0 -0
  97. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/runtime_classifier.py +0 -0
  98. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/scanner.py +0 -0
  99. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/schema.py +0 -0
  100. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/security_config.py +0 -0
  101. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/semantic_analyzer.py +0 -0
  102. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/serializer.py +0 -0
  103. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/spring_event_topology.py +0 -0
  104. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/spring_findings.py +0 -0
  105. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/spring_impact.py +0 -0
  106. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/spring_model.py +0 -0
  107. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/spring_semantic.py +0 -0
  108. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/summarizer.py +0 -0
  109. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/telemetry/__init__.py +0 -0
  110. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/telemetry/config.py +0 -0
  111. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/telemetry/consent.py +0 -0
  112. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/telemetry/events.py +0 -0
  113. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/telemetry/filters.py +0 -0
  114. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/telemetry/transport.py +0 -0
  115. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/tree_utils.py +0 -0
  116. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/validation_surface.py +0 -0
  117. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/version_check.py +0 -0
  118. {sourcecode-1.71.0 → sourcecode-1.72.0}/src/sourcecode/workspace.py +0 -0
  119. {sourcecode-1.71.0 → sourcecode-1.72.0}/supabase/functions/README.md +0 -0
  120. {sourcecode-1.71.0 → sourcecode-1.72.0}/supabase/functions/get-license/index.ts +0 -0
  121. {sourcecode-1.71.0 → sourcecode-1.72.0}/supabase/functions/lemonsqueezy-webhook/index.ts +0 -0
  122. {sourcecode-1.71.0 → sourcecode-1.72.0}/supabase/functions/telemetry/index.ts +0 -0
  123. {sourcecode-1.71.0 → sourcecode-1.72.0}/supabase/sql/license_event_ordering.sql +0 -0
  124. {sourcecode-1.71.0 → sourcecode-1.72.0}/supabase/sql/telemetry_events.sql +0 -0
@@ -1,5 +1,67 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.72.0] — 2026-07-01
4
+
5
+ ### Fixed — correctness on a large real-world Spring Boot 3 monolith (Broadleaf Commerce CE field test)
6
+
7
+ A downstream architecture audit of **BroadleafCommerce** (`97705634ac`, branch
8
+ `develop-7.0.x`; ~3,000 Java classes, 13 Maven poms, Spring Boot 3.5 / Spring 6.2 /
9
+ Hibernate 5.6) hand-verified against source surfaced seven reproducible
10
+ correctness defects across `modernize`, `spring-audit`, `migrate-check`, and
11
+ `export --integrations`. Each fix targets the root cause and adds regression
12
+ coverage; all were re-verified against the live repo.
13
+
14
+ - **`spring-audit` TX-005 — 100% false-positive rate (4/4) on expected-exception idioms.**
15
+ The "swallowed exception in `@Transactional`" rule flagged the standard JPA
16
+ control-flow idiom (`Query.getSingleResult()` → `catch (NoResultException)` →
17
+ insert; tolerant insert → `catch (EntityExistsException)`) and catches carrying an
18
+ explicit `// do nothing — acceptable condition` intent comment. TX-005 now skips
19
+ catches whose caught type is a well-known expected-control-flow exception
20
+ (`NoResultException`, `NonUniqueResultException`, `EntityExistsException`,
21
+ `EmptyResultDataAccessException`, …) and catches whose body documents a deliberate
22
+ no-op, while still flagging a genuine unexpected-exception swallow.
23
+ - **`modernize` `statically_unreferenced` — ~95% false positives on annotation-constant nested classes.**
24
+ Two compounding defects: (a) the pre-scan fast path emitted nested types as
25
+ `pkg.Inner`, dropping the enclosing class, so distinct nested types with the same
26
+ simple name (Broadleaf has 25 `GroupName`s) collapsed onto one colliding FQN;
27
+ (b) static-constant reads inside annotation arguments
28
+ (`@AdminPresentation(group = GroupName.General)`, incl. the fully-qualified
29
+ `Outer.Nested.CONST` form and class-level annotations) were never counted as
30
+ references, so the constant holders read as zero-caller dead code. Now: nested
31
+ symbols are binary-qualified (`Outer.Inner`); a new `references` edge pass resolves
32
+ annotation-value reads (bare, inherited-nested via supertypes, and qualified
33
+ chains) in both parsed and pre-scan-skipped files. Presentation-holder false
34
+ positives on Broadleaf went 11→0 (the survivors are genuinely-unused declarations).
35
+ - **`modernize` `cross_module_tangles` — re-emitted the subsystem list instead of coupling.**
36
+ The field echoed `{label, class_count, member_count}` per subsystem with no edge
37
+ data. It now measures real directed inter-subsystem coupling from structural graph
38
+ edges, with `edge_count`, `reverse_edge_count`, and `mutual` (bidirectional/cyclic
39
+ = the actual tangle) — e.g. Broadleaf `profile↔common` is now correctly flagged
40
+ mutual.
41
+ - **`migrate-check` MIG-031 — test-scope config counted as a production blocker + wrong explanation.**
42
+ A test-only `bl-applicationContext-test-security.xml` (under `src/main/resources`,
43
+ loaded only by test `@ContextConfiguration`) inflated `blocking_count`; XML config
44
+ with a `test` filename marker is now classified `code_context: "test"`
45
+ (blocking_count 1→0). The explanation was a static OR-template asserting both
46
+ triggers; it now reports only the sub-pattern that actually matched (auto-config
47
+ vs versioned legacy schema).
48
+ - **`export --integrations` — missed in-code Spring Security LDAP (false negative).**
49
+ Subclassing `LdapUserDetailsMapper` and using `DirContextOperations` — the
50
+ idiomatic Spring Security LDAP auth mapper — was undetected (Broadleaf reported 0
51
+ LDAP). Added `LdapUserDetailsMapper` / `DirContextOperations` / context-source
52
+ tokens; Broadleaf now reports 5 LDAP integration points.
53
+ - **`explain` vs `modernize` in-degree — silently contradictory numbers.**
54
+ The same class showed different fan-in in the two commands with no explanation.
55
+ Both metrics are now self-describing: `modernize.high_coupling_nodes` documents
56
+ `in_degree` as the raw all-edge-type incoming count; `explain` labels its value
57
+ as "N distinct caller classes".
58
+ - **`spring-audit` vs `endpoints` — unreconcilable endpoint counts.**
59
+ The canonical IR (feeding `spring-audit`, `impact`, `validation`) did not apply the
60
+ `endpoints` command's filter for framework dynamic-admin FQN-shaped paths, so its
61
+ count was silently inflated. The filter is now shared; `endpoints_analyzed` is
62
+ documented as the deduplicated canonical count (≤ `endpoints` total, difference =
63
+ multi-prefix collapse), reconciling 225 vs 220 on Broadleaf.
64
+
3
65
  ## [1.71.0] — 2026-07-01
4
66
 
5
67
  ### Fixed — framework-framing & migration reliability on a large non-Boot Spring repo (Jenkins core field test)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 1.71.0
3
+ Version: 1.72.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sourcecode"
7
- version = "1.71.0"
7
+ version = "1.72.0"
8
8
  description = "Persistent structural context and ultra-fast repeated analysis for AI coding agents"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,3 +1,3 @@
1
1
  """sourcecode — Deterministic codebase context maps for AI coding agents."""
2
2
 
3
- __version__ = "1.71.0"
3
+ __version__ = "1.72.0"
@@ -16,6 +16,7 @@ from __future__ import annotations
16
16
 
17
17
  import hashlib
18
18
  import json
19
+ import re
19
20
  from dataclasses import dataclass, field
20
21
  from pathlib import Path
21
22
  from typing import Any, Optional
@@ -37,6 +38,11 @@ IR_SCHEMA_VERSION = "1.0.0"
37
38
  # Edge types excluded from reverse graph (mirrors repository_ir._REVERSE_EXCLUDE)
38
39
  _REVERSE_EXCLUDE: frozenset[str] = frozenset({"annotated_with", "mapped_to"})
39
40
 
41
+ # A route path that looks like a Java FQN is framework dynamic-admin routing, not a
42
+ # real REST endpoint (mirrors the filter in repository_ir.extract_java_endpoints so
43
+ # the CIR endpoint count reconciles with the `endpoints` command). See BUG #7.
44
+ _FQN_PATH_RE: re.Pattern = re.compile(r"/(org|com|net|io|edu)\.[a-z][a-z0-9]*\.[a-zA-Z]")
45
+
40
46
 
41
47
  # ---------------------------------------------------------------------------
42
48
  # CanonicalSecurity
@@ -324,9 +330,17 @@ def ir_dict_to_canonical(
324
330
  # Build canonical endpoints from route_surface — stable ordering
325
331
  route_surface = ir.get("route_surface") or []
326
332
  # Deduplicate by endpoint id (route_surface can have duplicates from multi-prefix)
333
+ # BUG #7 (Broadleaf field test): the `endpoints` command (extract_java_endpoints)
334
+ # drops routes whose path looks like a Java FQN — framework dynamic-admin routing
335
+ # (Broadleaf @AdminSection registers entity class FQNs as URL segments) is not a
336
+ # real REST surface. The CIR endpoint list feeds `spring-audit` (endpoints_analyzed),
337
+ # `impact` and `validation`; it must apply the SAME filter so its count reconciles
338
+ # with `endpoints` instead of being silently inflated by those pseudo-routes.
327
339
  _seen_ids: set[str] = set()
328
340
  raw_endpoints: list[CanonicalEndpoint] = []
329
341
  for r in route_surface:
342
+ if _FQN_PATH_RE.search(r.get("path", "") or ""):
343
+ continue
330
344
  ep = _route_to_canonical_endpoint(r)
331
345
  if ep.id not in _seen_ids:
332
346
  _seen_ids.add(ep.id)
@@ -5764,11 +5764,63 @@ def modernize_cmd(
5764
5764
  key=lambda h: (-h["hotspot_score"], h["fqn"]),
5765
5765
  )[:15]
5766
5766
 
5767
- # Cross-module tangles: subsystems with high member count
5768
- tangle_modules = sorted(
5769
- [s for s in subsystems if len(s.get("members") or []) >= 5],
5770
- key=lambda s: -len(s.get("members") or []),
5771
- )[:10]
5767
+ # Cross-module tangles: actual inter-subsystem coupling measured from the
5768
+ # dependency graph, NOT a re-labelled subsystem list. For every structural
5769
+ # edge whose endpoints live in two different subsystems we tally a directed
5770
+ # (from_subsystem → to_subsystem) coupling count; pairs coupled in BOTH
5771
+ # directions are the real tangles (bidirectional/cyclic module coupling).
5772
+ _graph_edges: list = (ir.get("graph") or {}).get("edges") or []
5773
+ # Structural coupling edge types. annotated_with / mapped_to / event edges are
5774
+ # excluded (metadata + separate topology, not module-decomposition coupling).
5775
+ _TANGLE_EDGE_TYPES = frozenset({
5776
+ "imports", "injects", "extends", "implements",
5777
+ "references", "calls", "instantiates", "returns",
5778
+ })
5779
+ _fqn_to_pkg: dict[str, str] = {}
5780
+ _pkg_to_label: dict[str, str] = {}
5781
+ for _s in subsystems:
5782
+ _pp = _s.get("package_prefix") or _s.get("pkg") or ""
5783
+ if not _pp:
5784
+ continue
5785
+ _pkg_to_label[_pp] = _s.get("label") or _s.get("name") or _pp
5786
+ for _m in (_s.get("members") or []):
5787
+ _fqn_to_pkg[_m] = _pp
5788
+
5789
+ from collections import Counter as _Counter
5790
+ _pair_counts: "_Counter[tuple[str, str]]" = _Counter()
5791
+ _pair_example: dict[tuple[str, str], str] = {}
5792
+ for _e in _graph_edges:
5793
+ if _e.get("type") not in _TANGLE_EDGE_TYPES:
5794
+ continue
5795
+ _a = _fqn_to_pkg.get(_e.get("from"))
5796
+ _b = _fqn_to_pkg.get(_e.get("to"))
5797
+ if not _a or not _b or _a == _b:
5798
+ continue
5799
+ _pair_counts[(_a, _b)] += 1
5800
+ _pair_example.setdefault((_a, _b), f"{_e.get('from')} → {_e.get('to')}")
5801
+
5802
+ _cross_module_tangles = []
5803
+ for (_a, _b), _cnt in sorted(
5804
+ _pair_counts.items(), key=lambda kv: (-kv[1], kv[0])
5805
+ ):
5806
+ _reverse = _pair_counts.get((_b, _a), 0)
5807
+ _cross_module_tangles.append({
5808
+ "from_subsystem": _pkg_to_label.get(_a, _a),
5809
+ "to_subsystem": _pkg_to_label.get(_b, _b),
5810
+ "from_package": _a,
5811
+ "to_package": _b,
5812
+ "edge_count": _cnt,
5813
+ "reverse_edge_count": _reverse,
5814
+ # A mutual (cyclic) dependency is the actual "tangle" — both modules
5815
+ # reference each other, so neither can be extracted independently.
5816
+ "mutual": _reverse > 0,
5817
+ "example": _pair_example.get((_a, _b), ""),
5818
+ })
5819
+ # Surface mutual tangles first (highest decomposition risk), then by strength.
5820
+ _cross_module_tangles.sort(
5821
+ key=lambda t: (not t["mutual"], -t["edge_count"], t["from_package"])
5822
+ )
5823
+ _cross_module_tangles = _cross_module_tangles[:15]
5772
5824
 
5773
5825
  _summary = {
5774
5826
  "total_classes": len([n for n in graph_nodes if n.get("type") in ("class", "interface")]),
@@ -5837,6 +5889,17 @@ def modernize_cmd(
5837
5889
  {"fqn": n["fqn"], "in_degree": n.get("in_degree", 0), "role": n.get("role", "other")}
5838
5890
  for n in coupling_nodes
5839
5891
  ],
5892
+ # BUG #6 (Broadleaf field test): make the in_degree metric self-describing
5893
+ # so it is not confused with `explain`'s caller count. They differ by design.
5894
+ "high_coupling_nodes_note": (
5895
+ "in_degree = raw count of incoming graph edges across ALL edge types "
5896
+ "(imports, injects, extends/implements, references, annotations), "
5897
+ "counted at symbol level. This is deliberately larger than and NOT "
5898
+ "equal to `sourcecode explain`'s caller count, which reports DISTINCT "
5899
+ "dependent classes (DI dependents + reverse-call-graph callers, "
5900
+ "deduplicated to class level). Use in_degree for blast-radius ranking, "
5901
+ "explain's caller list for the concrete dependents to inspect."
5902
+ ),
5840
5903
  "statically_unreferenced": [
5841
5904
  {"fqn": n["fqn"], "type": n.get("type", ""), "role": n.get("role", "other")}
5842
5905
  for n in dead_zones
@@ -5853,14 +5916,15 @@ def modernize_cmd(
5853
5916
  ],
5854
5917
  "subsystem_summary": _subsystem_summary,
5855
5918
  "subsystem_summary_note": _subsystem_summary_note,
5856
- "cross_module_tangles": [
5857
- {
5858
- "label": s.get("label") or s.get("name") or "",
5859
- "class_count": _class_count(s.get("members") or []),
5860
- "member_count": len(s.get("members") or []),
5861
- }
5862
- for s in tangle_modules
5863
- ],
5919
+ "cross_module_tangles": _cross_module_tangles,
5920
+ "cross_module_tangles_note": (
5921
+ "Directed inter-subsystem coupling measured from structural graph "
5922
+ "edges (imports/injects/extends/implements/references/calls). "
5923
+ "edge_count = edges from_package→to_package; mutual=true marks a "
5924
+ "bidirectional (cyclic) dependency — the actual tangle that blocks "
5925
+ "independent module extraction. Empty means no cross-subsystem "
5926
+ "structural coupling was detected."
5927
+ ),
5864
5928
  # BUG-05 fix: don't recommend "Start with hotspot_candidates" when the list is empty.
5865
5929
  "recommendation": (
5866
5930
  (
@@ -298,10 +298,15 @@ def _structural_purpose(
298
298
  head = f"Likely {role} (no DI annotations found)" if role else \
299
299
  "Likely a structurally significant class (no DI annotations found)"
300
300
  signals: list[str] = []
301
+ # BUG #6 (Broadleaf field test): this counts DISTINCT dependent classes (DI
302
+ # dependents + reverse-call-graph callers, deduped to class level), which is a
303
+ # different metric from modernize's node.in_degree (raw incoming-edge count
304
+ # across ALL edge types, symbol-level). Name it explicitly so the two numbers
305
+ # are not read as a contradiction when cross-referenced.
301
306
  if in_degree >= 3:
302
- signals.append(f"high in-degree ({in_degree})")
307
+ signals.append(f"high fan-in ({in_degree} distinct caller classes)")
303
308
  elif in_degree:
304
- signals.append(f"in-degree {in_degree}")
309
+ signals.append(f"{in_degree} distinct caller class{'es' if in_degree != 1 else ''}")
305
310
  if lifecycle:
306
311
  signals.append(f"lifecycle methods detected ({'/'.join(lifecycle)})")
307
312
  suffix = f" — {', '.join(signals)}" if signals else ""
@@ -83,6 +83,17 @@ _TOKEN_CLIENTS: "tuple[tuple[str, str, str], ...]" = (
83
83
  ("InitialLdapContext", "ldap", "jndi-ldap"),
84
84
  ("InitialDirContext", "ldap", "jndi-ldap"),
85
85
  ("LdapContext", "ldap", "jndi-ldap"),
86
+ # Spring Security LDAP authentication (BUG #5, Broadleaf field test): the
87
+ # idiomatic auth integration is a UserDetails mapper that extends
88
+ # LdapUserDetailsMapper and consumes DirContextOperations — neither uses
89
+ # LdapTemplate/ContextSource directly, so the connection-type tokens above
90
+ # miss it entirely. These types come from spring-security-ldap / spring-ldap
91
+ # and are unambiguous LDAP integration points.
92
+ ("LdapUserDetailsMapper", "ldap", "spring-security-ldap"),
93
+ ("DirContextOperations", "ldap", "spring-ldap"),
94
+ ("LdapContextSource", "ldap", "spring-ldap"),
95
+ ("DefaultSpringSecurityContextSource", "ldap", "spring-security-ldap"),
96
+ ("BindAuthenticator", "ldap", "spring-security-ldap"),
86
97
  # Mail / SMTP (JavaMail / Jakarta Mail)
87
98
  ("JavaMailSender", "smtp", "spring-mail"),
88
99
  ("MimeMessage", "smtp", "javamail"),
@@ -645,6 +645,18 @@ def _classify_code_context(finding: "MigrationFinding") -> str:
645
645
  if is_test_or_fixture_path(path):
646
646
  return "test"
647
647
  norm = path.replace("\\", "/").lower()
648
+ # BUG #4 (Broadleaf field test): a Spring XML config file can live under
649
+ # src/main/resources yet be test-only scaffolding loaded exclusively by
650
+ # test @ContextConfiguration (e.g. bl-applicationContext-test-security.xml).
651
+ # is_test_or_fixture_path deliberately ignores the filename, so a `test`
652
+ # marker in the config file's own name is not seen. For XML config, a `test`
653
+ # token in the base filename is a strong, low-FP test-scope signal —
654
+ # production context files are not named `-test-`. Keep it out of
655
+ # blocking_count so test scaffolding never inflates the headline blocker.
656
+ if norm.endswith(".xml"):
657
+ base = norm.rsplit("/", 1)[-1]
658
+ if re.search(r"(?:^|[-_.])test(?:[-_.]|$)", base):
659
+ return "test"
648
660
  if any(frag in norm for frag in _GENERATED_PATH_FRAGMENTS):
649
661
  return "generated"
650
662
  # javax.annotation.Generated is the marker emitted into autogenerated code.
@@ -936,6 +948,42 @@ def _find_non_java_files(
936
948
  return xml_files, build_files
937
949
 
938
950
 
951
+ def _xml_explanation_for(rule: "_XmlRule", matches: list) -> str:
952
+ """Return an explanation reflecting the sub-pattern that actually matched.
953
+
954
+ BUG #4 (Broadleaf field test): MIG-031's pattern is an OR of two independent
955
+ triggers — old-style `<http auto-config="true">` and a versioned legacy schema
956
+ (spring-security-[2-5].x.xsd). The static template asserted BOTH, so a file
957
+ that only used auto-config (its schema line already being the unversioned
958
+ spring-security.xsd) got a factually wrong explanation claiming a versioned
959
+ schema. Emit only the condition(s) present in this file.
960
+ """
961
+ if rule.id != "MIG-031":
962
+ return rule.explanation
963
+ matched_text = " ".join(m.group(0).lower() for m in matches)
964
+ has_autoconfig = "auto-config" in matched_text
965
+ has_versioned_schema = bool(re.search(r"spring-security-[2345]\.", matched_text))
966
+ triggers: list[str] = []
967
+ if has_autoconfig:
968
+ triggers.append(
969
+ "uses the old-style <http auto-config='true'> shortcut, which was "
970
+ "removed in Spring Security 6"
971
+ )
972
+ if has_versioned_schema:
973
+ triggers.append(
974
+ "references a versioned legacy schema (spring-security-[2-5].x.xsd) "
975
+ "whose namespace attributes changed in Spring Security 6"
976
+ )
977
+ if not triggers: # defensive — pattern matched but neither branch classified
978
+ return rule.explanation
979
+ return (
980
+ "XML-based Spring Security configuration in this file "
981
+ + " and ".join(triggers)
982
+ + ". Spring Security 6 (Spring Boot 3) requires migrating this to "
983
+ "Java-based @Configuration with a SecurityFilterChain @Bean."
984
+ )
985
+
986
+
939
987
  def _scan_xml_file(text: str, rel_path: str) -> list["MigrationFinding"]:
940
988
  """Apply XML rules to raw XML text. Returns one finding per matched rule."""
941
989
  findings: list[MigrationFinding] = []
@@ -947,6 +995,7 @@ def _scan_xml_file(text: str, rel_path: str) -> list["MigrationFinding"]:
947
995
  continue
948
996
  first_line = text[: matches[0].start()].count("\n") + 1
949
997
  snippets = [m.group(0)[:120].strip() for m in matches[:5]]
998
+ explanation = _xml_explanation_for(rule, matches)
950
999
  findings.append(
951
1000
  MigrationFinding(
952
1001
  id=MigrationFinding.make_id(rule.id, rel_path),
@@ -956,7 +1005,7 @@ def _scan_xml_file(text: str, rel_path: str) -> list["MigrationFinding"]:
956
1005
  source_file=rel_path,
957
1006
  first_line=first_line,
958
1007
  imports_found=snippets,
959
- explanation=rule.explanation,
1008
+ explanation=explanation,
960
1009
  fix_hint=rule.fix_hint,
961
1010
  migration_target=rule.migration_target,
962
1011
  openrewrite_recipe=rule.openrewrite_recipe,