sourcecode 1.67.0__tar.gz → 1.68.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 (122) hide show
  1. {sourcecode-1.67.0 → sourcecode-1.68.0}/CHANGELOG.md +45 -0
  2. {sourcecode-1.67.0 → sourcecode-1.68.0}/PKG-INFO +1 -1
  3. {sourcecode-1.67.0 → sourcecode-1.68.0}/pyproject.toml +1 -1
  4. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/__init__.py +1 -1
  5. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/cli.py +21 -0
  6. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/integration_detector.py +109 -10
  7. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/migrate_check.py +56 -0
  8. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/repository_ir.py +57 -7
  9. {sourcecode-1.67.0 → sourcecode-1.68.0}/.github/workflows/build-windows.yml +0 -0
  10. {sourcecode-1.67.0 → sourcecode-1.68.0}/.gitignore +0 -0
  11. {sourcecode-1.67.0 → sourcecode-1.68.0}/.ruff.toml +0 -0
  12. {sourcecode-1.67.0 → sourcecode-1.68.0}/CONTRIBUTING.md +0 -0
  13. {sourcecode-1.67.0 → sourcecode-1.68.0}/LICENSE +0 -0
  14. {sourcecode-1.67.0 → sourcecode-1.68.0}/README.md +0 -0
  15. {sourcecode-1.67.0 → sourcecode-1.68.0}/SECURITY.md +0 -0
  16. {sourcecode-1.67.0 → sourcecode-1.68.0}/raw +0 -0
  17. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/adaptive_scanner.py +0 -0
  18. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/architecture_analyzer.py +0 -0
  19. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/architecture_summary.py +0 -0
  20. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/ast_extractor.py +0 -0
  21. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/cache.py +0 -0
  22. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/canonical_ir.py +0 -0
  23. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/cir_graphs.py +0 -0
  24. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/classifier.py +0 -0
  25. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/code_notes_analyzer.py +0 -0
  26. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/confidence_analyzer.py +0 -0
  27. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/context_scorer.py +0 -0
  28. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/context_summarizer.py +0 -0
  29. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/contract_model.py +0 -0
  30. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/contract_pipeline.py +0 -0
  31. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/coverage_parser.py +0 -0
  32. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/dependency_analyzer.py +0 -0
  33. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/detectors/__init__.py +0 -0
  34. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/detectors/base.py +0 -0
  35. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/detectors/csproj_parser.py +0 -0
  36. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/detectors/dart.py +0 -0
  37. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/detectors/dotnet.py +0 -0
  38. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/detectors/elixir.py +0 -0
  39. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/detectors/go.py +0 -0
  40. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/detectors/heuristic.py +0 -0
  41. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/detectors/hybrid.py +0 -0
  42. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/detectors/java.py +0 -0
  43. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/detectors/jvm_ext.py +0 -0
  44. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/detectors/nodejs.py +0 -0
  45. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/detectors/parsers.py +0 -0
  46. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/detectors/php.py +0 -0
  47. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/detectors/project.py +0 -0
  48. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/detectors/python.py +0 -0
  49. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/detectors/ruby.py +0 -0
  50. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/detectors/rust.py +0 -0
  51. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/detectors/systems.py +0 -0
  52. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/detectors/terraform.py +0 -0
  53. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/detectors/tooling.py +0 -0
  54. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/doc_analyzer.py +0 -0
  55. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/entrypoint_classifier.py +0 -0
  56. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/env_analyzer.py +0 -0
  57. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/error_schema.py +0 -0
  58. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/explain.py +0 -0
  59. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/file_chunker.py +0 -0
  60. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/file_classifier.py +0 -0
  61. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/flow_analyzer.py +0 -0
  62. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/format_contract.py +0 -0
  63. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/fqn_utils.py +0 -0
  64. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/git_analyzer.py +0 -0
  65. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/graph_analyzer.py +0 -0
  66. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/hibernate_strat.py +0 -0
  67. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/license.py +0 -0
  68. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/mcp/__init__.py +0 -0
  69. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/mcp/onboarding/__init__.py +0 -0
  70. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/mcp/onboarding/applier.py +0 -0
  71. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/mcp/onboarding/backup.py +0 -0
  72. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/mcp/onboarding/detector.py +0 -0
  73. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/mcp/onboarding/planner.py +0 -0
  74. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/mcp/orchestrator.py +0 -0
  75. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/mcp/registry.py +0 -0
  76. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/mcp/runner.py +0 -0
  77. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/mcp/server.py +0 -0
  78. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/mcp_nudge.py +0 -0
  79. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/metrics_analyzer.py +0 -0
  80. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/openapi_surface.py +0 -0
  81. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/output_budget.py +0 -0
  82. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/path_filters.py +0 -0
  83. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/pr_comment_renderer.py +0 -0
  84. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/pr_impact.py +0 -0
  85. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/prepare_context.py +0 -0
  86. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/progress.py +0 -0
  87. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/ranking_engine.py +0 -0
  88. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/redactor.py +0 -0
  89. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/relevance_scorer.py +0 -0
  90. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/rename_refactor.py +0 -0
  91. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/repo_classifier.py +0 -0
  92. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/ris.py +0 -0
  93. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/runtime_classifier.py +0 -0
  94. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/scanner.py +0 -0
  95. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/schema.py +0 -0
  96. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/security_config.py +0 -0
  97. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/semantic_analyzer.py +0 -0
  98. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/serializer.py +0 -0
  99. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/spring_event_topology.py +0 -0
  100. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/spring_findings.py +0 -0
  101. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/spring_impact.py +0 -0
  102. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/spring_model.py +0 -0
  103. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/spring_security_audit.py +0 -0
  104. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/spring_semantic.py +0 -0
  105. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/spring_tx_analyzer.py +0 -0
  106. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/summarizer.py +0 -0
  107. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/telemetry/__init__.py +0 -0
  108. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/telemetry/config.py +0 -0
  109. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/telemetry/consent.py +0 -0
  110. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/telemetry/events.py +0 -0
  111. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/telemetry/filters.py +0 -0
  112. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/telemetry/transport.py +0 -0
  113. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/tree_utils.py +0 -0
  114. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/validation_surface.py +0 -0
  115. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/version_check.py +0 -0
  116. {sourcecode-1.67.0 → sourcecode-1.68.0}/src/sourcecode/workspace.py +0 -0
  117. {sourcecode-1.67.0 → sourcecode-1.68.0}/supabase/functions/README.md +0 -0
  118. {sourcecode-1.67.0 → sourcecode-1.68.0}/supabase/functions/get-license/index.ts +0 -0
  119. {sourcecode-1.67.0 → sourcecode-1.68.0}/supabase/functions/lemonsqueezy-webhook/index.ts +0 -0
  120. {sourcecode-1.67.0 → sourcecode-1.68.0}/supabase/functions/telemetry/index.ts +0 -0
  121. {sourcecode-1.67.0 → sourcecode-1.68.0}/supabase/sql/license_event_ordering.sql +0 -0
  122. {sourcecode-1.67.0 → sourcecode-1.68.0}/supabase/sql/telemetry_events.sql +0 -0
