Java units importing Java units #
Raw markdown: /java.md
For humans #
Read this if you are splitting Java work into helpers and importers. Skip it if every Java unit you write stands alone.
Do this #
A Java unit's package is its directory. The compiler refuses any other answer.
- Declare the package that matches the folder.
code/lib/MathBox.javadeclarespackage lib;. - Import it by that name from a unit in another folder:
import lib.MathBox;. - Drop the import for a unit in the same folder. Same directory means same package.
// code/caller/UseIt.java
package caller;
import lib.MathBox;
public class UseIt implements InterlockHandler<Object> {
public Object handle(Request req, Interlock il) {
return MathBox.twice(21); // 42
}
}
What will bite you #
There is no shared library. Each importer compiles its own copy of every helper it uses.
- Mutable statics are not shared. Each importer gets its own copy, so the cache never hits and the counter reads low. Nothing errors. Keep shared state in the store, which is genuinely shared.
- A helper's own class cannot cross
il.call. The cast fails on the far side and the error names the same class twice. Pass maps, lists, strings and numbers, or a type from your host jar.
Then read #
- Directory is package for the refusal text and the root case.
- Resolution is javac's SOURCE_PATH, not a shared classloader for why the copies exist.
- Rule 2: no mutable statics in an imported unit for the compile warning that catches it.
- For humans
- For robots
- Directory is package
- Same directory needs no import
- Directory names must be Java identifiers
- Resolution is javac's SOURCE_PATH, not a shared classloader
- Rule 1: unit-local types must not cross il.call
- Rule 2: no mutable statics in an imported unit
- Invalidation cascades
- Handler discovery is by attribution
- Generated units are never importable
- Hosts opt in via CodeSource.list
- Standalone build checks see no siblings
- Host entities are denied by default
- Native image: no compiler
For robots #
Java units can import other Java units, the way JSX units always could. This page is the rule set that makes that safe rather than merely possible. It is where you land if you pasted package lib does not exist, NoClassDefFoundError, a ClassCastException whose two class names print identically, or a mutable static field warning out of a host log.
Read Units first for ids, environments, and the handler contract.
Everything here was read out of interlock-java-sdk/src/main/java/sh/interlock/sdk/runtime/JavaRunner.java and Engine.java, and is pinned by interlock-java-sdk/src/test/java/sh/interlock/sdk/runtime/JavaSiblingTest.java (10 tests). Versions: JDK 21, in-process javax.tools compiler, Caffeine 3.1.8 for the artifact cache.
Error text is quoted verbatim from the source that throws it. Rules proven by a test or a live run are labelled MEASURED; rules that follow from reading the code with no test pinning them, INFERRED.
Every Verify block below runs in your own host project. A few lines additionally name the SDK's own test suite; those are marked MAINTAINER-ONLY and need an Interlock repository checkout, so skip them and run the unmarked command above them.
- Directory is package
- Same directory needs no import
- Directory names must be Java identifiers
- Resolution is javac's SOURCE_PATH, not a shared classloader
- Rule 1: unit-local types must not cross
il.call - Rule 2: no mutable statics in an imported unit
- Invalidation cascades
- Handler discovery is by attribution
- Generated units are never importable
- Hosts opt in via
CodeSource.list - Standalone build checks see no siblings
- Native image: no compiler
Directory is package #
Rule. A Java unit's package is its directory under code/. code/notes/NoteApi.java declares package notes; and is imported as import notes.NoteApi;. The id/binary-name mapping is mechanical: notes/NoteApi maps to notes.NoteApi, and notes/gen/Thing to notes.gen.Thing.
The check runs only when the unit declares a package, so every package-less unit written before this existed keeps working untouched. A package-less unit can neither import nor be imported: it sits in the default package, which javac cannot name from anywhere else.
Why it exists. A unit's package is its directory the same way its id is its URL. If a unit could declare any package it liked, an import would resolve a name that its id contradicts, and there would be two answers to "where does this class live".
Failure it prevents. A unit whose declared package and directory disagree, refused at compile with the fix in the sentence:
unit 'notes/Wrong' declares package 'elsewhere' but its directory requires package 'notes' — a unit's package is its directory, the way its id is its URL
At the root of the tree the same refusal reads:
unit 'Wrong' declares package 'elsewhere' but its directory is the root, which is the default package — a unit's package is its directory, the way its id is its URL
Example.
// code/lib/MathBox.java
package lib;
public class MathBox {
public static int twice(int n) { return n * 2; }
}
// code/caller/UseIt.java
package caller;
import lib.MathBox;
import sh.interlock.sdk.Interlock;
import sh.interlock.sdk.InterlockHandler;
import sh.interlock.sdk.Request;
public class UseIt implements InterlockHandler<Object> {
public Object handle(Request req, Interlock il) {
return MathBox.twice(21); // 42
}
}
Verify. In your own tree, the gate compiles with -sourcepath pointing at the unit root, which is the same resolution the runtime uses, and then the running host proves it end to end:
./scripts/check-units-compile.sh # javac resolves lib.MathBox out of code/lib/MathBox.java
interlock run notes/NoteRenderer --env dev # and so does the host, at run time
A wrong package line fails the first command with package lib does not exist or with the mismatch text quoted above, in about two seconds.
# MAINTAINER-ONLY (Interlock repository checkout, not your host):
./gradlew :interlock-java-sdk:test --tests 'sh.interlock.sdk.runtime.JavaSiblingTest'
MEASURED (aUnitImportsAUnitAcrossDirectories, aPackageMustMatchItsDirectory, packagelessLegacyUnitsStillRun).
Same directory needs no import #
Rule. Two units in the same directory are in the same package and reference each other with no import statement at all. This is ordinary javac same-package resolution, not a special case.
Why it exists. It gives Java the "directory as module" feel the JSX side always had. A folder is the unit of cohesion.
Failure it prevents. Writing import notes.NoteApi; from code/notes/NoteRenderer.java, which is an import of a class in your own package. javac accepts it, so this one costs you nothing but noise. The failure that does bite is the opposite reading: assuming a same-directory reference needs an import, not finding one, and concluding the file must be dead code.
Example.
// code/notes/NoteRenderer.java — no import line
package notes;
import sh.interlock.sdk.Interlock;
import sh.interlock.sdk.InterlockHandler;
import sh.interlock.sdk.Request;
public class NoteRenderer implements InterlockHandler<Object> {
public Object handle(Request req, Interlock il) {
return NoteApi.render(req.integer("size", 8));
}
}
Verify. In your own tree, delete the same-package import line and confirm both the gate and the running host are unmoved by it. That is the whole rule: the reference resolves either way, so the import was never what made it work.
grep -rn "^import notes\." code/notes/ # expect nothing; a same-package import is noise
./scripts/check-units-compile.sh
interlock run notes/NoteRenderer --env dev
# MAINTAINER-ONLY (Interlock repository checkout, not your host):
./gradlew :interlock-java-sdk:test --tests 'sh.interlock.sdk.runtime.JavaSiblingTest.sameDirectoryNeedsNoImport'
MEASURED.
Directory names must be Java identifiers #
Rule. A directory whose name is not a valid Java identifier cannot be a package, so units inside it cannot be imported and cannot themselves declare a package. JavaRunner.packageForDir returns the empty string (the default package) if any path segment fails [A-Za-z_$][A-Za-z0-9_$]*. The same test is applied to the file's base name in SiblingIndex.resolvePackage, which skips ids that could not be a Java type name.
So code/my-lib/Thing.java is a unit and runs fine, but it is invisible to javac.
Why it exists. Ids are URL-shaped and permit kebab-case; Java packages are not. Rather than mangling a name behind your back (and giving one unit two identities), the tree simply does not offer a kebab directory as a package.
Failure it prevents. The confusing half of this is the error you get if you do declare a package in a kebab directory. The directory resolves to the root, so the message names the root and not the kebab:
unit 'my-lib/Thing' declares package 'myLib' but its directory is the root, which is the default package — a unit's package is its directory, the way its id is its URL
And if you declare nothing, the import from elsewhere fails with the ordinary javac diagnostic, which never mentions the directory name:
compile failed for 'caller/UseIt':
ERROR line 3: package my_lib does not exist
Example.
code/my-lib/Thing.java ❌ runs, but cannot be imported
code/myLib/Thing.java ✅ package myLib;
code/lib/Thing.java ✅ package lib;
Verify. Inventory your own tree first: any directory under the unit root whose name is not a Java identifier is a directory nothing can import out of.
find code/ -mindepth 1 -type d | grep -Ev '/[A-Za-z_$][A-Za-z0-9_$]*$'
# expected output: nothing. Every line is a directory invisible to javac.
To see the rule fire rather than infer it, move one library unit into a kebab directory, add an import of it from another unit, and run the gate. Expect package my_lib does not exist, then move it back.
./scripts/check-units-compile.sh
INFERRED. The identifier check is explicit in packageForDir and resolvePackage, but no test in JavaSiblingTest uses a kebab directory. If this bites you, that is the test to add.
Resolution is javac's SOURCE_PATH, not a shared classloader #
Rule. Sibling resolution is javac's own dependency discovery. MemoryFileManager claims StandardLocation.SOURCE_PATH and serves sibling unit sources when javac lists a package. javac compiles exactly what the unit actually references (-implicit:class), and the resulting classes land in this importer's own classloader.
The consequence is the whole design: each importer gets its own compiled copy of its dependencies. There is no shared commons loader and no shared class identity.
Why it exists. Sharing one dependency loader couples unrelated unit lifetimes: closing that loader during cache eviction can break classes that are still live. Per-importer duplication makes that failure class unrepresentable.
Failure it prevents.
java.lang.NoClassDefFoundError: lib/MathBox
after a shared helper's loader was evicted or closed while live classes still referenced it. Under the per-importer model there is no shared loader to close: an importer's classes live and die with that importer's cache entry.
The price is paid in two rules, both made loud rather than left to be remembered. They are the next two sections.
Example. Nothing to write. This is the mechanism, and the log line proves it fired:
java.compile id:notes/NoteRenderer siblings:[notes/NoteApi]
emitted at INFO by JavaRunner.compile whenever a unit compiled with at least one sibling.
Verify. Run the host with INFO logging and hit a unit that imports a sibling:
curl -s localhost:8099/app/notes/note-renderer > /dev/null
grep -n "java.compile id:" your-app.log
MEASURED (live run on a real host application (Team Lakes), whose own FloorCanvas unit moved out of the host jar into code/world/ and was served over HTTP from the synced tree with exactly this line, naming that host's ids).
Rule 1: unit-local types must not cross il.call #
Rule. Values crossing an il.call boundary must be host types or plain data (Maps, Lists, Strings, numbers). Never a class declared in a unit.
The engine warns, once per class name, when a unit-local class comes back out of il.call:
il.call id:caller/UseIt returned unit-local class lib.Pick — unit classes are per-importer; types crossing units should be host classes
The check is JavaRunner.unitLocal(o): true when the object's classloader is a MemoryClassLoader.
Why it exists. lib.Pick compiled into unit A and lib.Pick compiled into unit B are different runtime classes with the same name. A cast on the far side fails, and the exception prints the same name twice.
Failure it prevents. The confusing cast:
java.lang.ClassCastException: class lib.Pick cannot be cast to class lib.Pick (lib.Pick is in unnamed module of loader sh.interlock.sdk.runtime.JavaRunner$MemoryClassLoader @1b6d3586; lib.Pick is in unnamed module of loader sh.interlock.sdk.runtime.JavaRunner$MemoryClassLoader @4a7f959b)
Example.
// ❌ crosses the boundary as a unit-local class
public Pick handle(Request req, Interlock il) {
return new Pick("boots", 4900);
}
// ✅ crosses as plain data
public Map<String, Object> handle(Request req, Interlock il) {
return Map.of("sku", "boots", "cents", 4900);
}
A shared type that genuinely must be a class on both sides belongs in the host jar, where there is one copy, reached through il.context(SomeHostType.class).
Verify. Watch the host log for the warning:
grep -n "returned unit-local class" your-app.log
MEASURED for the warning (it fires from Engine.invoke). INFERRED for the exact ClassCastException text above: the two-loader message is the JVM's standard format for this situation, reconstructed rather than captured, so the loader hashes will differ on your run. Search for cannot be cast to class lib.Pick and the duplicated name is the tell.
Rule 2: no mutable statics in an imported unit #
Rule. A library unit must not hold mutable static state. Each importer compiles its own copy of the class, so a mutable static is not shared state, it is N copies of state that look shared.
The compile warns, per offending field, for every dependency class (never for the unit's own classes, which are its business):
unit 'caller/UseIt' compiled in sibling 'lib/Counter' which has mutable static field 'hits' — every importer gets its OWN copy of that static
static final fields are exempt, as are synthetic fields.
Why it exists. Said once, at compile time, instead of discovered in production as a cache that never hits, a counter that reads low, or a lazily initialized singleton that initializes twice.
Failure it prevents. No exception, ever. That is the point. The symptom is a shared cache with a 0% hit rate and two units each convinced they own the only copy.
Example.
// ❌ every importer gets its own `hits`
package lib;
public class Counter {
public static int hits;
public static void bump() { hits++; }
}
// ✅ state lives in the store, which is genuinely shared
package lib;
import sh.interlock.sdk.Interlock;
public class Counter {
public static void bump(Interlock il) {
var s = il.store("lib/Counter");
Object n = s.get("hits");
s.put("hits", (n == null ? 0L : ((Number) n).longValue()) + 1);
}
}
Verify.
grep -n "which has mutable static field" your-app.log
MEASURED (JavaRunner.warnMutableStatics walks every dependency class's declared fields after a successful compile).
Invalidation cascades #
Rule. A compiled unit records which sibling sources javac actually read, by SHA-256 of the source. On the next run, a recorded hash that no longer matches the live source invalidates the cache entry and the importer recompiles.
Two details that matter:
- It records what was read, not what was listed. An unrelated unit sitting in the same directory does not become an invalidation trigger (
UnitSource.readflips only ingetCharContent). - The id list (which units exist) is snapshotted for 10 seconds per environment in
Engine.siblings, because javac asks for package listings on every compile andCodeSource.listmay be an HTTP call. That staleness delays only how soon a newly created unit becomes importable. Edits to existing dependencies are caught by the hash cascade, not by the list.
Why it exists. Without the cascade, an importer whose own source is unchanged keeps serving a compiled copy of last week's helper. Its cache key is its own source hash, and its own source did not change.
Failure it prevents. No error text. You edit lib/MathBox, sync, hit caller/UseIt, and get the old answer:
expected: <30> but was: <20>
Example.
// before
public static int twice(int n) { return n * 2; } // caller/UseIt returns 20
// after the edit and a sync
public static int twice(int n) { return n * 3; } // caller/UseIt returns 30 on its NEXT run
Verify. In your own project, watch the cache flag on the importer flip after you edit its dependency. JavaRunner logs cached:true|false per run at INFO:
curl -s localhost:8099/app/caller/use-it # warm it: expect cached:false then cached:true
curl -s localhost:8099/app/caller/use-it
# now edit code/lib/MathBox.java, publish it, and run the IMPORTER again
interlock sync code
curl -s localhost:8099/app/caller/use-it
grep -o "java.run id:caller/UseIt cached:[a-z]*" your-app.log | tail -3
# expect: false, true, false. The third false is the cascade.
# MAINTAINER-ONLY (Interlock repository checkout, not your host):
./gradlew :interlock-java-sdk:test --tests 'sh.interlock.sdk.runtime.JavaSiblingTest.editingADependencyRecompilesItsImporter'
MEASURED. The 10-second list snapshot is INFERRED from Engine.siblings (now - snap.at() > 10_000); no test pins the window.
Handler discovery is by attribution #
Rule. The handler is chosen among classes attributed to the unit's own source file, not by scanning the loader for the first class implementing InterlockHandler.
MemoryFileManager.getJavaFileForOutput records, per emitted class, which source file it was born from: a UnitSource means a dependency, anything else means the primary unit. Only classes with a null origin are candidates, and among those the first concrete, non-abstract, non-interface implementer wins.
Why it exists. Compilation output now contains dependency classes, including their handlers. A dependency that happens to be a runnable unit puts its own InterlockHandler into the importer's loader. "First class implementing InterlockHandler" would be a lottery.
Failure it prevents. A request answered by the wrong unit, with a 200 and no error anywhere. The test asserts on exactly this: an importer whose dependency's handler returns "WRONG HANDLER" must still run its own.
Example.
// code/dep/Answer.java — a runnable unit that is ALSO imported
package dep;
public class Answer implements InterlockHandler<Object> {
public static String value() { return "dep"; }
public Object handle(Request req, Interlock il) { return "WRONG HANDLER"; }
}
// code/caller/UseIt.java — importing it must not inherit its handler
package caller;
import dep.Answer;
public class UseIt implements InterlockHandler<Object> {
public Object handle(Request req, Interlock il) { return "mine-" + Answer.value(); }
}
Running caller/UseIt returns mine-dep.
Verify. Run the importer and read the body, not the status. The provenance header names which unit answered, so the two facts are one request apart:
curl -si localhost:8099/app/caller/use-it | grep -i x-interlock-unit # must name caller/UseIt
curl -s localhost:8099/app/caller/use-it # must NOT be "WRONG HANDLER"
# MAINTAINER-ONLY (Interlock repository checkout, not your host):
./gradlew :interlock-java-sdk:test --tests 'sh.interlock.sdk.runtime.JavaSiblingTest.discoveryRunsTheImportersHandlerNotTheDependencys'
MEASURED.
Generated units are never importable #
Rule. A unit whose CodeUnit.origin is generated is never offered to javac. SiblingIndex.resolvePackage skips it, so the import simply does not resolve. Generated code stays behind il.call, where the reduced-trust chain holds.
Why it exists. Compiling generated source into an authored importer would run it with the importer's capability. That is precisely the trust escalation origin tracking exists to prevent. Origin is store metadata, never inferred from the source text, the class name, or the id, so a generated unit cannot rename itself into privilege.
Failure it prevents. It converts a silent privilege escalation into an ordinary compile error:
compile failed for 'caller/UseIt':
ERROR line 2: package lib does not exist
If a unit you know exists refuses to import, check its origin before you check your spelling.
Example.
// lib/MathBox is stored with origin "generated"
import lib.MathBox; // ❌ does not resolve, at all
Object n = il.call("lib/MathBox", Map.of("n", 21)); // ✅ this is the door
Verify. Only a host that actually produces generated units can see this fire. If yours does, add an import of one to an authored unit and run the gate; the expected result is package lib does not exist while il.call on the same id keeps working:
./scripts/check-units-compile.sh
interlock run caller/UseIt --env dev
If your host has no generated units, there is nothing here to run and nothing to worry about: the rule can only tighten what a unit may import.
# MAINTAINER-ONLY (Interlock repository checkout, not your host):
./gradlew :interlock-java-sdk:test --tests 'sh.interlock.sdk.runtime.JavaSiblingTest.aGeneratedUnitIsNotImportable'
MEASURED.
Hosts opt in via CodeSource.list #
Rule. Sibling imports require the host's CodeSource to implement list(String env). The interface default returns List.of(), so a source that only answers fetch keeps working exactly as before, with sibling imports simply off.
public interface CodeSource {
CodeUnit fetch(String id, String env);
default java.util.List<String> list(String env) {
return java.util.List.of();
}
}
Why it exists. javac resolves notes.NoteApi by listing package notes, and a package listing is a directory listing of unit ids. An import that cannot be enumerated cannot be resolved. The default is empty rather than abstract so that every existing implementation, including the one-lambda test doubles the interface was shaped for, keeps compiling unchanged.
Failure it prevents. A host that upgrades the SDK, writes a sibling import, and gets a compile error naming a package it can see on disk:
compile failed for 'caller/UseIt':
ERROR line 3: package lib does not exist
The unit tree is correct. The CodeSource is not enumerable. sh.interlock.sdk.client.InterlockClient and DirectoryCodeSource both implement list; a hand-rolled lambda source does not.
Example.
// ❌ sibling imports are off: a lambda only implements fetch
CodeSource src = (id, env) -> myDb.load(id, env);
// ✅ enumerable
CodeSource src = new CodeSource() {
@Override public CodeUnit fetch(String id, String env) { return myDb.load(id, env); }
@Override public List<String> list(String env) { return myDb.ids(env); }
};
// ✅ straight off a git checkout, offline, no server and no key
CodeSource src = new DirectoryCodeSource(Path.of("code"));
Verify. Read the interface off the SDK jar your own host already ships, no Interlock checkout needed. The list default is what decides whether sibling imports are available at all:
SDK=$(ls build/quarkus-app/lib/main/*interlock-java-sdk*.jar)
javap -cp "$SDK" sh.interlock.sdk.runtime.CodeSource
# expect the default: public default java.util.List<java.lang.String> list(java.lang.String);
Then prove your own source implements it rather than inheriting the empty default: run a unit that imports a sibling and look for the compile line naming the dependency.
grep -n "java.compile id:" your-app.log
# MAINTAINER-ONLY (Interlock repository checkout, not your host):
./gradlew :interlock-java-sdk:test --tests 'sh.interlock.sdk.runtime.JavaSiblingTest.directoryCodeSourceServesAGitTree'
MEASURED.
Standalone build checks see no siblings #
Rule. UnitBuild.check(id, language, source) (reachable as Engine.validate) compiles a unit in isolation, with siblings explicitly null. A unit that imports a sibling therefore reports that import as an error there and still runs perfectly on the host, where the engine resolves it.
This is a known, accepted limitation, not a bug in your unit.
Why it exists. UnitBuild validates source a control plane will never run: no side effects, the artifact discarded, a fresh runner per check. It already cannot see a host's classes, and sibling resolution is the same kind of context.
Failure it prevents. It does not prevent a failure; it causes a misleading one, and that is why it is written down. The diagnostic is indistinguishable from a genuinely broken import:
compile failed for 'caller/UseIt':
ERROR line 3: package lib does not exist
Before chasing it, check where the check ran. interlock check skips .java and .jsx entirely and prints:
skip dev/caller/UseIt.java (checked server-side by warm-on-change)
so a green interlock check is not evidence that a Java unit compiles.
The check that is worth running instead. Compile every Java unit through the real pipeline, in the host's own test suite, with DirectoryCodeSource + Engine.unitClass + reflection. It exercises sibling resolution, package validation, and the host classpath, so a broken unit fails in about two seconds instead of arriving as a boot WARN nobody reads in time. On a real host application (Team Lakes) this caught a shipped bug immediately: that host's WorldBackend unit called a package-private WorldPrompts.cityOf and could never have compiled on the host, because nothing had ever compiled it.
@Test
void everyUnitCompiles() {
try (Engine engine = new Engine(new DirectoryCodeSource(Path.of("code")),
stores, secrets, ai)) {
for (String id : new DirectoryCodeSource(Path.of("code")).list("dev")) {
engine.unitClass(id, "dev"); // throws EngineException with real diagnostics
}
}
}
Reflection is required and is not a workaround: the unit is deliberately not on the host's test classpath, and that is the boundary working.
Verify. See the two answers disagree on the same unit, on your own project. The standalone check reports the sibling import as missing; the host runs it:
interlock check # prints "skip dev/caller/UseIt.java" for Java units
interlock run caller/UseIt --env dev # the same unit answers, siblings resolved
./scripts/check-units-compile.sh # and this is the check that is actually worth trusting
If your host exposes Engine.validate on an admin route, calling it for a unit that imports a sibling returns package lib does not exist while the same unit runs. That disagreement is the rule, not a defect.
MEASURED (the null resolver is passed explicitly: new JavaRunner().precompile(id, "check", source, null, false)).
Host entities are denied by default #
Rule. A Java unit may compile against the host's classes — except persistence entities. Naming a host @Entity/Panache class in a unit is refused at compile unless the host exported that entity with Engine.exportEntity(TheEntity.class).
Why it exists. Entity statics (deleteAll, persist, find, listAll) act on the WHOLE table. A careless or generated Account.deleteAll() in a unit empties a production table with nothing warning — proven by a probe unit that did exactly that before the boundary existed.
Failure it prevents. The refusal is a sentence at compile time, where the author is:
unit 'reports/cleaner' references host entity 'Account', which is not exported to units — a unit
may not reach a persistence entity directly, because its static operations (deleteAll, persist,
find, listAll) act on the whole table. Export it with InterlockSDK.export(Account.class) if a unit
should have full table access, or hand the unit a read-view instead.
Example.
// host wiring — one line per entity a unit may genuinely own:
Engine.exportEntity(Task.class); // units may now use Task.find/persist/…
// or, for a first-party host that treats its whole schema as unit-reachable:
Engine.exportAllEntities();
Prefer handing the unit a read-view through the host context where it only needs to read.
Verify. Write a unit naming a non-exported entity and save it — the save's build check reports the sentence above, naming your entity. INFERRED → MEASURED by UnitReachBoundaryTest in the SDK suite, which first proved the pre-boundary probe could empty a fixture table.
Native image: no compiler #
Java units need the JDK compiler in-process. In a native image ToolProvider.getSystemJavaCompiler() returns null and the unit refuses to run:
no Java compiler available (native image?) — Java units need JVM-mode hosting
JS units run in both. MEASURED (JavaRunner.compile, first branch).