Module: io.github.microsphere-projects:microsphere-jdk-tools · 0.3.20-SNAPSHOT (release, published as 0.3.20)
One main type (io.microsphere.jdk.tools.compiler.Compiler) · one test type (CompilerTest) · CI on JDK 8 / 11 / 17 / 21 / 25
Path prefixes used below: src/main/java/io/microsphere/jdk/tools/compiler/ and
src/test/java/io/microsphere/jdk/tools/compiler/ inside microsphere-jdk-tools/; core classes are cited as
microsphere-java-core/src/main/java/io/microsphere/….
microsphere-jdk-tools is the portability shim of the annotation-processing stack: the single place where the
project turns Class objects into a javac run. Everything above it (microsphere-annotation-test,
microsphere-annotation-processor) is written against javax.tools types that this module obtains and configures.
The shim is deliberately dumb — one class, no SPI, no service file, no module-info.java, no resources, and no build
configuration of its own.
graph TD
ANNO["microsphere-java-annotations"] --> CORE["microsphere-java-core"]
CORE --> JT["microsphere-jdk-tools<br/>Compiler (1 type)"]
CORE --> LM["microsphere-lang-model"]
JT -. "test scope only" .-> LM
CORE --> AT["microsphere-annotation-test"]
JT --> AT
JAVA["microsphere-java-test"] --> AT
LM --> AP["microsphere-annotation-processor"]
AT -. "test scope" .-> AP
Every edge, verified against a POM:
| Edge | Scope | Evidence |
|---|---|---|
jdk-tools → core |
compile | microsphere-jdk-tools/pom.xml:26 (<version>${revision}</version> at :27) |
lang-model → jdk-tools |
test | microsphere-lang-model/pom.xml:46, <scope>test</scope> :48 |
annotation-test → jdk-tools |
compile | microsphere-annotation-test/pom.xml:33 |
annotation-processor → jdk-tools |
absent | the module's dependency list is core (microsphere-annotation-processor/pom.xml:26) + lang-model (:33) + test-scoped annotation-test (:53) |
annotation-processor → lang-model |
compile | microsphere-annotation-processor/pom.xml:33 |
| version managed for consumers | — | microsphere-java-dependencies/pom.xml:38 |
| reactor order | — | root pom.xml:57-67 (microsphere-jdk-tools at :62) |
Test-scope dependencies of the module itself: junit-jupiter (microsphere-jdk-tools/pom.xml:31-41),
microsphere-java-test (:44-49), mockito-core (:52-56), logback-classic (:59-63). Everything from line 30
downward is test scope; the module has one compile dependency.
Note
Two of those test dependencies are unused by the code that is actually there: CompilerTest imports no Mockito type
and nothing from io.microsphere.test (CompilerTest.java:1-28), while mockito-core is declared at
microsphere-jdk-tools/pom.xml:52-56 and microsphere-java-test at :44-49. Same story for lang-model's
test-scoped dependency on this module — no source or test in microsphere-lang-model references Compiler.
| Field | Declaration | Notes from the source |
|---|---|---|
logger |
private static final Logger logger = getLogger(Compiler.class) (:74) |
core facade; the backend LoggerFactory is chosen once in a static initializer of LoggerFactory (microsphere-java-core/src/main/java/io/microsphere/logging/LoggerFactory.java:73, :98), so the field itself is not a lookup risk |
DEFAULT_OPTIONS |
public static final String[] … = ofArray("-parameters", "-Xlint:-unchecked", "-nowarn", "-Xlint:deprecation") (:76-77) |
a public mutable array constant — Compiler.DEFAULT_OPTIONS[0] = "-g" is legal Java and rewrites the default for every instance in the JVM; the constructor installs the same array contents via ofList (:104, :112) |
sourcePaths |
private final Set<File> sourcePaths (:79) |
final reference to a mutable LinkedHashSet (SetUtils.newLinkedHashSet(E…), microsphere-java-core/src/main/java/io/microsphere/collection/SetUtils.java:568), insertion-ordered; final protects nothing here since :117 mutates it |
targetDirectory |
private final File targetDirectory (:81) |
never null-checked; null reaches singleton(null) at :176 |
javaCompiler |
private final JavaCompiler javaCompiler (:83) |
the only thread-safely published field; assigned once at :107 |
processors |
private Set<Processor> processors (:85) |
starts null (getProcessors() maps that to emptySet(), :201-207) |
options |
private List<String> options (:87) |
set to DEFAULT_OPTIONS by every constructor (:104); reassignable to emptyList() by options() (:112) |
diagnosticListener |
:89 |
null until diagnosticListener(...); null is legal for both getTask and getStandardFileManager, so diagnostics then go only to the writer |
locale |
:91 |
null = JVM default locale for file-manager/diagnostics localisation |
charset |
:93 |
null = platform default encoding (see §4.3) |
Construction order matters: Compiler(File,File) (:103-109) calls the overridable options(...) (:104)
before assigning sourcePaths, targetDirectory and javaCompiler. A subclass that overrides options therefore
runs during super-construction — the classic "overridable method called from a constructor" smell, harmless today
because options touches only the field it sets.
public Compiler() // :95-97 → defaultTargetDirectory()
public Compiler(File targetDirectory) // :99-101 → defaultSourceDirectory()
public Compiler(File defaultSourceDirectory, File targetDirectory) // :103-109The three-hop delegation is worth reading as code, not as intent: Compiler() and Compiler(File) both call
defaultSourceDirectory() / defaultTargetDirectory(), and those detect the directories of Compiler.class
itself (:228-240), i.e. of the microsphere-jdk-tools module — never of the caller's project. Consequences:
defaultTargetDirectory()(:236-240) is<rootOfCompilerClass>/target/generated-classes, where the root isclassPath.parent.parent(:267). In a Maven reactor that ismicrosphere-jdk-tools/target/generated-classes; when the classes come from a repository jar, the same two hops land inside the local repository layout.dir.mkdirs()(:238) ignores its return value, so a read-only or missing parent directory is invisible until javac fails to write.defaultSourceDirectory()(:228-230) can legitimately returnnull(source not found,:250-253);newLinkedHashSet(null)(SetUtils.java:568) then stores anullelement, andnew File((File) null, relativePath)at:220degrades to a current-working-directory-relative lookup.
Nine setters, all returning this (CompilerTest.java:117 and :139 assert exactly that identity). Two distinct
merge semantics coexist and are the most common source of surprises:
| Append | Replace |
|---|---|
sourcePaths(File…) → CollectionUtils.addAll(this.sourcePaths, …) (:117; core collection/CollectionUtils.java:454) |
options(String…) → this.options = ofList(options) (:112) |
sourcePaths(Class…) / sourcePath(Class) → one append per class (:121-134) |
processors(Processor…) → this.processors = ofSet(processors) (:137) |
sourcePath(Class) is the degrading one: a class whose source is missing changes nothing and reports nothing at
caller level (:129-133).
JavaCompiler javaCompiler = getJavaCompiler(); // :157 re-reads the final field into a local
StringBuilderWriter writer = new StringBuilderWriter(); // :158 core io/StringBuilderWriter.java:66
try (StandardJavaFileManager javaFileManager = getJavaFileManager()) { // :159 fresh manager, closed at exit
CompilationTask task = javaCompiler.getTask(writer, javaFileManager,
getDiagnosticListener(), getOptions(), null, getJavaFileObjects(javaFileManager, sourceClasses)); // :160-161
task.setProcessors(this.getProcessors()); // :162
return task.call(); // :163
} finally {
logger.trace("The output of the Java compiler:\n{}", writer); // :165
}Facts the shape encodes:
- The fifth
getTaskargument (fileNames) isnull(:161); compilation units come only fromgetJavaFileObjects(:209-215). - No
-classpath/-processorpathoption is ever passed (DEFAULT_OPTIONS,:76-77, plus whatever the caller sets), so the in-processjavacinherits the current JVM's class path — that is precisely what makes it useful for processor tests: the classes under test and the processor itself are already loaded. task.setProcessors(...)is called unconditionally (:162). BecausegetProcessors()yieldsemptySet()when the field isnull(:201-207) andCompilationTaskprocesses annotations only for a non-empty processor set, "no explicit processors" means no annotation processing, and it also suppresses javac's own SPI discovery.microsphere-annotation-testcompensates by loadingProcessorservices itself and handing them over (microsphere-annotation-test/src/main/java/io/microsphere/test/annotation/processing/CompilerInvocationInterceptor.java:60-62).task.call()is synchronous and blocks the calling thread for the whole compilation.- The captured
writeris never returned, exposed or thrown:finallylogs it at TRACE only (:165), including when the task itself threw. The output text survives solely in the log.
| Step | Code | Behaviour |
|---|---|---|
| Relative path | resolveJavaSourceFileRelativePath (:284-286): sourceClass.getName().replace(DOT_CHAR, separatorChar).concat(JAVA_EXTENSION) |
uses the binary name, so a nested class yields Outer$Inner.java — a path that never exists. Nested classes are therefore silently dropped by :212. Constants: core constants/SymbolConstants.java:79, constants/FileConstants.java:81 |
| Locate | searchJavaSourceFile (:217-226) |
first existing candidate across sourcePaths in insertion order, else null |
| Root | detectRootDirectory (:264-271) |
detectClassPath(...).getParentFile().getParentFile() — two unguarded hops (:267); assumes exactly the target/classes layout |
| Class path | detectClassPath (:273-282) |
ProtectionDomain → CodeSource → new File(location.getPath()) (:277-278); UnsupportedOperationException when the code source is absent (:280-281) |
| Scan | detectSourcePath (:242-262) |
SimpleFileScanner.INSTANCE.scan(rootDirectory, true, file → absolutePath.endsWith(relativePath)) (:246-247; core io/scanner/SimpleFileScanner.java:57, :87) then first(sourceFiles) (:249; core collection/CollectionUtils.java:542) |
The scan is a full recursive walk of the module root (including target/), and endsWith on an absolute path accepts
any directory that happens to end with the relative path. Non-existent roots do not throw: File#listFiles()
returns null and the scanner yields an empty set, so detectSourcePath returns null after a TRACE log (:251-253)
— the case CompilerTest.java:186 pins for Test.class.
- Location:
javax.tools.ToolProvider.getSystemJavaCompiler()— statically imported at:64, invoked exactly once perCompilerat:107. That is the only contact point with the compiler implementation in the whole module. - Caching: the result is memoised in the
finalfieldjavaCompiler(:83) and served by a plain getter (:169-171). It is not a lazy holder, not a static cache, not double-checked locking: it is eager, per-instance, constructor-time. The JVM-wide singleton lives insideToolProvideritself, so NCompilerinstances share oneJavaCompilerobject and the Nth construction costs one null check. - Class loader choice: none.
Compilernever builds a class loader, never touchesThread.currentThread().getContextClassLoader(), and never uses the system loader. The loader decision belongs toToolProvider(thejdk.compilermodule's provider on JDK 9+, itstools.jarlookup on JDK 8), and the loaded implementation is reachable toCompileronly through thejavax.toolsinterfaces. Your processors never pass through that loader either: they are instantiated by the caller and injected at:162, so javac performs no processor discovery or loading of its own. - Reflective lookups: zero in this module. No
Class.forName, noMethod/Field/MethodHandleuse, nosetAccessible. The only reflective-adjacent work runs inside core: theList.of/Set.ofmethod-handle bindings (microsphere-java-core/src/main/java/io/microsphere/collection/Lists.java:101,Sets.java:99) used by:112and:137, and the logging facade'sServiceLoader(logging/LoggerFactory.java:73-96).
There is no version branch in this module. Compiler.java imports nothing from io.microsphere.util for version
probing (no JavaVersion, no SystemUtils.IS_JAVA_*, no Runtime.version()), and contains no if on a JDK number.
The adaptation is a single code path that trusts one JDK API:
getSystemJavaCompiler()(:107) — the JDK resolvestools.jar(8) or thejdk.compilerservice (9+) internally.assertNotNull(...)(:108) — the only "is a compiler reachable?" signal the module acts on.- Everything after that is plain
javax.tools:getStandardFileManager,getTask,setProcessors,call.
flowchart TD
A["new Compiler(...) :103-109"] --> B["ToolProvider.getSystemJavaCompiler() :107"]
B --> C{"JDK resolves the tool"}
C -- "JDK 8: javac lives in lib/tools.jar" --> D["JavaCompiler backed by com.sun.tools.javac.Main<br/>(loaded by ToolProvider, not by this module)"]
C -- "JDK 9+: jdk.compiler module present" --> E["JavaCompiler from the jdk.compiler provider"]
C -- "JRE, or image without jdk.compiler" --> F["returns null"]
F --> G["assertNotNull fails :108<br/>IllegalArgumentException"]
D --> H["final field javaCompiler :83"]
E --> H
H --> I["getJavaCompiler() :169-171<br/>no lazy holder, no static cache"]
I --> J["per compile() call: fresh file manager + fresh CompilationTask :159-163"]
Important
This is the opposite of the strategy microsphere-java-core uses for URLClassPath internals, where two
per-JDK strategies are selected at runtime (classloading/ClassicURLClassPathHandle.java:35-41 for
sun.misc.URLClassPath/urls, classloading/ModernURLClassPathHandle.java:35-41 for
jdk.internal.loader.URLClassPath/unopenedUrls). microsphere-jdk-tools needs none of that machinery because it
never reads a JDK-internal member: javax.tools is the supported, version-stable surface.
The two indirect JDK dependencies that do exist are inherited from core and are behaviour-preserving:
| Site | JDK 8 | JDK 9+ | Net effect |
|---|---|---|---|
options(...) → ofList (:112) |
unmodifiableList(asList(…)) (microsphere-java-core/src/main/java/io/microsphere/collection/ListUtils.java:191-196) |
List.of(…) via method handle (Lists.java:101, :513-529) |
both unmodifiable; a null option would throw inside List.of but the catch (Throwable) fallback swallows it and returns the JDK-8 style list (Lists.java:526-527) |
processors(...) → ofSet (:137) |
linked-hash-set builder | Set.of(…) (Sets.java:99, :552-568) |
same null-tolerance conclusion (Sets.java:565-567) |
On module access flags: because no com.sun.tools.javac subpackage is referenced, neither --add-exports nor
--add-opens is required for this module, on any JDK in the matrix. CI confirms it — the build line passes no such
flag (.github/workflows/maven-build.yml:38-45) and there is no .mvn/jvm.config in the repository (only
.mvn/wrapper/maven-wrapper.properties). When a downstream harness does reach for com.sun.tools.javac.processing
(the usual case in processor test frameworks), the flag belongs to the forked compiler/Surefire JVM of that
module, and the module name is jdk.compiler, not java.compiler.
| Site | Exception | Condition | Evidence |
|---|---|---|---|
Compiler.java:108 |
IllegalArgumentException |
no compiler reachable (JRE, stripped image) | message text "No Java compiler available. Ensure this process is running on a JDK (not just a JRE)."; thrown by core util/Assert.java:149-153 |
Compiler.java:281 |
UnsupportedOperationException |
sourceClass.getProtectionDomain().getCodeSource() == null |
message "The source {} is based on the file system, the class path can't be detected." (:280; {} is core's FormatUtils placeholder, text/FormatUtils.java:17, :38), pinned by CompilerTest.java:92 |
Compiler.java:159, :163, :173-177 |
IOException (declared) |
file-manager creation, setLocation on invalid dirs, close, javac I/O |
compile(...) throws IOException (:156), getJavaFileManager() throws IOException (:173) |
Compiler.java:163 |
whatever task.call() raises |
a processor throwing, javac internals failing | unguarded — nothing in compile catches |
Potential-but-unguarded paths (no try, no check):
:267—classPath.getParentFile().getParentFile()NPEs if the class-path entry sits at a filesystem root.:278—new File(location.getPath())for a non-file:scheme (jrt:/…, ajar:URL) yields aFilethat does not exist; the failure surfaces later as anullsource path, never as an error.:238—mkdirs()ignored.:176-177—singleton(this.targetDirectory)accepts anullFile.
microsphere-java generally degrades quietly; this module is the exception with two fail-fast sites
(:108, :281) and a rich set of degrading ones (:130-133, :212, :225, :249-253, :195-198, :203-205).
The split is coherent — "cannot compile" throws, "cannot find a source file" returns null — but it is undocumented:
the class has exactly one Javadoc block (:66-71), no method comments, no @throws, and no @Nullable/@Nonnull
annotations anywhere in the file. For the open NPE-hardening audit, the concrete items here are:
:108throwsIllegalArgumentExceptionfrom a constructor, with no@throwsin the Javadoc — callers cannot discover it without reading the source (the "silent" reading of the policy is broken here on purpose, because aCompilerwithout a compiler is useless).:89–:93and:85are nullable fields handed straight to javac (:160-161,:162,:174); each is legal but none is annotated, so the nullability contract exists only in the code.getCharset()(:189-191) returnsjava.nio.charset.Charsetand is passed as the encoding argument ofgetStandardFileManager(:174), whosejavax.toolscontract is the encoding name; nothing in the module convertsCharset→ name (:151-154stores it unchanged). The test chain.charset(UTF_8)…compile(...)(CompilerTest.java:164-167) exercises exactly this pairing, so it is the highest-value item to verify here.CompilerTest.java:146says "with mocked compiler it's safe to call", but the test declares no mock and no Mockito import — a comment/behaviour contradiction on top of the unusedmockito-coredependency (microsphere-jdk-tools/pom.xml:52-56).:259logs"The source file[path : '{}] of {} was found in the source directory[path :'{}']"— unbalanced quotes in the template; harmless (TRACE only) but it shows the log channel is asserted by no test.
sequenceDiagram
participant T as Test / caller code
participant C as io.microsphere.jdk.tools.compiler.Compiler
participant FM as StandardJavaFileManager
participant JC as javax.tools.JavaCompiler (ToolProvider result)
participant TK as CompilationTask (javac)
participant PR as Processor implementations
T->>C: compile(sourceClasses) :156
C->>C: new StringBuilderWriter :158
C->>JC: getStandardFileManager(listener, locale, charset) :174
JC-->>FM: new file manager
C->>FM: setLocation(SOURCE_PATH, sourcePaths) :175
C->>FM: setLocation(CLASS_OUTPUT, targetDirectory) :176
C->>FM: setLocation(SOURCE_OUTPUT, targetDirectory) :177
C->>C: searchJavaSourceFile(each class) :217-226
C->>FM: getJavaFileObjects(files) :214
C->>JC: getTask(writer, fm, listener, options, null, units) :160-161
C->>TK: setProcessors(getProcessors()) :162
C->>TK: call() :163
TK->>PR: process(annotations, roundEnv) (only if the set is non-empty)
PR-->>TK: generated sources, diagnostics
TK-->>C: boolean result :163
TK-->>C: diagnostics to DiagnosticListener or to the writer
C->>C: fm.close() :159
C-->>T: boolean + writer content logged at TRACE only :165
Note where information goes to die: with diagnosticListener == null (the field default, :89) javac writes
diagnostics into the StringBuilderWriter, and the only consumer of that writer is logger.trace (:165). A failed
compile() returning false is therefore silent unless trace is enabled on
io.microsphere.jdk.tools.compiler.Compiler or a listener was registered.
- Safely published: only
javaCompiler(:83, written in the constructor beforecompilecan be reached) and the staticlogger/DEFAULT_OPTIONS(:74,:76). Reads at:157and:174are therefore race-free. - Not safe: the seven mutable configuration fields (
:79-93) and the liveLinkedHashSetat:79.compile()hands that very set to javac (:175) whilesourcePaths(File…)(:117) can still be appending to it, andsearchJavaSourceFile(:219) iterates it. Concurrent configure + compile on one instance can produceConcurrentModificationExceptionor a torn configuration. - Reuse pattern that is supported: build one
Compilerper compilation unit of work, configure it once, then callcompile(...)as often as you like from the owning thread. Eachcompilebuilds a fresh file manager and a freshCompilationTask(:159,:160-161), so per-call javac state never leaks between compiles even though theJavaCompilerobject itself is shared JVM-wide. - Sharing one
JavaCompileracross threads isToolProvider's contract, not this module's.Compileradds no synchronisation aroundgetTask/call(:160-163); if you must compile concurrently, use oneCompilerper thread and separate target directories, becauseCLASS_OUTPUTandSOURCE_OUTPUTboth point at the same directory (:176-177) and two compiles writing it concurrently will interleave. - Cost of construction is one virtual call plus the source-directory walk (
:228-230→:242-262). That walk is the expensive part — a recursive scan of the module root — so caching a configuredCompiler(per §2.4 rules) is the right optimisation, not caching rawdetectSourcePathresults by hand.
| Pattern | Where it actually is | Evidence |
|---|---|---|
| Facade | Compiler fronts the javax.tools tool family (JavaCompiler, StandardJavaFileManager, CompilationTask, DiagnosticListener, Processor) behind one type |
imports :22-27, :64; the only client-visible javac call is :107 |
| Facade (JDK-side, not ours) | the per-JDK part of the facade is ToolProvider itself; this module funnels through it rather than duplicating it |
:64, :107 |
| Fluent configuration / soft Builder | nine return this setters (:111-154) with compile(...) as a repeatable terminal operation (:156) — no build() and no state freeze |
CompilerTest.java:117, :139 |
| Eager per-instance caching (deliberately not an initialization-on-demand holder) | private final JavaCompiler javaCompiler assigned in the constructor |
:83, :107, :169-171 |
| Adapter | StringBuilderWriter adapts java.io.Writer to a StringBuilder so javac output is capturable in-process; Compiler adapts it into getTask's writer slot |
:158, :160; core io/StringBuilderWriter.java:66, :73 |
| Strategy (inherited from core, not local) | the List.of/Set.of method-handle choice behind options/processors |
:112, :137; core collection/Lists.java:101, :520-529, collection/Sets.java:99, :559-568 |
| Singleton (eager, via core) | SimpleFileScanner.INSTANCE drives the source-file walk |
:246; core io/scanner/SimpleFileScanner.java:57 |
| Specification / predicate | the filename filter lambda passed to the scanner | :247 |
| Fail-fast guard | assertNotNull on the tool lookup |
:108; core util/Assert.java:149-153 |
Absent by design: SPI registration (no META-INF/services file exists in this module), extension points (every
method is concrete and non-protected), and a module-info.java (none in the repository, so this module is always on
the classpath / in the unnamed module).
src/test/java/io/microsphere/jdk/tools/compiler/CompilerTest.java — 11 methods, all against real javac:
| Test | Pins | Lines |
|---|---|---|
testDefaultConstructor |
Compiler() resolves a JavaCompiler |
:38-44 |
testTargetDirectoryConstructor |
Compiler(File) likewise |
:47-55 |
testSourceAndTargetDirectoryConstructor |
Compiler(File,File) likewise |
:58-67 |
testDefaultDirectories |
the three package-private default* statics; root dir exists |
:70-83 |
testDetectClassPath |
project class path exists; bootstrap class → UnsupportedOperationException |
:86-93 |
testResolveJavaSourceFileRelativePath |
binary-name → separatorChar path + .java |
:96-104 |
testSourcePathsMethod |
fluent identity; both File… and Class… overloads |
:107-121 |
testProcessorsMethod |
fluent identity with an anonymous AbstractProcessor |
:124-140 |
testCompile |
a real end-to-end compile plus the full setter chain | :143-170 |
testDetectRootDirectory |
two-hop root resolution from target/classes |
:173-178 |
testDetectSourcePath |
project classes resolve; a JDK type resolves to null |
:181-187 |
Uncovered by any test: getOptions() / getProcessors() content (§4.1 of the User Guide), the IllegalArgumentException
at :108, the emptyList() branch at :195-198, getJavaFileManager() used standalone (:173-179), and locale/
charset effects on diagnostics.
getSystemJavaCompiler()is the load-bearing assumption. It holds as long as the JDK ships aJavaCompilerservice injdk.compiler. The first realistic break is not a JDK release but a runtime image: ajlinkimage (or a minimal container JRE) built withoutjdk.compilernow reaches:108and throws where it previously worked, and the message is the only clue.-Xlint:-unchecked/-nowarn/-Xlint:deprecationare javac options, not an API. A future JDK that turns unknown-Xlintsub-options into errors, or that starts warning about them, makes everycompile(...)fail before your processor runs — with the explanation only in the TRACE writer (:165). If that happens, the fix is inDEFAULT_OPTIONS(:76-77), and it needs a version branch where none exists today: the natural insertion point is the constructor at:104, choosing an option set from the detected runtime rather than a constant.- No
--release/--sourceis ever passed (:76-77,:112). Today the compile inherits the running JDK's default source level, so a class that compiles on 25 may not compile on 8 from the same call site — the behaviour is already version-dependent even though the code is not. Adding-source/--releasesupport means deciding whetherDEFAULT_OPTIONSshould grow a placeholder or whether a newrelease(String)setter belongs next tocharset(...)(:151-154). - The
target/classestwo-hop (:267) is a build-layout assumption. Gradle'sbuild/classes/java/main, IDE out-of-tree build directories, andjar:/jrt:code sources all break it in different ways: the hops either land somewhere useless (silentnullfromdetectSourcePath,:249-253) or NPE (:267). A layout-aware root detector — the same problem core'sURLClassPathHandlefamily solved for class loaders (§3 callout) — is the structural fix. - Nested classes can never be compiled through
compile(Class…)becauseresolveJavaSourceFileRelativePath(:284-286) mapsOuter$Innerto a non-existentOuter$Inner.java, and the miss is filtered out at:212. If a future test needs an inner class as a compilation unit, the adapter must strip everything after$. DEFAULT_OPTIONSis a mutable public array (:76-77). Any hardening that copies it (clone()/List<String>) changes the public field's type and is a binary-incompatible edit — schedule it with a major release.- Encoding plumbing (§4.2 item 3) is the one place where this module's call site and the
javax.toolssignature disagree in type terms; it should be settled before an update to a JDK that tightens the file-manager API. - Thread-safety documentation, not code, is the cheap win. Adding
@Nullable/@Nonnulland a class-level "not thread-safe; one instance per compilation" note to:66-71would make §2.1, §4.2 and §5 discoverable without reading the file.
- microsphere-jdk-tools — User Guide — coordinates, per-JDK table, API and pitfalls
- microsphere-annotation-test — Developer Guide — the consumer that drives
Compilerfrom JUnit 5 - microsphere-annotation-processor — processors compiled by that harness
- microsphere-lang-model — the
javax.lang.modellayer of the same toolchain - Class Loading and Artifact Detection — Developer Guide — the contrast case: per-JDK strategies where internals are reflected into
- Chinese version: 中文开发者指南