Extending the build
Every chapter so far drove the stock pipeline: a layout auto-detects your modules, the default assembler wires the conventional compile/jar/test flow, and you configure it by choosing among the options it offers. This chapter is for the build that needs something the stock pipeline does not model - a preprocessing pass, a code-generation step, a bespoke packaging step, an unusual dependency wiring.
Almost always, the answer is a plugin: a build module, named in one line of a properties file, that joins a module of the stock build and adds to it, or runs once over everything the build produced. A plugin never replaces what the stock build does; a build that must - one that redirects what the compiler reads, or wires a step between two stock ones - is an entry point of its own, and that comes last.
Adding plugins to the stock build
A project names its plugins in jenesis., beside jenesis. at its root, one per
line:
greeting+binary/generated=./plugin
signing+artifact=demo.signing
The key is <name>+<hook point>. The name is the plugin's own, and holds neither / nor +. The hook
point says where in the build the plugin joins, as the next section describes; a key without +<hook point>
adds the plugin to the module build itself, and an unknown hook point is refused. The value says where the
plugin comes from:
- a value starting with
.is a folder of the project, compiled from source on every build, and/ - anything else is a module name, resolved as
module/from the Jenesis module repository whatever the project's layout, with the local export (<name> ~/) searched first. A value that is neither, such as a path starting with. jenesis ., fails the build.. /
Either may end in @<provider>, which selects the provider annotated @BuildModuleName("<provider>") when the plugin
module provides several: signing+artifact=. Without it, the module must provide exactly
one unannotated provider.
Hook points
A hook point is a place in the build graph that Jenesis leaves open for plugins. It is named by its path, as
the build log shows it - binary/ is the source generation of a module's binary build,
stage/ a place in stage - and it decides three things about a plugin: when it runs, what it is handed
as input, and where what it writes goes. A plugin never inspects the build around it: it reads the folders its
hook point hands it, and what it writes travels onwards by the same folder conventions the stock steps follow,
so a sources/ tree written in binary/ is compiled, and a maven/ folder written in
stage/ joins the staged Maven tree. Every hook point keeps its plugins in a namespace of its own -
custom/, transform/, inspect/ or plugin/ - beside the stock steps, so a plugin may take any name.
Hook points come in two kinds. A module hook point runs a plugin once in every module of the project that
configures it; a project hook point runs it once for the whole project. preprocess runs before any module is
built and the other project hook points after every module is, so the module hook points sit between them; each
table lists its hook points in the order the build reaches them.
A plugin of a module hook point runs as <hook point>/ within each module's build, as the build log
shows it under build/ or build/ - binary/, say - and a plugin with no hook
point runs as custom/, in the module build itself:
| Module hook point | When a plugin runs, and what it is handed |
|---|---|
check |
Beside the stock source checks, handed the module's sources and manifests. |
format |
Beside the stock formatters, handed the same. |
compliance |
Beside the licence and vulnerability checks, handed the manifests and the resolved dependencies. |
binary/ |
Before compilation, beside the stock generators; a sources/ tree it writes is compiled with the module. |
binary/ |
Beside the stock compilers, handed what they compile. |
binary/ |
After compilation, beside the bytecode checks, handed the compiled classes. |
binary |
Within the module's compile toolchain, handed what the toolchain reads. |
artifact |
Once the module's jar is built, handed it with the module's dependencies. |
observed |
With the module's tests, beside the stock observers such as coverage. |
documentation/ |
Beside the stock documentation generators. |
documentation |
Within the documentation build, handed the compiled module. |
| (none) | In the module build itself, handed everything the module reads. |
package |
When the module is packaged, handed its jar, its dependencies and the stock images; what it writes into packages/ is staged in stage/. |
A plugin of a project hook point runs under the goal it belongs to, at the same path in every project:
build/, build/ and build/,
stage/ and stage/, export/, release/, and
plugin/:
| Project hook point | When a plugin runs, and what it is handed |
|---|---|
preprocess |
Before any module is built, handed only what it binds. It hands the build nothing, so it can stop the build early but never feed it. |
postprocess/ |
After every module is built, handed every module's inventory, in the order the file names them; adds files to the modules or to the whole project. |
postprocess/ |
After the transforms, handed the same with what they added; fails the build by throwing. |
stage/ |
After the stock staging, handed every staged tree; what it writes under a tree's name joins that tree. |
stage/ |
After the transforms of stage, handed the staged trees with what they added. |
export |
With export, beside the stock export steps, handed everything staged. |
release |
With release, beside JReleaser, handed everything staged. |
plugin |
Only when plugin/ is named, handed only what it binds; nothing waits for it and it waits for nothing. |
The module hook points run while the module is built, so everything after them sees what they add;
postprocess still belongs to build, so stage, export, release, pin, dependencies, ide and
Execute. never run past a failed inspection. stage runs only when a goal needs it, and export,
release and plugin only when they are asked for.
Plugins of a module
A plugin in a module hook point reads what the module it joins reads, and its output belongs to that module like
any of the stock steps': a plugin in binary/ that writes a sources/ tree has it compiled with the
project's own sources, and a plugin in artifact reads the jars the build produced.
It runs in a module only where plugin-<name>. is found, looked up like any other
configuration file - in build., with the profiles first - so the same line can serve every module of
a project and still run only in those that configure it. The file's values are handed to the plugin when it
is created:
# build.jenesis/plugin-greeting.properties
greeting=Hello from a generated source
The plugin's dependencies are pinned like any other, in the dependency group named after the plugin,
plugin-<name>:
/**
* @jenesis.pin plugin-greeting/module/org.json 20260522 SHA-256/...
*/
A plugin found in the local export is built on your machine and is not checked against a pinned checksum.
-Djenesis. leaves a plugin out, as the stock tools are switched off, without editing the
file, and -Djenesis. leaves out every plugin the file names.
A plugin in package can wrap or sign one of the images packaging. asks Jenesis to build - a
runtime image, a jpackage image, a launcher - into a package of its own: an AppImage, an installer built with
other tools, a distribution zip. A package whose name another packager, or jpackage, writes already fails the
build rather than replacing it.
Handing a plugin files of the project
A plugin reads only what the build hands it, so a file of the project reaches it as an input. Among a
plugin's values, a key starting with @ binds a file or folder instead of naming a value:
# build.jenesis/plugin-greeting.properties
greeting=Hello from a generated source
@templates=templates
@templates/legal/HEADER.txt=legal/HEADER.txt
@<input>= binds the path into an input named <input>, which the plugin reads as the folder
.. A target after the input's name, @<input>/, places what is bound at that path
inside the input rather than at its root, and one input takes one key per target, so it can gather several files
and folders. A single file keeps its name unless a target renames it. Editing a bound file runs the plugin again.
A value whose key really starts with @ is written with two: @@name= hands the plugin @name=.
In a module's plugin-<name>., a path is resolved against the module's own folder, the one holding its
module-info. or pom.. It has
to stay within the project, symbolic links included, and a plugin folder named as . must lie within
the project as well. A path that leaves the project, does not exist, or shares a target with another binding of
the same input fails the build.
jenesis included. Before
you build a project whose plugins you have not reviewed, run the build in a container with
-Djenesis.project.docker=true: the plugins then run inside the container and never on your
machine, and the project cannot switch Docker off (see
What runs on the host).
Plugins for the whole project
A plugin in a project hook point runs once for the whole project. The line in jenesis.
switches it on by itself, as no plugin-<name>. applies to it:
# jenesis.plugins.properties
licence+preprocess=./licence
notice+postprocess/transform=./notice
audit+postprocess/inspect=./audit
checksums+stage/transform=./checksums
complete+stage/inspect=./complete
publish+export=./publish
announce+release=./announce
lines+plugin=./lines
Their names take no /, . or +, and a name used for a module hook point cannot also name one of them.
A preprocessor checks the project before anything is compiled: a licence header, a forbidden file, a policy
on what the repository holds. A file every module needs comes from a plugin in binary/ instead.
A transform adds files to the modules - a notice, a report, a signature of your own - and an inspection checks the result and fails the build when it is wrong. Transforms run in the order the file names them, each seeing what the ones before it added, and the inspections run after all of them.
A transform of stage writes into a folder named after a staged tree - maven/, modular/, packages/,
project/ and the others under target/ - and what it writes joins that tree where it stands, so
export, release, a jreleaser. and their plugins take it like anything else staged: checksums or
signatures beside every file, repository metadata, a manifest of what ships. A file the tree holds already fails
the build rather than being replaced. An inspection of stage then checks the staged trees with what was added,
and nothing is exported or released past one that fails.
An exporter and a releaser deliver what was staged - to an internal repository, a bucket, a registry, a
release page. Naming export/ or release/ runs the plugins alone, without the steps Jenesis runs
there itself. Such a step writes outside the build, so it overrides shouldRun to run every time it is selected,
as the stock export steps do, rather than only when what it reads has changed.
A plugin under plugin belongs to no goal and starts at once, as
java build/: a report over the project's own files, a task run by hand. What
needs the built or staged result belongs in one of the hook points that follow them.
Configuring them
A plugin of the whole project reads its values from jenesis. beside
jenesis., one line per value as <plugin>., and from nowhere else - never from the
command line:
# jenesis.plugins.arguments.properties
notice.holder=Example Corp.
notice.@legal=legal
The plugin notice then receives holder=, and @legal binds the project's legal/ folder as its
input, resolved against the project root. A line that names no declared plugin fails the build. A profile brings
values of its own in jenesis., which win over the file without a
profile, and of two active profiles the one named first wins. That is why the plugin files are named with dots:
in a file name, a dash after a name always introduces a profile, as in jenesis-<profile>..
Since the plugins in build run with every build, one that takes long is best switched off in jenesis. with
jenesis. and switched back on in the profile that ships. -Djenesis.
leaves out every plugin at once, those of the module hook points included.
What a transform and an inspection see, and what they may add
Each of them is handed the inventory of every module: its jar, sources and documentation, its POM, and every
dependency it resolved, each with the jar it resolved to. A transform adds to a module by writing files into its
own output and naming them in an inventory. there, under the prefix the inventory gives the module
(its build identity, such as module-sources):
module-sources.attachment.notice=notices/module-sources/NOTICE.txt
The key after the prefix is any key the module's inventory knows, read by the stages as the module's own:
<module>.is staged beside the module's jar under that classifier:attachment. <classifier> <artifact>-<version>-<classifier>.in the Maven tree,<extension> <module>-<classifier>.in the modular tree.<extension> <module>.is staged with the module's reports underreport. <name> stage/.reports/
What a transform adds is its author's responsibility. A module the build does not have fails the build, and so does a key two transforms add, or an attachment whose file name the build stages already.
What belongs to no module - an aggregated report, a site, a distribution of every module - a transform writes into
a project/ folder of its own output, laid out as it sees fit. stage copies it as it stands into
target/, where exporters and releasers find it with the rest, and a file two transforms
place at the same path fails the build. A transform that brings
its own SBOM, say, attaches it as cyclonedx once -Djenesis. has switched off the stock
one. An inspection reads the same inventories plus what the transforms added, and fails the build by throwing.
It writes only into its own output: an inspection that changes a file it was handed fails the build as well.
Pinning them
These plugins belong to no module, so their pins live beside the file that names them, in
jenesis., one line for each module in each plugin's closure:
plugin-audit/module/build.jenesis=0.14.0 SHA-256/...
plugin-notice/module/build.jenesis=0.14.0 SHA-256/...
pin writes the file and pins every plugin of the whole project the file names, including one a setting
switches off, so a plugin that only a profile switches on is pinned all the same. The plugins run with
everything that builds, pin among it, so an inspection that fails stops pin too. This pins without running
any plugin:
java -Djenesis.project.plugins=false build/jenesis/Make.java pin
Writing a build step
A build step is the unit of work introduced in Core concepts: it reads one or more input folders and writes into one fresh output folder. When you write your own, that is the shape you implement - a function handed its inputs and an output folder to fill:
CompletionStage<BuildStepResult> apply(Executor executor,
BuildStepContext context,
SequencedMap<String, BuildStepArgument> arguments);
The context gives you three folder slots:
next- the folder this run writes into. It is created fresh every time; your step writes here and nowhere else.previous- the same step's output from the prior run, ornullon a first run. You may read it to hard-link or copy unchanged files instead of regenerating them, but never write into it.supplement- scratch space for intermediate files you do not want to publish innext.
The arguments map carries one entry per predecessor you wired in. Each exposes the folder to read
(argument.) and a per-file change status - ADDED, ALTERED, REMOVED, or RETAINED - computed
against the previous run. The default behaviour re-runs your step whenever any input changed; override
shouldRun(. if you want finer control.
A step can also say that its output is not worth sending to a cache server. Override shouldCacheRemotely()
to return false when the output is large against what it costs to produce, or when it is re-derivable from
something the machine already has. The output is still cached locally, so a rebuild on this machine still
skips the work. See the build cache.
next, reach outside neither. That is what makes its output cacheable and safe to share
between builds - the incremental engine relies on it.
Talk through folders, not step names
Steps compose by file and folder conventions, not by knowing who wired them. A step discovers what to
read by looking for well-known paths inside each input folder - sources/ for Java sources, classes/ for
compiled output, artifacts/ for jars - and writes its output under names its consumers look up the same
way. Do not inspect the names of your predecessors to guess which input is which; read the folders. This is
what lets you splice a custom step between two stock ones without either noticing.
The serialised-state rule
Core concepts flagged that a step re-runs when its serialised state changes, and left the details here. This is the one rule a custom step must get right.
Jenesis content-hashes each step's serialised form and folds that hash into its cache key. So a step re-runs when its inputs change or when its own configuration changes, and "configuration" means the values of its serialised fields. The practical rule follows directly:
Put every knob that should trigger a rebuild into a serialised field. A greeting to substitute, a flag, a target version - if changing it should re-run the step, it has to be a (non-
transient) field, because that field's value is exactly what the cache hashes.
apply, fix a
bug in a helper - without changing any serialised field, its hash is identical and Jenesis reuses
the stale output. Example: a preprocess step whose substitution string lives in a
field re-runs the moment you edit that string; but if you instead hard-code the string in the method body
and edit it there, nothing re-runs until an input changes. Keep behaviour-affecting values in fields, or
bump the step's serialVersionUID (below) when you have changed only code.
State must be serialisable
Because the step is serialised to be hashed, all of its state must be serialisable. This is checked on the first run, at hash time, not lazily. Two things make the common cases work:
- A step written as a lambda serialises with what it captures, because
BuildStepis itselfSerializable. A lambda that uses only its parameters captures nothing; one that creates an anonymous class inside an instance method captures that instance, which then has to beSerializabletoo. - A lambda field serialises only if its declared type does. Declare the field as a serialisable
functional interface, or cast the lambda to
Function<…> &where you store it, and a lambda that closes over, say, aSerializable Pathserialises cleanly. The stock steps do this at their constructors, which is why you can hand them a plain lambda. - A
Pathfield is hashed by its string form, even though the JDK'sPathis not itselfSerializable. SoPath-typed configuration is first-class.
Genuinely non-serialisable state - an open socket, a database handle, a live Context object - throws
NotSerializableException at hash time, on the first run. That is deliberate: the error surfaces the bug
immediately rather than silently breaking cache invalidation. If you see it, hold the serialisable
description of the resource (a URL, a path, coordinates) as the field and open the resource inside
apply, or mark truly incidental state transient so it never reaches the digest.
serialVersionUID, which is the lever for a code-only change.
Without an explicit one, the JVM derives it from the class structure, so adding a field or changing a method
signature already changes the hash - but editing a method body does not. Declare a
serialVersionUID and bump it whenever you change a step's behaviour without touching its
fields. Once you declare one, structural changes no longer shift the hash on their own, so bumping it is
your job from then on.
Writing a plugin
A plugin is a build module: a named Java module that provides a build-executor service, which Jenesis
discovers through that declaration alone:
module demo.plugin {
requires build.jenesis;
requires org.json;
provides build.jenesis.BuildExecutorModule with demo.plugin.GreetingModule;
}
Its BuildExecutorModule adds the steps it contributes, as a stock module does. The values of its
plugin-<name>., or its lines of jenesis., reach it through a public
constructor taking a SequencedMap<String, String> of them, in the file's order. javac requires every service provider to keep a public constructor without
arguments as well, which the build uses when the file is empty. A file with values for a provider that takes
none fails the build, rather than the values being dropped:
public GreetingModule() {
this(Collections.emptyNavigableMap());
}
public GreetingModule(SequencedMap<String, String> properties) {
greeting = properties.getOrDefault("greeting", "Hello from a generated source!");
}
The steps a plugin adds are handed their inputs as arguments. An input bound with @<input> arrives as the
argument ., and a transform or an inspection finds each module's inventory as an
inventory. in the folders of its arguments:
BuildStepArgument legal = arguments.get("../inputs/legal");
A plugin compiled from source lives in a project folder of its own, which carries an empty .
marker so the project's module discovery does not mistake it for a second project module. A published plugin
is built and exported like any other module, and resolved by its module name.
ModuleLayer with its own class loader, and calls are
bridged across the boundary - so the two copies never clash and a plugin may pin its own Jenesis version,
as long as the API it uses lines up. The plugin itself must be an explicit named module; its
dependencies need not be, since a module layer admits automatic modules too.
Writing an entry point of your own
A build that plugins cannot express - one that changes what the stock steps do, several builds compared, one
staged build feeding the next, a graph with no project at all - is a program of its own, run as
java build/. Where it builds the
project, it calls Make, which reads jenesis., the profiles and the -Djenesis. properties the
JVM was started with, and returns what the build produced:
Make.Result staged = new Make("build.jenesis.Project").build("stage");
Path packages = staged.outputs().get("stage/packages");
Changing the stock build
An entry point that keeps the stock build but changes it builds the project from the settings, as Make does,
and replaces its assembler - the callback that wires each module's compile/jar/test sub-graph - with one
that wraps the stock InferredMultiProjectAssembler:
Environment environment = new Environment(Make.settings(Path.of(".")).keys());
InferredMultiProjectAssembler stock = InferredMultiProjectAssembler.ofEnvironment(environment);
Project project = Project.ofEnvironment(environment, Path.of("."))
.assembler((descriptor, repositories, resolvers) -> stock
.apply(descriptor, repositories, resolvers)
.mapBuild(build -> (sub, inherited) -> {
sub.addModule("assemble", build, inherited.sequencedKeySet().stream());
sub.addStep("sign", (executor, context, arguments) -> {
// read the jars in each argument's folder, write their signatures into context.next()
return CompletableFuture.completedStage(new BuildStepResult(true));
}, "assemble");
}));
System.exit(new Execution(project).execute(args));
mapBuild decorates only the module's build phase - here registering the stock output under assemble and
chaining a sign step onto it. Execution builds the project and runs the module that declares a main class,
as build/ does for the stock build.
The stock modules take additional steps and modules in code as well, through the same custom namespace a plugin is
wired into:
InferredMultiProjectAssembler checked = stock.check(check -> check.custom("placeholders", (executor, context, arguments) -> {
// fail when a source in any argument's folder still holds a ${ placeholder
return CompletableFuture.completedStage(new BuildStepResult(true));
}));
custom(name, step)adds one step andcustom(name, module)one module, after those added before; either refuses a name that is taken already.custom(map)sets every added module at once, aSequencedMap<String, BuildExecutorModule>in the order they are wired.
The plugins of the whole project are a value of the project rather than of the assembler: plugins()
answers the ProjectPlugins that jenesis. declared, and preprocess(name, step),
transform(name, step), inspect(name, step), stageTransform(name, step), stageInspect(name, step),
export(name, step), release(name, step) and goal(name, step) add one more, a
step or a module, refusing a name that is taken already. Here
ReleaseAudit is a BuildStep of the project that throws when a module's inventory lacks what every release must
carry:
Project project = Project.ofEnvironment(environment, Path.of("."));
project = project.plugins(project.plugins().inspect("audit", new ReleaseAudit()));
A plugin that pin should pin also needs its resolution(), the part of an InternalModule or ExternalModule
that resolves the plugin's closure without building it; ProjectPlugins takes those by name beside the
plugins themselves.
Redirecting a module's inputs
An entry point can also change what the stock steps consume, because the module descriptor is immutable with a
wither per property. Every reference accessor (sources, resources, manifests, dependencies,
artifacts, content, coordinates, spdx) returns a SequencedSet<String>, so you can add or replace
inputs in one line:
descriptor.sources("preprocess") // stock compile now reads the preprocess step's output, not sources/
That is the whole trick behind a preprocessing build: add a preprocess step that reads the module's
sources/, rewrites it into its own output, then hand the stock assembler a descriptor whose sources()
points at preprocess. javac, the jar step, and the tests all consume the transformed tree, and the rest
of the build is untouched. Any pass that produces a sources/ tree - template expansion, code generation,
licence-header stamping - fits the same shape.
Starting on the selected JDK
Such an entry point starts on whichever JDK runs it. To honour jenesis., as Make.
does, ask Toolchain from build.: new Toolchain(). answers the JDK the version selects, and
launch(Demo. starts the entry point again on it and returns its exit code.
A file that names Project or the steps is compiled by the JDK's source launcher on every run, engine
included. Compile it once when that matters - javac -d ., then
java -cp . - and keep java build/ as the documented command.
Reusing the toolchain without Project
When the stock compile/jar/test flow fits but Project does not, call the convenience factory
MavenProject. (or ModularProject. for a Java Module System project). It
discovers the modules under a root, fills in sane defaults - a Maven Central repository, the right resolver, a
digest - and leaves only the environment those defaults read their settings from and the assembler for you to
supply:
Environment environment = new Environment(Make.settings(Path.of(".")).keys());
BuildExecutor root = BuildExecutor.of(Path.of("target"));
root.addModule("maven", MavenProject.make(environment,
Path.of("."),
(descriptor, repositories, resolvers) -> new InferredMultiProjectAssembler().apply(
new ProjectModuleDescriptor(descriptor) // the discovered module
.configuration(Path.of(".")) // its configuration folders
.pathPlacement(PathPlacement.CLASS_PATH), // place dependencies on the class path
repositories, resolvers)));
root.execute(args);
The descriptor runs the module's tests, attaches no sources or javadoc jar and pins leniently unless told
otherwise; test(false), source(true), documentation(true) and pinning(Pinning. change one of
those each.
This is a middle ground: no layout, no goals, no Project, yet you did not wire every step by hand either.
ModularProject. is the modular counterpart; its convenience form builds pure modules (a modular jar,
no generated POM). For full control - a custom repository, strict pinning, a different digest, or emitting a
POM as well - switch to the longer make(. overload that Project itself uses.
Wiring the graph by hand
When auto-detection is the wrong starting point entirely - a non-Java pipeline, code generation, a wildly
custom graph - drop to the BuildExecutor primitives and build exactly the graph you want:
BuildExecutor root = BuildExecutor.of(Path.of("target"));
root.addSource("sources", Bind.asSources(), Path.of("sources"));
root.addStep("generate", new GenerateSource(), "sources"); // writes sources/sample/Generated.java
root.addStep("classes", new Javac(ProcessHandler.Factory.of()), "sources", "generate");
root.addStep("artifacts", new Jar(ProcessHandler.Factory.of(), Jar.Sort.CLASSES), "classes");
root.execute(args);
BuildExecutor. is the root and writes everything under target/. addSource binds a directory so
changes to it invalidate downstream caches. addStep(name, step, predecessors…) chains a step whose
arguments come from the named predecessors. execute runs the graph (or a selector's subtree), reusing
cached outputs whose inputs are unchanged. The generate step above synthesises a Java source on the fly and
Javac, which reads the sources/ of every predecessor, compiles it next to the hand-written ones.
There is no phase lifecycle to fit into: a build is just steps wired to steps, and here you wire them
yourself.
Running a build inside another program
An entry point of your own is still a process. A program that already runs - an IDE, a test harness, a
server that builds what it serves - can run a build in its own JVM instead, through
java., the JDK's interface for a tool that runs in-process the way javac and jlink
do. build. publishes three, named after the commands they answer to:
| Tool | What it does | What follows the settings |
|---|---|---|
jenesis-make |
Builds the project | Selectors, as on a command line |
jenesis-exec |
Builds, then runs what it built | The program's own arguments |
jpx |
Runs a published program | The target and jpx's options |
StringWriter log = new StringWriter();
int code = ToolProvider.findFirst("jenesis-make").orElseThrow().run(
new PrintWriter(log), new PrintWriter(log),
"-Djenesis.project.version=1.0.0", "build");
The leading -Djenesis. arguments configure that run and nothing else: they are never written to the
JVM's properties, and the run never reads them from there, so two builds in one program can be configured
differently and neither leaves anything behind. Everything the build prints arrives on the writers you
passed, which the tool does not flush - they are yours, and an autoflushing PrintWriter already drains
itself. An @<file> argument works here as on a command line.
A setting that would replace the process a build runs in cannot be honoured in-process, and is refused by
name rather than ignored: jenesis. and jenesis. for all three, and
jenesis. for the program jenesis-exec runs. A -J option is refused as well, as the JDK's own
tools refuse one: a tool runs in its caller's JVM and is handed no options for one, so the -J of Execute.
and of jpx is the commands' alone. The refusal arrives on err with a non-zero code rather than as an
exception. jenesis-exec forks the program it runs, as its command does, so that
program writes to the JVM's own streams while the build's output goes to the writers.
The tools are found by name when build. is a resolved module or a jar on the class path. Source
mode registers no service, so a program there constructs new MakeTool(), new ExecuteTool() or
new JpxTool() itself; the contract is the same.