sourcecode 1.67.0__tar.gz → 1.69.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.
- {sourcecode-1.67.0 → sourcecode-1.69.0}/CHANGELOG.md +105 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/PKG-INFO +1 -1
- {sourcecode-1.67.0 → sourcecode-1.69.0}/pyproject.toml +1 -1
- sourcecode-1.69.0/scripts/generate_jdk_exports.py +132 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/__init__.py +1 -1
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/classifier.py +121 -1
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/cli.py +21 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/detectors/java.py +36 -1
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/explain.py +83 -1
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/integration_detector.py +109 -10
- sourcecode-1.69.0/src/sourcecode/jdk_exports.py +26 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/migrate_check.py +119 -6
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/repository_ir.py +138 -9
- {sourcecode-1.67.0 → sourcecode-1.69.0}/.github/workflows/build-windows.yml +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/.gitignore +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/.ruff.toml +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/CONTRIBUTING.md +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/LICENSE +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/README.md +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/SECURITY.md +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/raw +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/adaptive_scanner.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/architecture_analyzer.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/architecture_summary.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/ast_extractor.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/cache.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/canonical_ir.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/cir_graphs.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/code_notes_analyzer.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/confidence_analyzer.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/context_scorer.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/context_summarizer.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/contract_model.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/contract_pipeline.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/coverage_parser.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/dependency_analyzer.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/detectors/__init__.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/detectors/base.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/detectors/csproj_parser.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/detectors/dart.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/detectors/dotnet.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/detectors/elixir.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/detectors/go.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/detectors/heuristic.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/detectors/hybrid.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/detectors/jvm_ext.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/detectors/nodejs.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/detectors/parsers.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/detectors/php.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/detectors/project.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/detectors/python.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/detectors/ruby.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/detectors/rust.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/detectors/systems.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/detectors/terraform.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/detectors/tooling.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/doc_analyzer.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/entrypoint_classifier.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/env_analyzer.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/error_schema.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/file_chunker.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/file_classifier.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/flow_analyzer.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/format_contract.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/fqn_utils.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/git_analyzer.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/graph_analyzer.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/hibernate_strat.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/license.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/mcp/__init__.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/mcp/onboarding/__init__.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/mcp/onboarding/applier.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/mcp/onboarding/backup.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/mcp/onboarding/detector.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/mcp/onboarding/planner.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/mcp/orchestrator.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/mcp/registry.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/mcp/runner.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/mcp/server.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/mcp_nudge.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/metrics_analyzer.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/openapi_surface.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/output_budget.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/path_filters.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/pr_comment_renderer.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/pr_impact.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/prepare_context.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/progress.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/ranking_engine.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/redactor.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/relevance_scorer.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/rename_refactor.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/repo_classifier.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/ris.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/runtime_classifier.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/scanner.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/schema.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/security_config.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/semantic_analyzer.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/serializer.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/spring_event_topology.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/spring_findings.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/spring_impact.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/spring_model.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/spring_security_audit.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/spring_semantic.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/spring_tx_analyzer.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/summarizer.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/telemetry/__init__.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/telemetry/config.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/telemetry/consent.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/telemetry/events.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/telemetry/filters.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/telemetry/transport.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/tree_utils.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/validation_surface.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/version_check.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/src/sourcecode/workspace.py +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/supabase/functions/README.md +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/supabase/functions/get-license/index.ts +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/supabase/functions/lemonsqueezy-webhook/index.ts +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/supabase/functions/telemetry/index.ts +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/supabase/sql/license_event_ordering.sql +0 -0
- {sourcecode-1.67.0 → sourcecode-1.69.0}/supabase/sql/telemetry_events.sql +0 -0
|
@@ -1,5 +1,110 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.69.0] — 2026-06-30
|
|
4
|
+
|
|
5
|
+
### Fixed — framing & symbol-graph fidelity on a non-framework Java library (JobRunr field test)
|
|
6
|
+
|
|
7
|
+
A full architectural audit of **JobRunr** (Gradle multi-module, 707 classes, a
|
|
8
|
+
background-job-processing **library** that is framework-agnostic at its core and
|
|
9
|
+
integrates with Quarkus/Micronaut/Spring only through small optional adapter
|
|
10
|
+
modules) surfaced four reproducible defects. Each fix targets the **root cause** so
|
|
11
|
+
it generalizes to any equivalent repo, with new regression coverage across five
|
|
12
|
+
suites.
|
|
13
|
+
|
|
14
|
+
- **`migrate-check` MIG-011 — false positive on JDK-exported packages (BUG #1).**
|
|
15
|
+
The rule flagged *any* `sun.*` / `com.sun.*` import as a strongly-encapsulated
|
|
16
|
+
JDK internal on a pure prefix heuristic. On JobRunr this turned 14 benign
|
|
17
|
+
`com.sun.net.httpserver.*` imports into 14 `high`/`manual_migration` findings —
|
|
18
|
+
**100% of an inflated `blocking_count: 14`** on a healthy repo (the GO/NO-GO
|
|
19
|
+
metric). `com.sun.net.httpserver` is exported **unconditionally** by the
|
|
20
|
+
`jdk.httpserver` module (public since Java 6, basis of JEP 408) and needs no
|
|
21
|
+
`--add-exports`/`--add-opens`. MIG-011 now consults an allowlist of
|
|
22
|
+
unconditionally-exported JDK packages **generated from the running JDK** by
|
|
23
|
+
`scripts/generate_jdk_exports.py` (`src/sourcecode/jdk_exports.py`), never
|
|
24
|
+
hand-maintained. Genuinely-internal packages (`sun.misc.Unsafe`,
|
|
25
|
+
`com.sun.tools.*`, `com.sun.jdi.*`, `com.sun.source.*`) stay `high`.
|
|
26
|
+
`blocking_count` and `estimated_effort_days` recompute accordingly. JobRunr
|
|
27
|
+
`blocking_count` 14 → 0.
|
|
28
|
+
- **Phantom symbols extracted from prose (BUG #2).** `modernize`'s
|
|
29
|
+
`statically_unreferenced` / `framework_dispatched` lists (and the shared symbol
|
|
30
|
+
graph behind `repo-ir` / `impact` / `export --c4`) contained FQNs that match no
|
|
31
|
+
real type — e.g. `org.jobrunr.configuration.provides` (from the Javadoc "This
|
|
32
|
+
class **provides** the entry point") and `…details.instead` (from an exception
|
|
33
|
+
string "…**instead** of an actual implementation"). The non-annotated fast-path
|
|
34
|
+
symbol scan ran `(?:class|interface|enum)\s+(\w+)` over **raw source**, tokenizing
|
|
35
|
+
words inside comments and string literals. It now strips comments + string
|
|
36
|
+
literals first and requires a capitalized type name, matching the precision of the
|
|
37
|
+
annotated-file extractor. Fixing the shared parser fixes every downstream command.
|
|
38
|
+
- **`endpoints` — total false negative on imperative router DSLs (BUG #3).** JobRunr
|
|
39
|
+
exposes 12 live dashboard REST routes via `get("/jobs", handler)` /
|
|
40
|
+
`post(...)` / `delete(...)` calls; the annotation-only extractor returned **0
|
|
41
|
+
endpoints** (silently disabling `validation` downstream). A shape-based detector
|
|
42
|
+
now recognizes router-DSL registrations — an HTTP-verb method whose first argument
|
|
43
|
+
is a path literal **and** that has a second argument (the trailing comma
|
|
44
|
+
distinguishes a route from a 1-arg `Map.get`). Reported at `confidence: medium`
|
|
45
|
+
with `source: router_dsl`. Handles bare (Spark/static-import) and `app.get(...)`
|
|
46
|
+
(Javalin) forms. JobRunr `endpoints` 0 → 12.
|
|
47
|
+
- **Stack/architecture classification biased to the most-recognizable framework (BUG #4).**
|
|
48
|
+
JobRunr (19 modules, framework only in 3 small adapters, `core` ≈ 85% of code) was
|
|
49
|
+
reported as `project_type: "api"` / `frameworks: ["Quarkus"]`. Two root causes:
|
|
50
|
+
(a) the Gradle detector substring-matched `"quarkus"` inside an **exclusion filter**
|
|
51
|
+
(`configure(subprojects.findAll { !it.name.contains('quarkus') })`) — framework
|
|
52
|
+
tokens are now matched only inside genuine dependency/plugin lines; and (b)
|
|
53
|
+
presence-based typing — a framework confined to a minority adapter module (its
|
|
54
|
+
dominant source module uses no framework) no longer drives the project type, and a
|
|
55
|
+
multi-module JVM repo with no defining web/API framework classifies as `library`,
|
|
56
|
+
never `unknown`. A monolithic framework app is unaffected. `explain` no longer
|
|
57
|
+
emits the misleading "No stereotype detected — may be a plain class or utility" for
|
|
58
|
+
a structurally central non-annotated class: it infers a low-confidence stereotype
|
|
59
|
+
from in-degree, lifecycle methods, and naming (e.g. `BackgroundJobServer` →
|
|
60
|
+
"Likely server/orchestrator … lifecycle methods detected (start/stop)"). JobRunr
|
|
61
|
+
`project_type` "api" → "library", `frameworks` `["Quarkus"]` → `[]`.
|
|
62
|
+
|
|
63
|
+
## [1.68.0] — 2026-06-30
|
|
64
|
+
|
|
65
|
+
### Fixed — narrative/structured-data parity on non-Spring DI repos (Netflix Eureka field test)
|
|
66
|
+
|
|
67
|
+
A real audit of **Netflix Eureka** (Gradle multi-module, JAX-RS/Jersey 1.x + Google
|
|
68
|
+
Guice, **no Spring, no Hibernate, no persistence layer**) surfaced six reliability
|
|
69
|
+
defects that share one root cause: **prose that assumed a Spring/Hibernate/persistence
|
|
70
|
+
context even though the same JSON's own structured fields already knew it did not
|
|
71
|
+
apply.** A consumer trusting the narrative over the structured fields would have read
|
|
72
|
+
false conclusions. Guiding principle applied throughout: **a narrative term (Spring,
|
|
73
|
+
Guice, persistence, …) may only appear when the corresponding structured signal
|
|
74
|
+
confirms it.** New regression coverage across four suites.
|
|
75
|
+
|
|
76
|
+
- **SMTP false positive on a log string (BUG #1).** `export --integrations` reported
|
|
77
|
+
`kind=smtp` for `logger.warn("Transport initialization failure", e)` — the bare word
|
|
78
|
+
"Transport" in a log literal. The detector now (a) skips any token matched inside a
|
|
79
|
+
string literal and (b) gates the JavaMail tokens (`Transport`, `MimeMessage`) on a
|
|
80
|
+
`javax.mail`/`jakarta.mail`/`org.springframework.mail` import. Eureka SMTP hits 3 → 0.
|
|
81
|
+
- **JNDI DNS mislabeled as LDAP (BUG #2).** `javax.naming.directory.{Dir,Initial}Context`
|
|
82
|
+
is protocol-agnostic; it is now classified by its `INITIAL_CONTEXT_FACTORY` —
|
|
83
|
+
`DnsContextFactory` → `kind=dns` (`client=jndi-dns`), `LdapCtxFactory` → `ldap`, and an
|
|
84
|
+
unresolvable factory → `kind=naming-directory-unknown` with `confidence=low`, never an
|
|
85
|
+
assumed LDAP. Eureka `DnsResolver` now reports `dns`.
|
|
86
|
+
- **"Spring Boot 3" narrative on a non-Spring repo (BUG #3).** When `spring_present` is
|
|
87
|
+
`False`, every `migrate-check` finding's `explanation` is rewritten from Spring-Boot
|
|
88
|
+
framing into framework-neutral Jakarta-EE / servlet-container framing (the javax→jakarta
|
|
89
|
+
finding itself is unchanged). Guarantee: no explanation contains "Spring Boot" unless
|
|
90
|
+
the report confirms runtime Spring.
|
|
91
|
+
- **Inconsistent DI-framework label (BUG #4).** `impact` emitted "Spring/CDI DI pattern"
|
|
92
|
+
in the short `explanation` but "Spring/CDI/Guice" in `via_interface_note` of the *same*
|
|
93
|
+
JSON, while `direct_callers` proved the framework was Guice. Both strings now derive
|
|
94
|
+
from one label computed from caller evidence — a `*.guice.*` caller yields "Guice".
|
|
95
|
+
- **"persistence paths" for non-persistence mappers (BUG #5).** Eureka's `AzToRegionMapper`
|
|
96
|
+
classes (AWS topology, no data layer) were counted as "4 persistence paths". Each
|
|
97
|
+
`mappers_affected` entry now carries `mapper_kind` (`persistence` only for a confirmed
|
|
98
|
+
`@Repository` role or MyBatis `mapper_interface`; otherwise `name_heuristic`). The
|
|
99
|
+
explanation says "persistence path" only for confirmed entries and neutral
|
|
100
|
+
"data-mapping class" otherwise — the word "persistence" never appears for a repo with no
|
|
101
|
+
confirmed persistence signal.
|
|
102
|
+
- **Ambiguous `member_count` unit in `modernize` (BUG #6).** `subsystem_summary.member_count`
|
|
103
|
+
counts all graph members (classes + methods + fields), ~5× the class count and not
|
|
104
|
+
comparable to repo-level `total_classes`. Each entry now also carries an unambiguous
|
|
105
|
+
`class_count` (class/interface declarations only, comparable to `total_classes`), with a
|
|
106
|
+
`subsystem_summary_note` documenting both units.
|
|
107
|
+
|
|
3
108
|
## [1.67.0] — 2026-06-30
|
|
4
109
|
|
|
5
110
|
### Fixed — framing accuracy on non-Spring repos (Apache OFBiz field test)
|
|
@@ -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,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
|
-
)
|
|
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
|
|
@@ -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
|
|
@@ -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
|
-
|
|
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)
|
|
@@ -223,7 +223,89 @@ def _build_purpose(
|
|
|
223
223
|
if role_anns:
|
|
224
224
|
desc = f"{role_anns[0]} bean"
|
|
225
225
|
|
|
226
|
-
|
|
226
|
+
if desc:
|
|
227
|
+
return desc
|
|
228
|
+
|
|
229
|
+
# BUG #4 (JobRunr field test): with no recognized framework annotation, the old
|
|
230
|
+
# fallback returned "No stereotype detected — may be a plain class or utility",
|
|
231
|
+
# which is actively misleading for a central domain class. Libraries and clean/
|
|
232
|
+
# hexagonal architectures model rich roles WITHOUT DI annotations. Infer a
|
|
233
|
+
# low-confidence structural role from signals the tool already computes:
|
|
234
|
+
# in-degree (coupling), lifecycle methods, and naming convention.
|
|
235
|
+
structural = _structural_purpose(class_fqn, raw_nodes, cir)
|
|
236
|
+
if structural:
|
|
237
|
+
return structural
|
|
238
|
+
return "No stereotype detected — may be a plain class or utility."
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# Lifecycle method names that signal an orchestrator/component managing state.
|
|
242
|
+
_LIFECYCLE_METHODS = frozenset({
|
|
243
|
+
"start", "stop", "init", "initialize", "shutdown", "close",
|
|
244
|
+
"pause", "resume", "run", "destroy", "open", "restart",
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
# Class-name suffix → inferred role (no annotation required).
|
|
248
|
+
_NAME_ROLE_SUFFIXES: tuple[tuple[str, str], ...] = (
|
|
249
|
+
("Server", "server/orchestrator"),
|
|
250
|
+
("Manager", "manager/coordinator"),
|
|
251
|
+
("Controller", "controller"),
|
|
252
|
+
("Service", "service"),
|
|
253
|
+
("Repository", "repository/data access"),
|
|
254
|
+
("Factory", "factory"),
|
|
255
|
+
("Builder", "builder"),
|
|
256
|
+
("Handler", "handler"),
|
|
257
|
+
("Listener", "listener"),
|
|
258
|
+
("Provider", "provider"),
|
|
259
|
+
("Registry", "registry"),
|
|
260
|
+
("Scheduler", "scheduler"),
|
|
261
|
+
("Dispatcher", "dispatcher"),
|
|
262
|
+
("Processor", "processor"),
|
|
263
|
+
("Filter", "filter"),
|
|
264
|
+
("Interceptor", "interceptor"),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _structural_purpose(
|
|
269
|
+
class_fqn: str,
|
|
270
|
+
raw_nodes: list[dict],
|
|
271
|
+
cir: "CanonicalRepositoryIR",
|
|
272
|
+
) -> str:
|
|
273
|
+
"""Infer a low-confidence stereotype from structural signals (no annotations)."""
|
|
274
|
+
try:
|
|
275
|
+
in_degree = len(_build_callers(class_fqn, cir))
|
|
276
|
+
except Exception:
|
|
277
|
+
in_degree = 0
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
method_names = {m.split("(")[0].lower() for m in _build_public_methods(class_fqn, raw_nodes)}
|
|
281
|
+
except Exception:
|
|
282
|
+
method_names = set()
|
|
283
|
+
lifecycle = sorted(method_names & _LIFECYCLE_METHODS)
|
|
284
|
+
|
|
285
|
+
simple = _simple(class_fqn)
|
|
286
|
+
role = ""
|
|
287
|
+
for suffix, label in _NAME_ROLE_SUFFIXES:
|
|
288
|
+
if simple.endswith(suffix):
|
|
289
|
+
role = label
|
|
290
|
+
break
|
|
291
|
+
if not role and lifecycle:
|
|
292
|
+
role = "orchestrator/lifecycle component"
|
|
293
|
+
|
|
294
|
+
# Require at least one real signal — otherwise stay honestly silent.
|
|
295
|
+
if not role and in_degree < 3:
|
|
296
|
+
return ""
|
|
297
|
+
|
|
298
|
+
head = f"Likely {role} (no DI annotations found)" if role else \
|
|
299
|
+
"Likely a structurally significant class (no DI annotations found)"
|
|
300
|
+
signals: list[str] = []
|
|
301
|
+
if in_degree >= 3:
|
|
302
|
+
signals.append(f"high in-degree ({in_degree})")
|
|
303
|
+
elif in_degree:
|
|
304
|
+
signals.append(f"in-degree {in_degree}")
|
|
305
|
+
if lifecycle:
|
|
306
|
+
signals.append(f"lifecycle methods detected ({'/'.join(lifecycle)})")
|
|
307
|
+
suffix = f" — {', '.join(signals)}" if signals else ""
|
|
308
|
+
return f"{head}{suffix} — inferred from structure, not annotations (low confidence)."
|
|
227
309
|
|
|
228
310
|
|
|
229
311
|
def _build_public_methods(class_fqn: str, raw_nodes: list[dict]) -> list[str]:
|