sourcecode 1.68.0__tar.gz → 1.70.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.68.0 → sourcecode-1.70.0}/CHANGELOG.md +129 -0
  2. {sourcecode-1.68.0 → sourcecode-1.70.0}/PKG-INFO +1 -1
  3. {sourcecode-1.68.0 → sourcecode-1.70.0}/pyproject.toml +1 -1
  4. sourcecode-1.70.0/scripts/generate_jdk_exports.py +132 -0
  5. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/__init__.py +1 -1
  6. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/architecture_analyzer.py +39 -6
  7. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/architecture_summary.py +39 -0
  8. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/classifier.py +121 -1
  9. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/detectors/java.py +36 -1
  10. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/explain.py +83 -1
  11. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/hibernate_strat.py +96 -21
  12. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/integration_detector.py +42 -0
  13. sourcecode-1.70.0/src/sourcecode/jdk_exports.py +26 -0
  14. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/migrate_check.py +87 -9
  15. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/repository_ir.py +81 -2
  16. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/spring_impact.py +30 -3
  17. {sourcecode-1.68.0 → sourcecode-1.70.0}/.github/workflows/build-windows.yml +0 -0
  18. {sourcecode-1.68.0 → sourcecode-1.70.0}/.gitignore +0 -0
  19. {sourcecode-1.68.0 → sourcecode-1.70.0}/.ruff.toml +0 -0
  20. {sourcecode-1.68.0 → sourcecode-1.70.0}/CONTRIBUTING.md +0 -0
  21. {sourcecode-1.68.0 → sourcecode-1.70.0}/LICENSE +0 -0
  22. {sourcecode-1.68.0 → sourcecode-1.70.0}/README.md +0 -0
  23. {sourcecode-1.68.0 → sourcecode-1.70.0}/SECURITY.md +0 -0
  24. {sourcecode-1.68.0 → sourcecode-1.70.0}/raw +0 -0
  25. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/adaptive_scanner.py +0 -0
  26. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/ast_extractor.py +0 -0
  27. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/cache.py +0 -0
  28. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/canonical_ir.py +0 -0
  29. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/cir_graphs.py +0 -0
  30. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/cli.py +0 -0
  31. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/code_notes_analyzer.py +0 -0
  32. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/confidence_analyzer.py +0 -0
  33. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/context_scorer.py +0 -0
  34. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/context_summarizer.py +0 -0
  35. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/contract_model.py +0 -0
  36. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/contract_pipeline.py +0 -0
  37. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/coverage_parser.py +0 -0
  38. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/dependency_analyzer.py +0 -0
  39. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/detectors/__init__.py +0 -0
  40. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/detectors/base.py +0 -0
  41. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/detectors/csproj_parser.py +0 -0
  42. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/detectors/dart.py +0 -0
  43. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/detectors/dotnet.py +0 -0
  44. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/detectors/elixir.py +0 -0
  45. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/detectors/go.py +0 -0
  46. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/detectors/heuristic.py +0 -0
  47. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/detectors/hybrid.py +0 -0
  48. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/detectors/jvm_ext.py +0 -0
  49. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/detectors/nodejs.py +0 -0
  50. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/detectors/parsers.py +0 -0
  51. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/detectors/php.py +0 -0
  52. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/detectors/project.py +0 -0
  53. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/detectors/python.py +0 -0
  54. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/detectors/ruby.py +0 -0
  55. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/detectors/rust.py +0 -0
  56. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/detectors/systems.py +0 -0
  57. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/detectors/terraform.py +0 -0
  58. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/detectors/tooling.py +0 -0
  59. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/doc_analyzer.py +0 -0
  60. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/entrypoint_classifier.py +0 -0
  61. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/env_analyzer.py +0 -0
  62. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/error_schema.py +0 -0
  63. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/file_chunker.py +0 -0
  64. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/file_classifier.py +0 -0
  65. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/flow_analyzer.py +0 -0
  66. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/format_contract.py +0 -0
  67. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/fqn_utils.py +0 -0
  68. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/git_analyzer.py +0 -0
  69. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/graph_analyzer.py +0 -0
  70. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/license.py +0 -0
  71. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/mcp/__init__.py +0 -0
  72. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/mcp/onboarding/__init__.py +0 -0
  73. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/mcp/onboarding/applier.py +0 -0
  74. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/mcp/onboarding/backup.py +0 -0
  75. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/mcp/onboarding/detector.py +0 -0
  76. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/mcp/onboarding/planner.py +0 -0
  77. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/mcp/orchestrator.py +0 -0
  78. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/mcp/registry.py +0 -0
  79. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/mcp/runner.py +0 -0
  80. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/mcp/server.py +0 -0
  81. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/mcp_nudge.py +0 -0
  82. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/metrics_analyzer.py +0 -0
  83. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/openapi_surface.py +0 -0
  84. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/output_budget.py +0 -0
  85. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/path_filters.py +0 -0
  86. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/pr_comment_renderer.py +0 -0
  87. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/pr_impact.py +0 -0
  88. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/prepare_context.py +0 -0
  89. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/progress.py +0 -0
  90. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/ranking_engine.py +0 -0
  91. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/redactor.py +0 -0
  92. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/relevance_scorer.py +0 -0
  93. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/rename_refactor.py +0 -0
  94. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/repo_classifier.py +0 -0
  95. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/ris.py +0 -0
  96. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/runtime_classifier.py +0 -0
  97. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/scanner.py +0 -0
  98. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/schema.py +0 -0
  99. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/security_config.py +0 -0
  100. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/semantic_analyzer.py +0 -0
  101. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/serializer.py +0 -0
  102. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/spring_event_topology.py +0 -0
  103. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/spring_findings.py +0 -0
  104. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/spring_model.py +0 -0
  105. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/spring_security_audit.py +0 -0
  106. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/spring_semantic.py +0 -0
  107. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/spring_tx_analyzer.py +0 -0
  108. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/summarizer.py +0 -0
  109. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/telemetry/__init__.py +0 -0
  110. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/telemetry/config.py +0 -0
  111. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/telemetry/consent.py +0 -0
  112. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/telemetry/events.py +0 -0
  113. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/telemetry/filters.py +0 -0
  114. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/telemetry/transport.py +0 -0
  115. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/tree_utils.py +0 -0
  116. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/validation_surface.py +0 -0
  117. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/version_check.py +0 -0
  118. {sourcecode-1.68.0 → sourcecode-1.70.0}/src/sourcecode/workspace.py +0 -0
  119. {sourcecode-1.68.0 → sourcecode-1.70.0}/supabase/functions/README.md +0 -0
  120. {sourcecode-1.68.0 → sourcecode-1.70.0}/supabase/functions/get-license/index.ts +0 -0
  121. {sourcecode-1.68.0 → sourcecode-1.70.0}/supabase/functions/lemonsqueezy-webhook/index.ts +0 -0
  122. {sourcecode-1.68.0 → sourcecode-1.70.0}/supabase/functions/telemetry/index.ts +0 -0
  123. {sourcecode-1.68.0 → sourcecode-1.70.0}/supabase/sql/license_event_ordering.sql +0 -0
  124. {sourcecode-1.68.0 → sourcecode-1.70.0}/supabase/sql/telemetry_events.sql +0 -0