@@ -1,5 +1,50 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.68.0] — 2026-06-30
4
+
5
+ ### Fixed — narrative/structured-data parity on non-Spring DI repos (Netflix Eureka field test)
6
+
7
+ A real audit of **Netflix Eureka** (Gradle multi-module, JAX-RS/Jersey 1.x + Google
8
+ Guice, **no Spring, no Hibernate, no persistence layer**) surfaced six reliability
9
+ defects that share one root cause: **prose that assumed a Spring/Hibernate/persistence
10
+ context even though the same JSON's own structured fields already knew it did not
11
+ apply.** A consumer trusting the narrative over the structured fields would have read
12
+ false conclusions. Guiding principle applied throughout: **a narrative term (Spring,
13
+ Guice, persistence, …) may only appear when the corresponding structured signal
14
+ confirms it.** New regression coverage across four suites.
15
+
16
+ - **SMTP false positive on a log string (BUG #1).** `export --integrations` reported
17
+ `kind=smtp` for `logger.warn("Transport initialization failure", e)` — the bare word
18
+ "Transport" in a log literal. The detector now (a) skips any token matched inside a
19
+ string literal and (b) gates the JavaMail tokens (`Transport`, `MimeMessage`) on a
20
+ `javax.mail`/`jakarta.mail`/`org.springframework.mail` import. Eureka SMTP hits 3 → 0.
21
+ - **JNDI DNS mislabeled as LDAP (BUG #2).** `javax.naming.directory.{Dir,Initial}Context`
22
+ is protocol-agnostic; it is now classified by its `INITIAL_CONTEXT_FACTORY` —
23
+ `DnsContextFactory` → `kind=dns` (`client=jndi-dns`), `LdapCtxFactory` → `ldap`, and an
24
+ unresolvable factory → `kind=naming-directory-unknown` with `confidence=low`, never an
25
+ assumed LDAP. Eureka `DnsResolver` now reports `dns`.
26
+ - **"Spring Boot 3" narrative on a non-Spring repo (BUG #3).** When `spring_present` is
27
+ `False`, every `migrate-check` finding's `explanation` is rewritten from Spring-Boot
28
+ framing into framework-neutral Jakarta-EE / servlet-container framing (the javax→jakarta
29
+ finding itself is unchanged). Guarantee: no explanation contains "Spring Boot" unless
30
+ the report confirms runtime Spring.
31
+ - **Inconsistent DI-framework label (BUG #4).** `impact` emitted "Spring/CDI DI pattern"
32
+ in the short `explanation` but "Spring/CDI/Guice" in `via_interface_note` of the *same*
33
+ JSON, while `direct_callers` proved the framework was Guice. Both strings now derive
34
+ from one label computed from caller evidence — a `*.guice.*` caller yields "Guice".
35
+ - **"persistence paths" for non-persistence mappers (BUG #5).** Eureka's `AzToRegionMapper`
36
+ classes (AWS topology, no data layer) were counted as "4 persistence paths". Each
37
+ `mappers_affected` entry now carries `mapper_kind` (`persistence` only for a confirmed
38
+ `@Repository` role or MyBatis `mapper_interface`; otherwise `name_heuristic`). The
39
+ explanation says "persistence path" only for confirmed entries and neutral
40
+ "data-mapping class" otherwise — the word "persistence" never appears for a repo with no
41
+ confirmed persistence signal.
42
+ - **Ambiguous `member_count` unit in `modernize` (BUG #6).** `subsystem_summary.member_count`
43
+ counts all graph members (classes + methods + fields), ~5× the class count and not
44
+ comparable to repo-level `total_classes`. Each entry now also carries an unambiguous
45
+ `class_count` (class/interface declarations only, comparable to `total_classes`), with a
46
+ `subsystem_summary_note` documenting both units.
47
+
3
48
  ## [1.67.0] — 2026-06-30
4
49
 
5
50
  ### Fixed — framing accuracy on non-Spring repos (Apache OFBiz field test)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 1.67.0
3
+ Version: 1.68.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.67.0"
7
+ version = "1.68.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.67.0"
3
+ __version__ = "1.68.0"
@@ -5777,14 +5777,32 @@ def modernize_cmd(
5777
5777
  "statically_unreferenced": len(dead_zones),
5778
5778
  "framework_dispatched": len(framework_dispatched),
5779
5779
  }
5780
+ # BUG #6 (v1.68.0): `member_count` counts ALL graph members in the subsystem —
5781
+ # classes, methods AND fields — so it runs ~5x higher than the class count and
5782
+ # is not comparable to the repo-level `total_classes` (which counts only
5783
+ # class/interface nodes). Surface an unambiguous `class_count` alongside it,
5784
+ # computed against the same class/interface node set as total_classes, so a
5785
+ # reader does not have to reverse-engineer the unit. `member_count` is retained
5786
+ # for backward compatibility but is now documented as the graph-member tally.
5787
+ _class_fqns = {n["fqn"] for n in graph_nodes if n.get("type") in ("class", "interface")}
5788
+
5789
+ def _class_count(members: list) -> int:
5790
+ return sum(1 for m in members if m in _class_fqns)
5791
+
5780
5792
  _subsystem_summary = [
5781
5793
  {
5782
5794
  "label": s.get("label") or s.get("name") or "",
5783
5795
  "package_prefix": s.get("package_prefix") or s.get("pkg") or "",
5796
+ "class_count": _class_count(s.get("members") or []),
5784
5797
  "member_count": len(s.get("members") or []),
5785
5798
  }
5786
5799
  for s in subsystems[:15]
5787
5800
  ]
5801
+ _subsystem_summary_note = (
5802
+ "class_count = class/interface declarations only (comparable to "
5803
+ "summary.total_classes). member_count = all graph members in the subsystem "
5804
+ "including methods and fields, so it is larger and not a class tally."
5805
+ )
5788
5806
 
5789
5807
  if (not _mod_is_pro) and _mod_large(str(root)):
5790
5808
  # Large monolith, free tier: structural discovery preview only — no dead
@@ -5801,6 +5819,7 @@ def modernize_cmd(
5801
5819
  ),
5802
5820
  "summary": _summary,
5803
5821
  "subsystem_summary": _subsystem_summary,
5822
+ "subsystem_summary_note": _subsystem_summary_note,
5804
5823
  "hotspot_candidates": hotspots[:3],
5805
5824
  "high_coupling_nodes": [
5806
5825
  {"fqn": n["fqn"], "in_degree": n.get("in_degree", 0), "role": n.get("role", "other")}
@@ -5833,9 +5852,11 @@ def modernize_cmd(
5833
5852
  for n in framework_dispatched
5834
5853
  ],
5835
5854
  "subsystem_summary": _subsystem_summary,
5855
+ "subsystem_summary_note": _subsystem_summary_note,
5836
5856
  "cross_module_tangles": [
5837
5857
  {
5838
5858
  "label": s.get("label") or s.get("name") or "",
5859
+ "class_count": _class_count(s.get("members") or []),
5839
5860
  "member_count": len(s.get("members") or []),
5840
5861
  }
5841
5862
  for s in tangle_modules
@@ -7,8 +7,14 @@ deterministic source-text matching (same approach as the JNDI datasource scan in
7
7
 
8
8
  Covered clients:
9
9
 
10
- * HTTP — ``RestTemplate``, ``WebClient``, ``@FeignClient`` (declarative)
11
- * LDAP — ``LdapTemplate``
10
+ * HTTP — ``RestTemplate``, ``WebClient``, ``@FeignClient`` (declarative),
11
+ JDK/Apache/OkHttp clients
12
+ * LDAP — ``LdapTemplate``, JNDI ``InitialLdapContext``/``LdapContext``
13
+ * DNS — JNDI ``DirContext`` configured with ``DnsContextFactory`` (BUG #2:
14
+ ``DirContext`` is protocol-agnostic and is classified by its
15
+ ``INITIAL_CONTEXT_FACTORY``, not assumed to be LDAP)
16
+ * SMTP — JavaMail / Jakarta Mail (BUG #1: gated on a mail import so the bare
17
+ word "Transport" in a log string is not a false positive)
12
18
  * JMS — ``JmsTemplate``, ActiveMQ connection factories
13
19
 
14
20
  Each hit is reported with a ``file:line`` evidence anchor and, when a literal URL
@@ -28,6 +34,24 @@ _URL_RE = re.compile(r'"((?:https?|ldaps?|tcp|amqp|jms|nio)://[^"]*)"')
28
34
  # First string literal on a line (fallback target, e.g. WebClient.create("x")).
29
35
  _STR_RE = re.compile(r'"([^"]+)"')
30
36
 
37
+ # BUG #1 (v1.68.0): a JavaMail/Jakarta-Mail import is required before an SMTP token
38
+ # (Transport, MimeMessage) is trusted. The bare word "Transport" is also a common
39
+ # English noun that appears in log strings ( logger.warn("Transport initialization
40
+ # failure") in non-mail code), so a mail-import gate plus a string-literal skip
41
+ # stops those false positives.
42
+ _MAIL_IMPORT_RE = re.compile(
43
+ r"^\s*import\s+(?:(?:javax|jakarta)\.mail|org\.springframework\.mail)\b",
44
+ re.MULTILINE,
45
+ )
46
+
47
+ # BUG #2 (v1.68.0): javax.naming.directory.{DirContext,InitialDirContext} is NOT
48
+ # LDAP-specific — the actual protocol is decided by the value bound to
49
+ # Context.INITIAL_CONTEXT_FACTORY. com.sun.jndi.dns.DnsContextFactory means DNS
50
+ # (SRV/A record lookups), com.sun.jndi.ldap.LdapCtxFactory means LDAP. Classify by
51
+ # the factory class present in the file instead of defaulting to LDAP.
52
+ _DNS_FACTORY_RE = re.compile(r"jndi\.dns|DnsContextFactory", re.IGNORECASE)
53
+ _LDAP_FACTORY_RE = re.compile(r"jndi\.ldap|LdapCtxFactory", re.IGNORECASE)
54
+
31
55
  # Declarative HTTP client. Attrs may span multiple lines, so matched on full text.
32
56
  _FEIGN_RE = re.compile(r"@FeignClient\s*\(([^)]*)\)", re.DOTALL)
33
57
  _ATTR_URL_RE = re.compile(r'url\s*=\s*"([^"]*)"')
@@ -82,6 +106,39 @@ def _extract_target(line: str) -> Optional[str]:
82
106
  return None
83
107
 
84
108
 
109
+ def _in_string_literal(line: str, idx: int) -> bool:
110
+ """True if char offset ``idx`` falls inside a double-quoted string on ``line``.
111
+
112
+ BUG #1 (v1.68.0): a Java type token never legitimately appears inside a string
113
+ literal, so a match there (e.g. the word "Transport" in a log message) is noise,
114
+ not a real client construct. Counts unescaped quotes before ``idx``.
115
+ """
116
+ quote_count = 0
117
+ i = 0
118
+ while i < idx and i < len(line):
119
+ ch = line[i]
120
+ if ch == "\\":
121
+ i += 2
122
+ continue
123
+ if ch == '"':
124
+ quote_count += 1
125
+ i += 1
126
+ return quote_count % 2 == 1
127
+
128
+
129
+ def _classify_naming_factory(text: str) -> Optional[str]:
130
+ """Classify a javax.naming.directory usage by its INITIAL_CONTEXT_FACTORY.
131
+
132
+ Returns ``"dns"``, ``"ldap"``, or ``None`` (factory not statically resolvable).
133
+ See BUG #2 — DirContext alone does not imply LDAP.
134
+ """
135
+ if _DNS_FACTORY_RE.search(text):
136
+ return "dns"
137
+ if _LDAP_FACTORY_RE.search(text):
138
+ return "ldap"
139
+ return None
140
+
141
+
85
142
  def detect_integrations(file_paths: "list[str]", root: Path) -> dict:
86
143
  """Detect outbound integrations across ``file_paths`` (relative to ``root``).
87
144
 
@@ -93,18 +150,31 @@ def detect_integrations(file_paths: "list[str]", root: Path) -> dict:
93
150
  seen: "set[tuple[str, str, Optional[str], str]]" = set()
94
151
  records: "list[dict]" = []
95
152
 
96
- def _add(kind: str, client: str, target: Optional[str], rel: str, line: int) -> None:
153
+ def _add(
154
+ kind: str,
155
+ client: str,
156
+ target: Optional[str],
157
+ rel: str,
158
+ line: int,
159
+ confidence: Optional[str] = None,
160
+ ) -> None:
97
161
  evidence = f"{rel}:{line}"
98
162
  key = (kind, client, target, evidence)
99
163
  if key in seen:
100
164
  return
101
165
  seen.add(key)
102
- records.append({
166
+ rec = {
103
167
  "kind": kind,
104
168
  "client": client,
105
169
  "target": target,
106
170
  "evidence": evidence,
107
- })
171
+ }
172
+ # Per-record confidence is emitted only when the classification is uncertain
173
+ # (e.g. a JNDI DirContext whose factory could not be resolved). Confident hits
174
+ # stay schema-clean without the field.
175
+ if confidence is not None:
176
+ rec["confidence"] = confidence
177
+ records.append(rec)
108
178
 
109
179
  for rel in file_paths:
110
180
  try:
@@ -126,6 +196,10 @@ def detect_integrations(file_paths: "list[str]", root: Path) -> dict:
126
196
  )
127
197
  _add("http", "feign", target, rel, _line_of(text, m.start()))
128
198
 
199
+ # Per-file context used to disambiguate / gate uncertain tokens (BUG #1/#2).
200
+ has_mail_import = bool(_MAIL_IMPORT_RE.search(text))
201
+ naming_factory = _classify_naming_factory(text)
202
+
129
203
  # Token clients — per line, skipping imports/package/comment noise.
130
204
  # First pass records the declaration site and any variable name bound to
131
205
  # the client, so a later call site (where the URL literal usually lives)
@@ -146,7 +220,28 @@ def detect_integrations(file_paths: "list[str]", root: Path) -> dict:
146
220
  m = token_re.search(line)
147
221
  if not m:
148
222
  continue
149
- _add(kind, client, _extract_target(line), rel, lineno)
223
+ # BUG #1: a token matched inside a string literal (log message, doc
224
+ # comment text) is never a real client construct — skip it.
225
+ if _in_string_literal(line, m.start()):
226
+ continue
227
+ confidence: Optional[str] = None
228
+ # BUG #1: SMTP tokens require a JavaMail/Jakarta-Mail import to be
229
+ # trusted — "Transport" / "MimeMessage" are too generic otherwise.
230
+ if kind == "smtp" and not has_mail_import:
231
+ continue
232
+ # BUG #2: javax.naming.directory.{Dir,Initial}Context is protocol-
233
+ # agnostic. Reclassify by the configured context factory; default to
234
+ # an explicit low-confidence "unknown" rather than assuming LDAP.
235
+ if client == "jndi-ldap" and "Dir" in token_re.pattern:
236
+ if naming_factory == "dns":
237
+ kind, client = "dns", "jndi-dns"
238
+ elif naming_factory == "ldap":
239
+ kind, client = "ldap", "jndi-ldap"
240
+ else:
241
+ kind, client, confidence = (
242
+ "naming-directory-unknown", "jndi-dircontext", "low",
243
+ )
244
+ _add(kind, client, _extract_target(line), rel, lineno, confidence)
150
245
  tok = m.group(0)
151
246
  # `Type name` (field/local decl) and `name = new Type(` forms.
152
247
  decl = re.search(re.escape(tok) + r"\s+(\w+)\b", line)
@@ -186,9 +281,13 @@ def detect_integrations(file_paths: "list[str]", root: Path) -> dict:
186
281
  "confidence": confidence,
187
282
  "coverage_note": (
188
283
  "Detects HTTP (RestTemplate/WebClient/JDK/Apache/OkHttp), LDAP (Spring "
189
- "+ JNDI), SMTP (JavaMail), and JMS client constructs by source-text "
190
- "matching. A count of 0 means no such construct was found, not that the "
191
- "system has no outbound integrations runtime/DI-wired clients are not "
192
- "statically visible."
284
+ "+ JNDI), DNS (JNDI DirContext w/ DnsContextFactory), SMTP (JavaMail, "
285
+ "import-gated), and JMS client constructs by source-text matching. JNDI "
286
+ "DirContext usage is classified by its INITIAL_CONTEXT_FACTORY (dns vs "
287
+ "ldap); when the factory is not statically resolvable the kind is "
288
+ "'naming-directory-unknown' with confidence='low', never assumed LDAP. A "
289
+ "count of 0 means no such construct was found, not that the system has no "
290
+ "outbound integrations — runtime/DI-wired clients are not statically "
291
+ "visible."
193
292
  ),
194
293
  }
@@ -1631,6 +1631,53 @@ def _refine_mig006(matched_imports: list[str]) -> tuple[str, str]:
1631
1631
  )
1632
1632
 
1633
1633
 
1634
+ # ---------------------------------------------------------------------------
1635
+ # BUG #3 (v1.68.0): framework-gated narrative.
1636
+ # MIG-* explanations are written assuming a Spring Boot 2→3 upgrade because that is
1637
+ # the overwhelmingly common Jakarta-migration context. On a repo where the tool has
1638
+ # already determined spring_present=False (plain Jakarta EE, JAX-RS/Jersey, Quarkus,
1639
+ # Guice, ...), the underlying javax→jakarta finding is still valid, but prose that
1640
+ # says "Spring Boot 3 requires ..." is factually wrong for that repo and misleads any
1641
+ # reader who trusts the narrative over the structured spring_present field.
1642
+ #
1643
+ # Treat the narrative as a pure function of the structured data: when spring_present
1644
+ # is False, rewrite Spring-specific framing into framework-neutral Jakarta-EE /
1645
+ # servlet-container framing. Ordered phrase map first (keeps nice sentences for the
1646
+ # common phrasings), then a catch-all so no "Spring Boot" string can survive.
1647
+ # ---------------------------------------------------------------------------
1648
+ _NON_SPRING_EXPLANATION_SUBS: tuple[tuple[str, str], ...] = (
1649
+ ("Spring Boot 3 bundles Jakarta Servlet 6.0.",
1650
+ "Modern servlet containers (Jetty 12+, Tomcat 11+) require Jakarta Servlet 6.0."),
1651
+ ("Spring Boot 3 requires Jakarta Servlet 5.0+",
1652
+ "Jakarta EE 9+ servlet containers require Jakarta Servlet 5.0+"),
1653
+ ("Spring Boot 3 uses Jakarta EE 9 which moved",
1654
+ "Jakarta EE 9 moved"),
1655
+ ("Spring Boot 3 uses Hibernate Validator 8.x which implements jakarta.validation.",
1656
+ "Jakarta EE 9+ uses Hibernate Validator 8.x which implements jakarta.validation."),
1657
+ ("Spring Boot 3 depends on Jakarta Transactions (jakarta.transaction).",
1658
+ "Jakarta EE 9+ uses Jakarta Transactions (jakarta.transaction)."),
1659
+ ("Spring Boot 3 requires Hibernate 6",
1660
+ "Jakarta Persistence (JPA 3.x) requires Hibernate 6"),
1661
+ ("Spring Boot 3 cache abstraction. Spring Boot 3 requires EhCache 3.x",
1662
+ "Jakarta-EE cache abstraction. The modern JCache provider requires EhCache 3.x"),
1663
+ # Catch-all: any remaining Spring-Boot framing → generic Jakarta EE 9+.
1664
+ ("Spring Boot 3", "Jakarta EE 9+"),
1665
+ ("Spring Boot 2", "the pre-Jakarta (javax) baseline"),
1666
+ )
1667
+
1668
+
1669
+ def _neutralize_non_spring_explanation(text: str) -> str:
1670
+ """Rewrite Spring-specific migration prose into framework-neutral Jakarta framing.
1671
+
1672
+ Applied only when the report's spring_present is False. Deterministic phrase
1673
+ substitution — no model, no guess. Guarantees the returned text contains no
1674
+ "Spring Boot" framing that the structured data does not support.
1675
+ """
1676
+ for needle, repl in _NON_SPRING_EXPLANATION_SUBS:
1677
+ text = text.replace(needle, repl)
1678
+ return text
1679
+
1680
+
1634
1681
  def _scan_file(
1635
1682
  source: str,
1636
1683
  rel_path: str,
@@ -1822,6 +1869,15 @@ def run_migrate_check(
1822
1869
  for f in all_findings:
1823
1870
  f.code_context = _classify_code_context(f)
1824
1871
 
1872
+ # BUG #3 (v1.68.0): on a non-Spring repo, rewrite Spring-Boot-specific framing in
1873
+ # every finding's explanation into framework-neutral Jakarta-EE framing. The
1874
+ # finding itself (javax→jakarta) stays; only the prose is corrected so it matches
1875
+ # the structured spring_present=False signal in the same report.
1876
+ if not spring_present:
1877
+ for f in all_findings:
1878
+ if f.explanation:
1879
+ f.explanation = _neutralize_non_spring_explanation(f.explanation)
1880
+
1825
1881
  # Hibernate 5→6 stratification (independent of min_severity — it is its own
1826
1882
  # risk model, not a severity-filtered finding stream).
1827
1883
  from sourcecode.hibernate_strat import analyze_hibernate
@@ -20,7 +20,7 @@ import subprocess
20
20
  from collections import deque
21
21
  from dataclasses import dataclass, field
22
22
  from pathlib import Path
23
- from typing import Any, Optional
23
+ from typing import Any, Iterable, Optional
24
24
 
25
25
  from sourcecode.fqn_utils import normalize_owner_fqn as _normalize_owner_fqn
26
26
  from sourcecode.path_filters import is_test_path as _is_test_path
@@ -4700,9 +4700,16 @@ def compute_blast_radius(
4700
4700
  )
4701
4701
  if is_mapper and fqn not in _seen_mapper_fqns:
4702
4702
  _seen_mapper_fqns.add(fqn)
4703
+ # BUG #5 (v1.68.0): distinguish a CONFIRMED persistence mapper (a
4704
+ # @Repository/DAO role or a MyBatis @Mapper interface) from a class that
4705
+ # only matched the name pattern (e.g. AzToRegionMapper — an AWS topology
4706
+ # mapper with no data layer). Only confirmed entries may be described as
4707
+ # "persistence" in the narrative.
4708
+ _is_persistence = role in _MAPPER_ROLES or symbol_kind == "mapper_interface"
4703
4709
  _mapper_entry: dict = {
4704
4710
  "fqn": fqn,
4705
4711
  "role": role or ("mapper" if symbol_kind == "mapper_interface" else "repository"),
4712
+ "mapper_kind": "persistence" if _is_persistence else "name_heuristic",
4706
4713
  "source_file": node_dict.get("source_file") or "",
4707
4714
  }
4708
4715
  if canonical != fqn:
@@ -4841,18 +4848,33 @@ def compute_blast_radius(
4841
4848
  _parts.append(f"{n_ep} endpoint{'s' if n_ep != 1 else ''} exposed")
4842
4849
  if n_txn:
4843
4850
  _parts.append(f"{n_txn} transactional boundary{'s' if n_txn != 1 else ''} touched")
4844
- if n_mappers:
4845
- _parts.append(f"{n_mappers} persistence path{'s' if n_mappers != 1 else ''} in blast cone")
4851
+ # BUG #5 (v1.68.0): only confirmed persistence mappers earn the word "persistence".
4852
+ # Name-heuristic matches (e.g. *Mapper utility classes with no data layer) are
4853
+ # surfaced as neutral "data-mapping class(es)" so the prose never invents a
4854
+ # persistence tier the repo does not have.
4855
+ _n_persist = sum(1 for m in mappers_affected if m.get("mapper_kind") == "persistence")
4856
+ _n_heur = n_mappers - _n_persist
4857
+ if _n_persist:
4858
+ _parts.append(f"{_n_persist} persistence path{'s' if _n_persist != 1 else ''} in blast cone")
4859
+ if _n_heur:
4860
+ _parts.append(f"{_n_heur} data-mapping class{'es' if _n_heur != 1 else ''} in blast cone")
4846
4861
  if n_sec:
4847
4862
  _parts.append(f"{n_sec} security-gated endpoint{'s' if n_sec != 1 else ''} affected")
4848
4863
  if n_modules > 1:
4849
4864
  _parts.append(f"impact crosses {n_modules} modules")
4850
4865
 
4866
+ # BUG #4 (v1.68.0): derive the DI-framework label ONCE from caller evidence and
4867
+ # reuse it for both the short explanation and the long via_interface_note, so the
4868
+ # two never disagree (the old code hardcoded "Spring/CDI" in one and "Spring/CDI/
4869
+ # Guice" in the other). When the actual framework is identifiable from the caller
4870
+ # FQNs (e.g. *.guice.* packages on a Guice repo), name it specifically instead of
4871
+ # a generic guess.
4872
+ _di_label = _detect_di_framework_label(direct_callers)
4851
4873
  if _iface_bridging:
4852
4874
  _iface_names = [b["interface"].split(".")[-1] for b in _iface_bridging]
4853
4875
  _parts.append(
4854
4876
  f"callers resolved via interface{'s' if len(_iface_names) > 1 else ''} "
4855
- f"({', '.join(_iface_names)}) — Spring/CDI DI pattern"
4877
+ f"({', '.join(_iface_names)}) — {_di_label} DI pattern"
4856
4878
  )
4857
4879
 
4858
4880
  # Transparency: hub-class BFS truncation must appear in explanation so the
@@ -4924,9 +4946,9 @@ def compute_blast_radius(
4924
4946
  if _iface_bridging:
4925
4947
  out["via_interface_resolution"] = _iface_bridging
4926
4948
  out["via_interface_note"] = (
4927
- "Target is a concrete class injected via interface(s) in DI frameworks "
4928
- "(Spring/CDI/Guice). direct_callers includes callers of the implemented "
4929
- "interface(s) — these are the real production dependents."
4949
+ "Target is a concrete class injected via interface(s) in a "
4950
+ f"{_di_label} DI framework. direct_callers includes callers of the "
4951
+ "implemented interface(s) — these are the real production dependents."
4930
4952
  )
4931
4953
  if _bfs_truncated:
4932
4954
  out["bfs_truncation_reason"] = "hub_class_depth_cap"
@@ -5106,3 +5128,31 @@ def _all_callers_from_rg(fqn: str, reverse_graph: dict[str, dict[str, list[str]]
5106
5128
  def _simple_name(fqn: str) -> str:
5107
5129
  """Extract the simple class name from a fully-qualified name."""
5108
5130
  return fqn.split(".")[-1].split("#")[0]
5131
+
5132
+
5133
+ def _detect_di_framework_label(caller_fqns: "Iterable[str]") -> str:
5134
+ """Name the DI framework(s) evidenced by the caller FQNs (BUG #4, v1.68.0).
5135
+
5136
+ Returns a "/"-joined label of the frameworks actually identifiable from the
5137
+ callers' package names (e.g. "Guice" when callers live under a ``*.guice.*``
5138
+ package, "Spring" under ``org.springframework``). When nothing is identifiable
5139
+ the label degrades to the generic "Spring/CDI/Guice" rather than asserting a
5140
+ framework the evidence does not support. The same label feeds both the short
5141
+ explanation and the long via_interface_note so they cannot disagree.
5142
+ """
5143
+ frameworks: set[str] = set()
5144
+ for fqn in caller_fqns:
5145
+ low = fqn.lower()
5146
+ if "guice" in low or "com.google.inject" in low:
5147
+ frameworks.add("Guice")
5148
+ if "springframework" in low or ".spring." in low:
5149
+ frameworks.add("Spring")
5150
+ if "jakarta.enterprise" in low or "javax.enterprise" in low or ".cdi." in low:
5151
+ frameworks.add("CDI")
5152
+ if "micronaut" in low:
5153
+ frameworks.add("Micronaut")
5154
+ if "dagger" in low:
5155
+ frameworks.add("Dagger")
5156
+ if frameworks:
5157
+ return "/".join(sorted(frameworks))
5158
+ return "Spring/CDI/Guice"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes