feat(lang-java): Java test selection with JUnit 5, Maven and Gradle - #31
Open
itaywol wants to merge 2 commits into
Open
feat(lang-java): Java test selection with JUnit 5, Maven and Gradle#31itaywol wants to merge 2 commits into
itaywol wants to merge 2 commits into
Conversation
Adds `testless-lang-java` and the two core generalizations it needed. Package scope is now a key, not a directory. `Language::package_scoped` becomes `Language::package_key`, because a Java package is not a directory: Maven and Gradle split one package across `src/main/java` and `src/test/java`, which is precisely where a class and its unit test live, referencing each other with no import statement. Keying on the directory would have missed the single most important edge in a Java graph. Go keys on its directory as before; TS and Rust stay file-scoped by default. Runner selection no longer follows from the language id. Java is driven by Maven or Gradle depending on the nearest build file, so `runner_for_lang(lang)` becomes `runner::runner_for(lang, module, repo)`, sniffing `pom.xml` / `build.gradle` at the test's own module and then the repo root, with a `java-runner` override in `testless.toml`. Commands are scoped per build module (`mvn -pl`, `gradle :mod:test`), and Maven gets `-DfailIfNoTests=false` so a narrowed selection doesn't fail every other module in the reactor. Two Java-specific decisions worth recording: - Methods are parented to `<module>`, not to their class. `walk` reverse-propagates `Contains` as behavioral embedding, so parenting a method to its class would make any method-body edit imply "the class changed", pulling in every test that so much as writes `new Calc()`. Where a class genuinely runs its own method (a field initializer, a static block) that is already an ordinary `Calls` ref, so nothing real is lost. - `resolve_import` genuinely resolves rather than giving up outside the current module. `Unknown(name)` widening is scoped to the forward import closure, so an unresolved cross-module import silently drops instead of widening: an under-select, which the selection contract does not allow. Source roots are discovered once per repo and memoized. Verified on google/gson (multi-module Maven, 264 files, 1534 tests): editing a private helper in `JavaVersion` selects 6 tests, all in `JavaVersionTest`; editing `LinkedTreeMap.get` widens to 1433.
Running the plugin against two real repos surfaced one performance failure and two selection defects that the fixture tests could not. Import resolution was quadratic. `resolve_import` stat-probed every source root per import. spring-boot is ~8.7k Java files across ~460 Gradle modules, so ~900 roots, which is hundreds of millions of syscalls and never completes. Replaced with a fully-qualified-name index built once per repo and memoized behind an `Arc`: indexing spring-boot now takes 8.7s. A Spring `@Bean` change selected zero of 17,720 tests. The previous commit parented methods to `<module>` rather than to their class, to avoid "any method edit implies the class changed". That is unsound for container-invoked code: a `@Bean` method is called by the container, never by name, so nothing reached it. Members carrying a *framework* annotation now parent to their class. Inert annotations are excluded, and that exclusion is load-bearing rather than cosmetic: `@Override` alone, treated as reflective, took a gson leaf edit from 6 to 1501 of 1534 tests. The class was still not enough, because the dependency lives on a field. A JUnit class builds its fixture in a field initializer and every test reaches it through `this.contextRunner`; with no def for the field, the initializer's refs landed on the class, `Contains` only walks child to parent, and no test was reached. Fields now get defs, and `this.<field>` and bare-receiver reads are extracted. Field defs are named `Class#field`, not `Class.field`, because the indexer keys on the segment after the last `.`: the dotted form indexes as bare `field`, so two test classes each holding a `gson` field cross-linked. A bare field name now resolves in its own class, with a bare-name fallback so an inherited field is still found. Also corrects the previous commit's message: the "6 of 1534" it cites for gson was an under-select, not a success. The chain broke at the static field `majorJavaVersion`, so `isJava9OrLater` reading it produced no edge. `JavaVersion` really does feed `ReflectionAccessFilterHelper`, and so nearly all of Gson's serialization; the wide answer is the correct one. Measured after these fixes: spring-boot leaf utility edit 62 of 17,720 spring-boot @bean body edit 257 of 17,720 (was 0) gson peripheral class 1,420 of 1,534 The gson number is sound but imprecise, and the cause is not Java-specific: tier-1 resolution matches on short names, so `iterator.next()` binds to every `next` in scope. Java shows it worst because package scope plus ubiquitous method names collide constantly. Fixing it needs qualifier-aware resolution in core, which is left for follow-up.
Owner
Author
|
Precision limitation measured on gson is tracked separately in #32 — not addressed in this PR. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds
testless-lang-java: JUnit 5 test selection for Maven and Gradle projects, plus the two core generalizations it needed.Verified end to end on two real repos — google/gson (multi-module Maven) and spring-projects/spring-boot (460-module Gradle, 8,662 files, 17,720 tests).
Core changes
Package scope is now a key, not a directory.
Language::package_scoped() -> boolbecomesLanguage::package_key(file) -> Option<PathBuf>. A Java package is not a directory: Maven and Gradle split one package acrosssrc/main/javaandsrc/test/java, which is exactly where a class and its unit test live, referencing each other with no import statement. Keying on the directory would miss the single most important edge in a Java graph. Go keys on its directory as before; TS and Rust stay file-scoped by default.Runner selection no longer follows from the language id. Java is driven by Maven or Gradle depending on the nearest build file, so
runner_for_lang(lang)becomesrunner::runner_for(lang, module, repo). It sniffspom.xml/build.gradleat the test's own module and then the repo root, with ajava-runneroverride intestless.toml. Commands are scoped per build module (mvn -pl,gradle :mod:test), and Maven gets-DfailIfNoTests=falseso a narrowed selection doesn't fail every other module in the reactor.Java plugin
JUnit 5 (
@Test,@ParameterizedTest,@RepeatedTest,@TestFactory,@Nested), classes/interfaces/enums/records, method invocations,new X(), and field/parameter type references — the last being the edge that makes dependency-injected code visible at all.Three decisions worth reviewing, each forced by a real repo rather than by the fixtures:
Import resolution is a per-repo FQN index, not a probe loop.
walk'sUnknown(name)widening is scoped to the forward import closure, so an unresolved cross-module import silently drops — an under-select. Resolution therefore has to actually succeed. The first implementation stat-probed every source root per import; on spring-boot's ~900 roots that is hundreds of millions of syscalls and never completes. Now built once per repo and memoized: 8.7s to index spring-boot.Framework-annotated members parent to their class; inert annotations do not. A Spring
@Beanmethod is invoked by the container, never by name, so with no containment edge a change to it reached zero tests. Excluding@Overrideand friends is load-bearing, not cosmetic: counting@Overrideas reflective took a gson leaf edit from 6 to 1501 of 1534 tests.Fields are defs, named
Class#field. A JUnit class builds its fixture in a field initializer and every test reaches it viathis.contextRunner; without a field def the chain never reaches the tests. The#matters because the indexer keys on the segment after the last.—Class.fieldindexes as barefield, so two test classes each holding agsonfield cross-linked.Measured
@BeanbodyThe gson number is sound but imprecise, and the cause is not Java-specific: tier-1 resolution matches on short names, so
iterator.next()binds to everynextin scope. Java shows it worst because package scope plus ubiquitous method names collide constantly. Tracked in the follow-up issue; not addressed here.Notes for review
mainandtestcontributes only its main half; andClass.forName(..).getMethod("plainName")against an unannotated method is not seen (always-runis the escape hatch).clippy -D warningsclean.