@@ -1,5 +1,134 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.70.0] — 2026-07-01
4
+
5
+ ### Fixed — reliability & internal-consistency on a large Spring 5 / Hibernate 5 repo (openmrs-core field test)
6
+
7
+ A full migration audit of **openmrs-core** (`7c0144ad2`, branch `2.9.x`; ~866
8
+ production files, Maven multi-module, Spring Framework 5 + Hibernate 5, classic
9
+ WAR, **no Spring Boot**) surfaced four reproducible defects where a headline value
10
+ was wrong or internally contradictory. Each fix targets the **root cause** and,
11
+ where certainty is not achievable, degrades to an explicit low-confidence / `null`
12
+ signal rather than asserting a concrete-but-false value. New regression coverage
13
+ across four suites.
14
+
15
+ - **`migrate-check` — Hibernate version confused a sibling artifact's version (BUG #1, CRITICAL).**
16
+ Hibernate ORM was resolved by scanning for *any* `<hibernate*version>` property
17
+ and keeping the **newest**, so on openmrs `${hibernateSearchVersion}=6.2.4.Final`
18
+ (Hibernate **Search**, a different artifact) beat `${hibernateVersion}=5.6.15.Final`
19
+ (Hibernate **ORM**). The tool declared the repo "already on Hibernate 6", set
20
+ `migration_applicable:false`, and silently **dropped the 28.9–95.6 person-day**
21
+ rewrite range from `estimated_effort_days` (under-reporting 1.5–2.6×). Resolution
22
+ is now **anchored to the `hibernate-core`/`hibernate-orm` dependency** — the
23
+ `<version>` on that coordinate, resolving a `${property}` to the *specific*
24
+ referenced property, and the Gradle coordinate. The property fallback (BOM-managed
25
+ repos) **excludes sibling artifacts** (Search/Validator/Envers/OGM/…). openmrs now
26
+ resolves `5.6.15.Final` / major 5 / `migration_applicable:true`, and
27
+ `estimated_effort_days` includes the Hibernate rewrite range via a new
28
+ `effort_breakdown` block. The same-name contradiction (`hibernate_readiness:100`
29
+ at root vs `0` nested) is removed: the nested field is renamed
30
+ **`rewrite_zone_readiness`** (raw rewrite-zone score, independent of applicability),
31
+ leaving the document-root `hibernate_readiness` as the single authoritative,
32
+ applicability-gated score.
33
+
34
+ - **`impact-chain` — a class's own methods counted as "direct callers" (BUG #2, HIGH).**
35
+ For a class-level seed, the BFS folds in every method key of the class, so internal
36
+ method→method calls leaked into `direct_callers`. On `ConceptServiceImpl` this
37
+ reported **24 direct callers (22 of them own members)**, inflating `risk_score` to
38
+ `20.0`/`high` — ~12× the real blast radius. Callers whose owning class is a seed
39
+ class are now excluded (they are *members*, not external callers); the count is
40
+ surfaced as `self_referential_excluded` plus an `analysis_warnings` entry.
41
+ `ConceptServiceImpl` now reports **2 external callers**, `risk_score` recomputed to
42
+ `8.5`/`medium`.
43
+
44
+ - **`export --integrations` — a user class classified as a JDK API by bare name (BUG #3, MEDIUM).**
45
+ `org.openmrs.util.HttpClient` (a thin wrapper over `java.net.HttpURLConnection`)
46
+ was labeled `jdk-httpclient` purely because its simple name matches
47
+ `java.net.http.HttpClient`. The client is now resolved by the **fully-qualified
48
+ import** — `jdk-httpclient` only when `java.net.http.HttpClient` is actually
49
+ imported and the file does not declare its own — otherwise it degrades to a
50
+ low-confidence **`custom-http-wrapper`**. Pure type-declaration sites (field,
51
+ parameter, return type such as `setImplementationIdHttpClient(HttpClient c)`) are
52
+ no longer emitted as network-invocation evidence; only constructions / static calls
53
+ count.
54
+
55
+ - **`--compact` — "rest api" headline unsupported by the `endpoints` command (BUG #4, LOW).**
56
+ The `architecture_summary` label is driven by HTTP-framework **presence** (Spring
57
+ MVC on the classpath), so openmrs was headlined "Java rest api" while the
58
+ authoritative `endpoints` command found **only 3 endpoints** (one a test module,
59
+ one medium-confidence `router_dsl`). The summary now consults the same endpoint
60
+ extractor and, below a high-confidence-endpoint threshold, degrades to a qualified,
61
+ consistent phrasing ("HTTP framework present; only N endpoints detected — see
62
+ `endpoints`") instead of overclaiming (Java/Kotlin repos only).
63
+
64
+ ## [1.69.0] — 2026-06-30
65
+
66
+ ### Fixed — framing & symbol-graph fidelity on a non-framework Java library (JobRunr field test)
67
+
68
+ A full architectural audit of **JobRunr** (Gradle multi-module, 707 classes, a
69
+ background-job-processing **library** that is framework-agnostic at its core and
70
+ integrates with Quarkus/Micronaut/Spring only through small optional adapter
71
+ modules) surfaced four reproducible defects. Each fix targets the **root cause** so
72
+ it generalizes to any equivalent repo, with new regression coverage across five
73
+ suites.
74
+
75
+ - **`migrate-check` MIG-011 — false positive on JDK-exported packages (BUG #1).**
76
+ The rule flagged *any* `sun.*` / `com.sun.*` import as a strongly-encapsulated
77
+ JDK internal on a pure prefix heuristic. On JobRunr this turned 14 benign
78
+ `com.sun.net.httpserver.*` imports into 14 `high`/`manual_migration` findings —
79
+ **100% of an inflated `blocking_count: 14`** on a healthy repo (the GO/NO-GO
80
+ metric). `com.sun.net.httpserver` is exported **unconditionally** by the
81
+ `jdk.httpserver` module (public since Java 6, basis of JEP 408) and needs no
82
+ `--add-exports`/`--add-opens`. MIG-011 now consults an allowlist of
83
+ unconditionally-exported JDK packages **generated from the running JDK** by
84
+ `scripts/generate_jdk_exports.py` (`src/sourcecode/jdk_exports.py`), never
85
+ hand-maintained. Genuinely-internal packages (`sun.misc.Unsafe`,
86
+ `com.sun.tools.*`, `com.sun.jdi.*`, `com.sun.source.*`) stay `high`.
87
+ `blocking_count` and `estimated_effort_days` recompute accordingly. JobRunr
88
+ `blocking_count` 14 → 0.
89
+ - **Phantom symbols extracted from prose (BUG #2).** `modernize`'s
90
+ `statically_unreferenced` / `framework_dispatched` lists (and the shared symbol
91
+ graph behind `repo-ir` / `impact` / `export --c4`) contained FQNs that match no
92
+ real type — e.g. `org.jobrunr.configuration.provides` (from the Javadoc "This
93
+ class **provides** the entry point") and `…details.instead` (from an exception
94
+ string "…**instead** of an actual implementation"). The non-annotated fast-path
95
+ symbol scan ran `(?:class|interface|enum)\s+(\w+)` over **raw source**, tokenizing
96
+ words inside comments and string literals. It now strips comments + string
97
+ literals first and requires a capitalized type name, matching the precision of the
98
+ annotated-file extractor. Fixing the shared parser fixes every downstream command.
99
+ - **`endpoints` — total false negative on imperative router DSLs (BUG #3).** JobRunr
100
+ exposes 12 live dashboard REST routes via `get("/jobs", handler)` /
101
+ `post(...)` / `delete(...)` calls; the annotation-only extractor returned **0
102
+ endpoints** (silently disabling `validation` downstream). A shape-based detector
103
+ now recognizes router-DSL registrations — an HTTP-verb method whose first argument
104
+ is a path literal **and** that has a second argument (the trailing comma
105
+ distinguishes a route from a 1-arg `Map.get`). Reported at `confidence: medium`
106
+ with `source: router_dsl`. Handles bare (Spark/static-import) and `app.get(...)`
107
+ (Javalin) forms. JobRunr `endpoints` 0 → 12.
108
+ - **Stack/architecture classification biased to the most-recognizable framework (BUG #4).**
109
+ JobRunr (19 modules, framework only in 3 small adapters, `core` ≈ 85% of code) was
110
+ reported as `project_type: "api"` / `frameworks: ["Quarkus"]`. Two root causes:
111
+ (a) the Gradle detector substring-matched `"quarkus"` inside an **exclusion filter**
112
+ (`configure(subprojects.findAll { !it.name.contains('quarkus') })`) — framework
113
+ tokens are now matched only inside genuine dependency/plugin lines; and (b)
114
+ presence-based typing — a framework confined to a minority adapter module (its
115
+ dominant source module uses no framework) no longer drives the project type, and a
116
+ multi-module JVM repo with no defining web/API framework classifies as `library`,
117
+ never `unknown`. A monolithic framework app is unaffected. `explain` no longer
118
+ emits the misleading "No stereotype detected — may be a plain class or utility" for
119
+ a structurally central non-annotated class: it infers a low-confidence stereotype
120
+ from in-degree, lifecycle methods, and naming (e.g. `BackgroundJobServer` →
121
+ "Likely server/orchestrator … lifecycle methods detected (start/stop)"). Finally
122
+ the `architecture_summary` no longer prints a phantom "MVC pattern with
123
+ controller, model, view layers" for a non-web library: MVC now requires a real
124
+ View layer (it was inferred from a `handlers` dir + a `model` dir with no view),
125
+ a bundled SPA under `src/main/resources/.../frontend` is no longer counted as a
126
+ backend view layer, `testFixtures` source roots are excluded, and View detection
127
+ recognizes template files (`.html`/etc.) that the code-extension filter dropped.
128
+ JobRunr `project_type` "api" → "library", `frameworks` `["Quarkus"]` → `[]`,
129
+ architecture "MVC … controller, model, view" → "Layered … controller, repository,
130
+ infrastructure".
131
+
3
132
  ## [1.68.0] — 2026-06-30
4
133
 
5
134
  ### Fixed — narrative/structured-data parity on non-Spring DI repos (Netflix Eureka field test)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 1.68.0
3
+ Version: 1.70.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.68.0"
7
+ version = "1.70.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"
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env python3
2
+ """Generate the JDK unconditional-export allowlist used by migrate-check MIG-011.
3
+
4
+ MIG-011 flags `sun.*` / `com.sun.*` imports as strongly-encapsulated JDK-internal
5
+ APIs. That heuristic is *prefix-based* and produces false positives on packages
6
+ that merely share the prefix but are in fact exported **unconditionally** by a
7
+ JDK module (no `to` clause) and therefore require NO `--add-exports` /
8
+ `--add-opens` on any classpath or module-path scenario. The canonical example is
9
+ `com.sun.net.httpserver.*` (module `jdk.httpserver`) — public and documented
10
+ since Java 6, the basis of JEP 408.
11
+
12
+ This script derives the allowlist directly from the running JDK by parsing
13
+ `java --describe-module` for every module, so the data tracks the real platform
14
+ instead of a hand-maintained list that silently rots across releases.
15
+
16
+ Two filters are applied:
17
+
18
+ 1. Only `exports <pkg>` lines WITHOUT a `to <module>` qualifier are kept
19
+ (qualified exports still need `--add-exports` for arbitrary code).
20
+
21
+ 2. Modules whose whole purpose is internal / tooling / deprecated access are
22
+ DENYLISTED — their `sun.*` / `com.sun.*` exports are genuine migration
23
+ concerns and must keep MIG-011's `high` severity:
24
+ - jdk.unsupported → sun.misc.Unsafe, sun.reflect (deprecated for removal)
25
+ - jdk.compiler → com.sun.source.*, com.sun.tools.javac (compiler internals)
26
+ - jdk.attach → com.sun.tools.attach.* (tooling)
27
+ - jdk.jdi → com.sun.jdi.* (debugger internals)
28
+ - jdk.jconsole / jdk.jdeps / jdk.javadoc / jdk.jshell → com.sun.tools.*
29
+
30
+ Run with the OLDEST supported target JDK to stay conservative (a package
31
+ exported in JDK 21 but not JDK 17 must not be allowlisted for a JDK 17 target).
32
+ Re-run after a JDK bump and commit the regenerated module.
33
+
34
+ Usage:
35
+ python scripts/generate_jdk_exports.py > src/sourcecode/jdk_exports.py
36
+ """
37
+ from __future__ import annotations
38
+
39
+ import re
40
+ import subprocess
41
+ import sys
42
+
43
+ # Modules whose sun.*/com.sun.* exports are genuinely internal/tooling/deprecated.
44
+ # Their packages must NOT be allowlisted — MIG-011 keeps firing `high` for them.
45
+ _DENYLIST_MODULES: frozenset[str] = frozenset(
46
+ {
47
+ "jdk.unsupported",
48
+ "jdk.compiler",
49
+ "jdk.attach",
50
+ "jdk.jdi",
51
+ "jdk.jconsole",
52
+ "jdk.jdeps",
53
+ "jdk.javadoc",
54
+ "jdk.jshell",
55
+ "jdk.internal.ed",
56
+ "jdk.internal.le",
57
+ "jdk.internal.opt",
58
+ "jdk.internal.vm.ci",
59
+ "jdk.internal.vm.compiler",
60
+ }
61
+ )
62
+
63
+ _EXPORT_RE = re.compile(r"^exports\s+(\S+?)(?:\s+to\s+.*)?$")
64
+
65
+
66
+ def _list_modules() -> list[str]:
67
+ out = subprocess.run(
68
+ ["java", "--list-modules"], capture_output=True, text=True, check=True
69
+ ).stdout
70
+ return [line.split("@", 1)[0].strip() for line in out.splitlines() if line.strip()]
71
+
72
+
73
+ def _module_exports(module: str) -> list[tuple[str, bool]]:
74
+ """Return (package, is_unconditional) for each `exports` line of a module."""
75
+ out = subprocess.run(
76
+ ["java", "--describe-module", module],
77
+ capture_output=True,
78
+ text=True,
79
+ ).stdout
80
+ results: list[tuple[str, bool]] = []
81
+ for line in out.splitlines():
82
+ line = line.strip()
83
+ m = _EXPORT_RE.match(line)
84
+ if not m:
85
+ continue
86
+ pkg = m.group(1)
87
+ unconditional = " to " not in line
88
+ results.append((pkg, unconditional))
89
+ return results
90
+
91
+
92
+ def main() -> int:
93
+ java_version = subprocess.run(
94
+ ["java", "-version"], capture_output=True, text=True
95
+ ).stderr.splitlines()[0]
96
+
97
+ allowlist: set[str] = set()
98
+ for module in _list_modules():
99
+ if module in _DENYLIST_MODULES:
100
+ continue
101
+ for pkg, unconditional in _module_exports(module):
102
+ if not unconditional:
103
+ continue
104
+ if pkg.startswith("sun.") or pkg.startswith("com.sun."):
105
+ allowlist.add(pkg)
106
+
107
+ packages = sorted(allowlist)
108
+
109
+ print('"""GENERATED FILE — do not edit by hand.')
110
+ print()
111
+ print("Allowlist of sun.* / com.sun.* packages exported UNCONDITIONALLY by the")
112
+ print("JDK (no `--add-exports` / `--add-opens` required on classpath or module")
113
+ print("path). Consumed by migrate-check MIG-011 to suppress false positives.")
114
+ print()
115
+ print(f"Regenerate with: python scripts/generate_jdk_exports.py > "
116
+ "src/sourcecode/jdk_exports.py")
117
+ print(f"Generated from: {java_version}")
118
+ print('"""')
119
+ print("from __future__ import annotations")
120
+ print()
121
+ print()
122
+ print("JDK_UNCONDITIONAL_EXPORTS: frozenset[str] = frozenset(")
123
+ print(" {")
124
+ for pkg in packages:
125
+ print(f' "{pkg}",')
126
+ print(" }")
127
+ print(")")
128
+ return 0
129
+
130
+
131
+ if __name__ == "__main__":
132
+ sys.exit(main())
@@ -1,3 +1,3 @@
1
1
  """sourcecode — Deterministic codebase context maps for AI coding agents."""
2
2
 
3
- __version__ = "1.68.0"
3
+ __version__ = "1.70.0"
@@ -38,7 +38,15 @@ _CODE_EXTENSIONS = {
38
38
  }
39
39
  _GENERIC_NAMES = {"utils", "helpers", "common", "shared", "misc", "core", "root", ""}
40
40
 
41
- _TEST_DIRS: frozenset[str] = frozenset({"tests", "test", "spec", "specs", "__tests__", "e2e"})
41
+ _TEST_DIRS: frozenset[str] = frozenset({
42
+ "tests", "test", "spec", "specs", "__tests__", "e2e",
43
+ # Gradle/Maven test-fixture source roots are test code, not runtime architecture.
44
+ "testfixtures", "testfixture",
45
+ })
46
+ # Always-vendored asset trees — never a backend code layer at any depth.
47
+ _ASSET_DIRS: frozenset[str] = frozenset({
48
+ "node_modules", "bower_components",
49
+ })
42
50
  _BENCHMARK_DIRS: frozenset[str] = frozenset({
43
51
  "benchmark", "benchmarks", "bench",
44
52
  "example", "examples",
@@ -50,7 +58,7 @@ _BENCHMARK_DIRS: frozenset[str] = frozenset({
50
58
  _DOCS_DIRS: frozenset[str] = frozenset({"docs", "doc", "documentation", "wiki"})
51
59
  _TOOLING_DIRS: frozenset[str] = frozenset({"scripts", "script", "tools", "tool", "ci"})
52
60
  # All dirs that are not part of the runtime source architecture
53
- _NON_SOURCE_DIRS: frozenset[str] = _TEST_DIRS | _BENCHMARK_DIRS | _DOCS_DIRS | _TOOLING_DIRS
61
+ _NON_SOURCE_DIRS: frozenset[str] = _TEST_DIRS | _BENCHMARK_DIRS | _DOCS_DIRS | _TOOLING_DIRS | _ASSET_DIRS
54
62
 
55
63
  # Exact file stems that signal a specific architectural layer
56
64
  _LAYER_STEM_EXACT: dict[str, str] = {
@@ -150,6 +158,15 @@ LAYER_PATTERNS: dict[str, dict[str, list[str]]] = {
150
158
  },
151
159
  }
152
160
 
161
+ # Layer keys a pattern MUST match to qualify, regardless of score. BUG (JobRunr
162
+ # field test): "mvc" was inferred from a controller-ish dir (`handlers`) plus a
163
+ # `model` dir, with NO view layer — that is not MVC (a library with no templates/
164
+ # pages/components is at most layered). MVC's defining trait is the View, so require
165
+ # it; without it the pattern falls through to layered / a weaker match.
166
+ _PATTERN_REQUIRED_KEYS: dict[str, frozenset[str]] = {
167
+ "mvc": frozenset({"view"}),
168
+ }
169
+
153
170
  # Higher value = wins when score ties
154
171
  _PATTERN_PRIORITY: dict[str, int] = {
155
172
  "cqrs": 8,
@@ -245,8 +262,12 @@ class ArchitectureAnalyzer:
245
262
  # Step 2: domain clustering
246
263
  domains = self._cluster_domains(filtered)
247
264
 
248
- # Step 3: layer detection
249
- pattern, layers = self._detect_layers(filtered)
265
+ # Step 3: layer detection. Feed the FULL path list (not the code-extension
266
+ # `filtered` set): a View layer is template files (.html/.jinja/.ejs…) that
267
+ # `filtered` would drop, hiding the very layer that distinguishes MVC.
268
+ # _detect_layers applies its own non-source/asset/test dir filtering, so
269
+ # bundled frontend trees and test fixtures are still excluded.
270
+ pattern, layers = self._detect_layers(sm.file_paths)
250
271
  if pattern in (None, "flat", "unknown"):
251
272
  if pattern == "flat":
252
273
  limitations.append("Layer pattern not detected: project has a flat directory structure")
@@ -518,10 +539,18 @@ class ArchitectureAnalyzer:
518
539
  return domains
519
540
 
520
541
  def _detect_layers(self, paths: list[str]) -> tuple[str, list[ArchitectureLayer]]:
521
- # Exclude non-source paths (tests, benchmarks, docs, tooling) from layer scoring
542
+ # Exclude non-source paths (tests, benchmarks, docs, tooling, vendored assets)
543
+ # from layer scoring. Also exclude anything under a `resources/` segment: in
544
+ # Maven/Gradle layouts `src/main/resources/**` is bundled config/assets — e.g.
545
+ # JobRunr ships a React dashboard SPA at
546
+ # `core/src/main/resources/org/jobrunr/dashboard/frontend/src/components`, whose
547
+ # `components` dir would otherwise be miscounted as a backend MVC "view" layer.
522
548
  source_paths = [
523
549
  p for p in paths
524
- if not any(part.lower() in _NON_SOURCE_DIRS for part in p.replace("\\", "/").split("/"))
550
+ if not any(
551
+ part.lower() in _NON_SOURCE_DIRS or part.lower() == "resources"
552
+ for part in p.replace("\\", "/").split("/")
553
+ )
525
554
  ]
526
555
  if not source_paths:
527
556
  return "unknown", []
@@ -544,6 +573,10 @@ class ArchitectureAnalyzer:
544
573
  matched_dirs = [d for d in dir_names if d in keywords]
545
574
  if matched_dirs:
546
575
  matched[layer_key] = matched_dirs
576
+ # A pattern with unmet required keys cannot qualify (e.g. mvc needs a view).
577
+ required = _PATTERN_REQUIRED_KEYS.get(pattern_name)
578
+ if required and not required.issubset(matched.keys()):
579
+ continue
547
580
  score = len(matched)
548
581
  priority = _PATTERN_PRIORITY.get(pattern_name, 0)
549
582
  if (score, priority) > (best_score, best_priority):
@@ -23,6 +23,14 @@ _JAVA_EXTENSIONS = {".java", ".kt", ".scala"}
23
23
 
24
24
  _CORE_DETECTION_MODULES = {"scanner", "detectors", "classifier", "workspace"}
25
25
 
26
+ # BUG #4 (v1.70.0): the "REST API" project-type label is driven by HTTP-framework
27
+ # PRESENCE (Spring MVC / JAX-RS on the classpath), not by an actual endpoint count.
28
+ # The `--compact` summary is the first (often only) thing an agent reads, so it must
29
+ # not assert "rest api" when the authoritative `endpoints` command finds almost no
30
+ # high-confidence surface. Below this many high-confidence endpoints we degrade the
31
+ # headline to a qualified, consistent phrasing instead of overclaiming.
32
+ _MIN_REST_ENDPOINTS_FOR_LABEL = 5
33
+
26
34
  _OPTIONAL_LABEL_MAP: dict[str, str] = {
27
35
  "DependencyAnalyzer": "dependencias",
28
36
  "GraphAnalyzer": "grafo de módulos",
@@ -41,6 +49,7 @@ class ArchitectureSummarizer:
41
49
 
42
50
  def __init__(self, root: Path) -> None:
43
51
  self.root = root
52
+ self._endpoint_support_cache: tuple[int, int] | None = None
44
53
 
45
54
  def generate(self, sm: SourceMap) -> str | None:
46
55
  try:
@@ -185,9 +194,39 @@ class ArchitectureSummarizer:
185
194
 
186
195
  fw_str = f" using {', '.join(fw_names)}" if fw_names else ""
187
196
  if runtime:
197
+ # BUG #4: never assert "rest api" in the headline unless the endpoints
198
+ # command actually backs it (Java/Kotlin only — that is where we have an
199
+ # authoritative extractor). Degrade to a qualified, consistent phrasing.
200
+ if sm.project_type == "api" and primary.stack in {"java", "kotlin"}:
201
+ total, high = self._endpoint_support()
202
+ if high < _MIN_REST_ENDPOINTS_FOR_LABEL:
203
+ plural = "s" if total != 1 else ""
204
+ return (
205
+ f"{stack_label} application{fw_str} "
206
+ f"(HTTP framework present; only {total} endpoint{plural} "
207
+ f"detected — see `endpoints`)."
208
+ )
188
209
  return f"{stack_label} {runtime.lower()}{fw_str}."
189
210
  return f"{stack_label} project{fw_str}."
190
211
 
212
+ def _endpoint_support(self) -> tuple[int, int]:
213
+ """Return (total, high_confidence) endpoint counts from the canonical
214
+ Java endpoint extractor — the same source the `endpoints` command uses,
215
+ so the summary cannot diverge from it. Cached; failure degrades to (0, 0)."""
216
+ if self._endpoint_support_cache is not None:
217
+ return self._endpoint_support_cache
218
+ total, high = 0, 0
219
+ try:
220
+ from sourcecode.repository_ir import extract_java_endpoints
221
+ data = extract_java_endpoints(self.root)
222
+ eps = data.get("endpoints", [])
223
+ total = data.get("total", len(eps))
224
+ high = sum(1 for e in eps if (e.get("confidence") or "high") == "high")
225
+ except Exception:
226
+ total, high = 0, 0
227
+ self._endpoint_support_cache = (total, high)
228
+ return self._endpoint_support_cache
229
+
191
230
  def _describe_arch_pattern(self, arch: Any) -> str:
192
231
  pattern_labels = {
193
232
  "clean": "Clean Architecture",
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import re
3
4
  from collections.abc import Iterable, Sequence
4
5
  from dataclasses import replace
5
6
  from typing import Any, Literal
@@ -104,6 +105,21 @@ class TypeClassifier:
104
105
  stack_names = {stack.stack for stack in stacks}
105
106
  framework_names = {framework.name for stack in stacks for framework in stack.frameworks}
106
107
 
108
+ # BUG #4 (JobRunr field test): a framework present only in a small optional
109
+ # integration/adapter submodule must NOT label the whole repo as that
110
+ # framework's app type. JobRunr is a framework-agnostic background-job
111
+ # LIBRARY whose `core` module holds ~85% of the code; Quarkus/Micronaut/Spring
112
+ # appear only in tiny per-framework adapter modules — yet presence-based
113
+ # classification returned project_type="api"+Quarkus. We weight by code
114
+ # locality: if the DOMINANT source module (the one with the most source files)
115
+ # contains no evidence of the app-defining framework, the framework is an
116
+ # optional adapter and the repo is a library. A monolithic Spring app is
117
+ # unaffected — its dominant module *does* use the framework. Drop such
118
+ # localized frameworks from the set that drives the app-type decision below.
119
+ _app_frameworks = framework_names & (_WEB_FRAMEWORKS | _API_FRAMEWORKS)
120
+ _localized = self._localized_adapter_frameworks(file_tree, stacks, _app_frameworks)
121
+ framework_names = framework_names - _localized
122
+
107
123
  if len(stack_names) >= 2 and self._is_fullstack(stacks):
108
124
  return "fullstack"
109
125
 
@@ -129,9 +145,16 @@ class TypeClassifier:
129
145
  if framework_names & _API_FRAMEWORKS:
130
146
  return "api"
131
147
 
148
+ # All app-defining frameworks were localized to optional adapter submodules
149
+ # (multi-module library with per-framework integrations) — report library,
150
+ # never "unknown", when there is clearly source code present.
151
+ if _localized and not (framework_names & (_WEB_FRAMEWORKS | _API_FRAMEWORKS)):
152
+ return "library"
153
+
154
+ # Strong CLI signals: a CLI framework or an explicit cli entry point.
132
155
  if framework_names & _CLI_FRAMEWORKS or any(
133
156
  entry.kind == "cli" for entry in entry_points
134
- ) or any(path.startswith("bin/") for path in flat_paths):
157
+ ):
135
158
  return "cli"
136
159
 
137
160
  if stack_names:
@@ -141,8 +164,105 @@ class TypeClassifier:
141
164
  if single in {"cpp", "dotnet"} and any(entry.kind == "cli" for entry in entry_points):
142
165
  return "cli"
143
166
 
167
+ # BUG #4 (JobRunr field test): a multi-module JVM repo with no app-defining
168
+ # web/API framework is a library/toolkit, not an "unknown" — never let the
169
+ # first command of an audit emit a vacuous classification for a clearly
170
+ # structured codebase (e.g. JobRunr: core + per-framework adapter modules).
171
+ # This is checked BEFORE the weak `bin/`-directory CLI heuristic so a build
172
+ # output / wrapper `bin/` dir does not mislabel a library as a CLI.
173
+ if stack_names & {"java", "kotlin", "scala"} and self._is_multi_module(file_tree):
174
+ return "library"
175
+
176
+ # Weak CLI heuristic: a top-level bin/ directory (only when nothing stronger).
177
+ if any(path.startswith("bin/") for path in flat_paths):
178
+ return "cli"
179
+
144
180
  return "unknown" if stacks else None
145
181
 
182
+ def _is_multi_module(self, file_tree: dict[str, Any]) -> bool:
183
+ """True when the repo has >1 source module (distinct `*/src/...` roots)."""
184
+ _CODE_EXTS = (".java", ".kt", ".kts", ".scala", ".groovy")
185
+ modules = {
186
+ self._module_of(p)
187
+ for p in flatten_file_tree(file_tree)
188
+ if p.endswith(_CODE_EXTS)
189
+ }
190
+ modules.discard("")
191
+ return len(modules) >= 2
192
+
193
+ @staticmethod
194
+ def _module_of(path: str) -> str:
195
+ """Group a source path into its module root.
196
+
197
+ For Maven/Gradle layouts the module is everything before `/src/`
198
+ (e.g. `framework-support/jobrunr-quarkus/src/main/java/...` →
199
+ `framework-support/jobrunr-quarkus`). Otherwise the top-level directory.
200
+ """
201
+ norm = path.replace("\\", "/")
202
+ idx = norm.find("/src/")
203
+ if idx > 0:
204
+ return norm[:idx]
205
+ head, _, tail = norm.partition("/")
206
+ return head if tail else ""
207
+
208
+ _EVIDENCE_PATH_RE = re.compile(r"\(([^()]+)\)\s*$")
209
+
210
+ def _localized_adapter_frameworks(
211
+ self,
212
+ file_tree: dict[str, Any],
213
+ stacks: Sequence[StackDetection],
214
+ candidate_frameworks: set[str],
215
+ ) -> set[str]:
216
+ """Frameworks confined to a minority module while a framework-agnostic
217
+ module dominates the codebase (library + per-framework adapters).
218
+
219
+ Returns the subset of ``candidate_frameworks`` that should NOT drive the
220
+ project-type decision. A framework qualifies only when (a) the repo is
221
+ multi-module, (b) the framework's evidence files are all outside the
222
+ dominant source module, and (c) the framework has located evidence files
223
+ (a manifest-only/root detection applies repo-wide and never localizes).
224
+ """
225
+ if not candidate_frameworks:
226
+ return set()
227
+
228
+ _CODE_EXTS = (".java", ".kt", ".kts", ".scala", ".groovy")
229
+ module_file_counts: dict[str, int] = {}
230
+ for p in flatten_file_tree(file_tree):
231
+ if not p.endswith(_CODE_EXTS):
232
+ continue
233
+ mod = self._module_of(p)
234
+ module_file_counts[mod] = module_file_counts.get(mod, 0) + 1
235
+
236
+ # Need a genuine multi-module repo to reason about locality.
237
+ if len(module_file_counts) < 2:
238
+ return set()
239
+ dominant_module = max(module_file_counts, key=lambda m: module_file_counts[m])
240
+
241
+ # Collect evidence file paths per framework from detected_via.
242
+ evidence: dict[str, set[str]] = {}
243
+ for stack in stacks:
244
+ for fw in stack.frameworks:
245
+ if fw.name not in candidate_frameworks:
246
+ continue
247
+ paths = evidence.setdefault(fw.name, set())
248
+ for ev in fw.detected_via:
249
+ if ev.startswith("manifest:"):
250
+ continue
251
+ m = self._EVIDENCE_PATH_RE.search(ev)
252
+ if m:
253
+ paths.add(m.group(1).strip())
254
+
255
+ localized: set[str] = set()
256
+ for fw_name in candidate_frameworks:
257
+ files = evidence.get(fw_name) or set()
258
+ if not files:
259
+ # No locatable evidence (manifest-only) → applies repo-wide.
260
+ continue
261
+ modules = {self._module_of(f) for f in files}
262
+ if dominant_module not in modules:
263
+ localized.add(fw_name)
264
+ return localized
265
+
146
266
  def _is_fullstack(self, stacks: Sequence[StackDetection]) -> bool:
147
267
  has_web = False
148
268
  has_api = False
@@ -90,6 +90,19 @@ _GRADLE_JAVA_VERSION_RE = re.compile(
90
90
  _GRADLE_JAVA_ENUM_RE = re.compile(
91
91
  r"""(?:sourceCompatibility|targetCompatibility)\s*=\s*JavaVersion\.VERSION_(\d+)"""
92
92
  )
93
+ # BUG #4: a gradle line that genuinely declares a dependency or plugin. Matches a
94
+ # quoted Maven coordinate (group:artifact, requires the colon), a plugins-block
95
+ # `id '...'`, an `apply plugin:`, or a `classpath`/`platform(`/`enforcedPlatform(`
96
+ # declaration. Deliberately does NOT match `it.name.contains('quarkus')`-style
97
+ # subproject filters (a bare quoted name with no colon and no plugin/dep keyword).
98
+ _GRADLE_DEP_LINE_RE = re.compile(
99
+ r"""['"][\w.\-]+:[\w.\-]+""" # maven coordinate "group:artifact..."
100
+ r"""|\bclasspath\b"""
101
+ r"""|\b(?:enforced)?platform\s*\("""
102
+ r"""|\bapply\s+plugin\s*:"""
103
+ r"""|\bid\s*[('"]""",
104
+ re.IGNORECASE,
105
+ )
93
106
 
94
107
 
95
108
  class JavaDetector(AbstractDetector):
@@ -279,7 +292,29 @@ class JavaDetector(AbstractDetector):
279
292
  original = "\n".join(read_text_lines(path))
280
293
  content = original.lower()
281
294
  sb_version = self._extract_gradle_sb_version(original)
282
- return self._detect_jvm_frameworks(content, "build.gradle", sb_version=sb_version)
295
+ # BUG #4 (JobRunr field test): framework tokens must be matched only inside
296
+ # genuine dependency / plugin declarations, never anywhere in the file. A
297
+ # multi-module root build.gradle that EXCLUDES a subproject by name —
298
+ # `configure(subprojects.findAll { !it.name.contains('quarkus') })` — must
299
+ # not be read as "this project uses Quarkus". Restrict the scan to lines
300
+ # carrying a Maven coordinate / plugin id; the exclusion filter has neither.
301
+ dep_text = self._gradle_dependency_text(content)
302
+ return self._detect_jvm_frameworks(dep_text, "build.gradle", sb_version=sb_version)
303
+
304
+ @staticmethod
305
+ def _gradle_dependency_text(content: str) -> str:
306
+ """Keep only gradle lines that declare a dependency or plugin.
307
+
308
+ A real dependency carries a Maven coordinate (`group:artifact`...) and a
309
+ plugin carries an `id`/`plugin`/`classpath` token. Subproject-name filters
310
+ and arbitrary prose are dropped, so a framework substring there cannot
311
+ manufacture a phantom framework detection.
312
+ """
313
+ kept: list[str] = []
314
+ for line in content.splitlines():
315
+ if _GRADLE_DEP_LINE_RE.search(line):
316
+ kept.append(line)
317
+ return "\n".join(kept)
283
318
 
284
319
  def _extract_gradle_sb_version(self, content: str) -> str | None:
285
320
  m = _GRADLE_SB_PLUGIN_RE.search(content)