An open API service indexing awesome lists of open source software.

https://github.com/raphw/jenesis

A Java-native build tool.
https://github.com/raphw/jenesis

build build-tool java

Last synced: about 1 month ago
JSON representation

A Java-native build tool.

Awesome Lists containing this project

README

          

Jenesis
=======

Getting started
---------------

Jenesis is a build tool for Java projects, written and configured in Java itself. It builds **modular projects**
out of the box (anything whose modules declare themselves via `module-info.java`) and also understands the
declarative slices of a `pom.xml` - descriptive metadata, plugin-free dependency lists, parent coordinates - so
a Maven-shaped project that does not lean on plugin lifecycles can be built without conversion. Pointed at a
project root containing a `module-info.java`, a `pom.xml`, or both, Jenesis discovers the multi-project graph
automatically and wires the matching compile, package, and (where sources are present) test pipeline.

One design goal is to ship the build *with* the project as plain Java source, and not as a binary. The Jenesis
sources sit inside your repository under `build/jenesis/`, the launcher is the JVM's single-file mode
(`java build/jenesis/Project.java`), and the build is reproducible from a clone plus a JDK. There is no opaque
wrapper, no fetched plugin tree, no fetched daemon - which closes the supply-chain surface that wrappers and
plugin resolvers otherwise expose. Shipping the build as plain source also keeps it fully modifiable where
needed: humans (and AI agents) can adjust how a project is built by implementing build steps in ordinary Java,
without a large API to learn first.

A second design goal is that the build is naturally incremental at every step and naturally produces reproducible
outputs. Each build step's inputs, outputs, and configuration are content-hashed, so unchanged work is reused
unchanged from the previous run, and identical inputs always reproduce identical outputs. That same posture is
what makes Jenesis strongly security-focused: dependencies can be pinned not only by version number but also by
the checksum of every downloaded artifact, so a build is naturally resistant to supply-chain attacks on its
inputs. Pinning at that level of detail is itself a consequence of embedding the build tool inside the project,
since the pin set lives in the same committed sources as everything else. The combination of plain-Java
sources and content-hashed steps also lays a foundation for optimising complex builds: a non-trivial custom
build can itself be compiled ahead of time, or shipped as a native image for environments that run the same
build at high frequency (a CI server, for example), and step outputs - being pure functions of their
content-hashed inputs - are easy to share between builds as a cache. A Jenesis build requires a JVM of
version 25 or newer, but nothing else.

### Installing

Three equivalent ways to populate `build/jenesis/` inside your project. All three land at the same on-disk
state, so the canonical `java build/jenesis/Project.java` invocation works identically afterwards:

**curl-piped bootstrap.** Fastest, no prerequisites beyond a JDK and `curl`. Run from your project root:

curl -fsSL https://get.jenesis.build | bash
java build/jenesis/Project.java

Set `JENESIS_VERSION=X.Y.Z` to pin a specific version. The script is `install.sh` at the repository root.

**Git submodule.** Most explicit; the pinned submodule commit is the reproducibility anchor, so a fresh clone
plus `git submodule update --init` is the entire setup with no separate install step:

git submodule add https://github.com/raphw/jenesis.git .jenesis
ln -s ../.jenesis/sources/build/jenesis build/jenesis
java build/jenesis/Project.java

On platforms without symlink support, replace the `ln -s` with `cp -r .jenesis/sources/build/jenesis build/jenesis`
and refresh after each submodule update.

**SDKMAN.** Best fit when you would rather manage versions globally instead of vendoring sources per project.
Install once, then initialise each consuming project from the SDK:

sdk install jenesis
jenesis-init # from your project root
java build/jenesis/Project.java # or just 'jenesis', equivalent

`jenesis-init` populates `build/jenesis/` from the installed SDK. The companion scripts `jenesis-validate`,
`jenesis-version`, and `jenesis-switch` are documented under [Using Jenesis as a CLI](#using-jenesis-as-a-cli).

You can also skip the embedding entirely and run `jenesis` directly from a project root: the SDK's own copy
of `Project.main(...)` is invoked against the current directory, with no `build/jenesis/` written. Customisation
is then limited to system properties (`-Djenesis.project.layout=...` and friends); custom builders and
hand-wired `.java` files under `build/` are not reachable. Useful for quick trials and for building projects
with an untrusted build source, where Jenesis itself stays the trusted, SDK-installed copy.

### Example: Building Jenesis itself

A clone of this repository is the easiest working example. Sources live under `sources/` and tests under
`tests/`, with a `module-info.java` in each (`build.jenesis` and `build.jenesis.test`) and a single root
`pom.xml` that points at both directories. The same canonical invocation builds it:

git clone https://github.com/raphw/jenesis.git
cd jenesis
java build/jenesis/Project.java

The auto-detected layout is `MAVEN`, since the root `pom.xml` takes precedence over a nested module-info.
The build compiles main and test sources, runs the tests, and writes artifacts under `target/`. Try
`java build/jenesis/Project.java stage` to materialise the release tree pushed to Maven Central, or browse
`metadata.properties` and the module-info javadoc to see how descriptive metadata flows into the emitted POM.

### Customizing the build

Customisation comes in three stages, picked by how far from the auto-wired pipeline you need to go.

**1. System properties on the canonical launcher.** When the project shape is fine but a knob needs flipping -
skip tests, force a layout, route `target/` elsewhere - pass `-Djenesis.project.*` flags. No Java code, no
separate entry point:

java -Djenesis.project.skipTests=true \
-Djenesis.project.layout=MODULAR_TO_MAVEN \
build/jenesis/Project.java

`Project.main(...)` calls `resolveProperties()` first, which maps `jenesis.project.*` onto the corresponding
`Project.Builder` setters. The full list is in [Configuration](#configuration). `jenesis.project.root` also
lets you target a project that lives outside the directory holding `build/jenesis/`:

java -Djenesis.project.root=/path/to/other/project build/jenesis/Project.java

**2. Custom entry point under `build/`.** When you want code-level control - a tailored assembler, an extra
step on top of the default per-module pipeline - drop a `.java` file alongside `Project.java` and use the
Builder there. Run it the same way (`java build/MyBuild.java`):

```java
package build;

import module java.base;
import build.jenesis.BuildExecutorModule;
import build.jenesis.Project;
import build.jenesis.project.JavaMultiProjectAssembler;
import build.jenesis.project.MultiProjectAssembler;
import build.jenesis.project.ProjectModuleDescriptor;

public class MyBuild {

static void main(String[] args) throws IOException {
MultiProjectAssembler base = new JavaMultiProjectAssembler();
MultiProjectAssembler withSign = (descriptor, repos, resolvers) -> {
BuildExecutorModule delegate = base.apply(descriptor, repos, resolvers);
return (sub, inherited) -> {
sub.addModule("assemble", delegate, inherited.sequencedKeySet().stream());
sub.addStep("sign", new Sign(), "assemble"); // Sign is a user-defined BuildStep
};
};
Project.builder()
.assembler(withSign)
.build(args);
}
}
```

The wrapper registers `JavaMultiProjectAssembler`'s output as a nested module named `assemble` and then
chains a `sign` step that depends on it. Assemblers compose this way freely: each layer registers its
delegate as a sub-module and adds its own steps next to it, so you can stack multiple decorators (sign,
attach licence headers, emit checksums) on top of a base assembler without subclassing. Jenesis itself
relies on the same pattern internally - the `MAVEN` and `MODULAR_TO_MAVEN` layouts transparently wrap the
user's assembler with `PomAwareAssembler`, which registers the user-supplied assembler under `assemble/`
and emits the per-module POM alongside it.

**3. Hand-wired build on the `BuildExecutor` API.** When auto-detection is not the right starting point at
all - a non-Java pipeline, a wildly custom graph, or you just want the primitives - bypass `Project` and
wire the build yourself:

```java
package build;

import module java.base;
import build.jenesis.BuildExecutor;
import build.jenesis.step.Bind;
import build.jenesis.step.Jar;
import build.jenesis.step.Javac;

public class Hand {

static void main(String[] args) throws IOException {
BuildExecutor root = BuildExecutor.of(Path.of("target"));
root.addSource("sources", Bind.asSources(), Path.of("sources"));
root.addStep("classes", Javac.tool(), "sources");
root.addStep("artifacts", Jar.tool(Jar.Sort.CLASSES), "classes");
root.execute(args);
}
}
```

`BuildExecutor.of(Path.of("target"))` is the root of the graph and writes all outputs under `target/`.
`addSource` binds an input directory through a `Bind` step so changes to the path invalidate downstream
caches; `addStep` chains a `BuildStep` whose argument list names its predecessors (`"sources"` for
`classes`, `"classes"` for `artifacts`); `execute(args)` runs the requested target (or the whole graph by
default), reusing cached outputs whose inputs have not changed. The full primitive set is documented under
[Architecture](#architecture), [Build steps](#build-steps), and [Build executor modules](#build-executor-modules),
and the example launchers under `build/` (`Minimal.java`, `Manual.java`, `Maven.java`, `Modular.java`,
`ModularToMaven.java`, `Modules.java`) are progressively richer working starting points.

### Selectors

`Project.main(args)` and `Builder.build(args)` accept selector strings as positional arguments. The canonical
example is `stage`, which runs the full release recipe (build → stage) and materialises a Maven-shaped tree
under `target/stage/output/`:

java build/jenesis/Project.java stage

Without arguments, `Project` runs whatever its `defaultTarget` is set to. Out of the box that is `"build"`,
which compiles and packages every discovered module but stops short of the downstream `stage` step.
`Project.Builder.defaultTarget(...)` changes the default (there is no matching system property). The other
top-level targets the shipped layouts register are `export` (only on `MAVEN` and `MODULAR_TO_MAVEN`; publishes
the staged tree into the local Maven repository) and `pin` (rewrite every `pom.xml` / `module-info.java` so
the full transitive closure is pinned at source level).

**Module selectors.** Selectors that start with `+` are rewritten by the active layout into the per-project
module path of that name, so a single module can be built without dragging its siblings in. The shipped
layouts encode names as `module-` and place them under their per-project aggregator:

- `+sources` resolves to `build/modules/compose/module/module-sources` under `MODULAR` and `MODULAR_TO_MAVEN`,
or to `build/maven/compose/module/module-sources` under `MAVEN`.
- `+` alone resolves to `module-` (trailing empty segment), the identity Maven's scanner produces for the root
POM in a multi-module Maven layout. A pure modular project has no such root, so `+` alone will not resolve
there.

The rewriter always yields a literal path, which avoids the lenient cascade that a bare module name would
trigger across sibling modules.

**General syntax and wildcards.** Under the hood every selector is a slash-delimited path of step identities
(`module/step`) that the executor matches against the registered graph. Two wildcards are supported:

- `:` matches a single path segment, so `build/:/java` matches the `java` module of every direct child of
`build`.
- `::` matches any depth (zero or more segments), so `::/sign` matches every `sign` step anywhere in the
tree.

Wildcards are lenient: branches that fail to match are silently skipped. A literal path that does not
resolve throws. Once a step is matched, its transitive preliminary closure runs unconditionally, so its
inputs are real folders rather than lenient-skipped placeholders. The full mechanics, including how sibling
modules along a wildcard path still have their `accept(...)` invoked, are documented under
[`BuildExecutor`](#buildexecutor) and [Selectors on the command line](#selectors-on-the-command-line).

### Layouts and assemblers

Two callbacks govern how the build is assembled, and they are pluggable independently:

- `Project.Layout` (set via `.layout(...)`) wires the top-level pipeline (the `download` step where applicable, the
`build` multi-project module, the `stage` step that walks per-module inventories, and on the Maven layouts the
`export` step that publishes the staged tree) and returns the `Function` that expands
`+`-prefixed selectors. The shipped constants `Layout.MAVEN`, `Layout.MODULAR`,
`Layout.MODULAR_TO_MAVEN` mirror `build/Maven.java`, `build/Modular.java`, and `build/ModularToMaven.java`.
`Layout.AUTO` (the default) calls `Layout.of(root)` and dispatches to one of the concrete layouts;
`MODULAR_TO_MAVEN` is reachable only explicitly, because its on-disk signature (`module-info.java` +
`pom.xml`) is indistinguishable from a pure modular project that keeps a `pom.xml` for IDE support.
- `MultiProjectAssembler` (set via `Project.Builder.assembler(...)`) wires the per-project
sub-graph: what each discovered module compiles, packages, and tests. The assembler's
`apply(D descriptor, Map repositories, Map resolvers)` receives the
per-module descriptor *and* the per-module merged repositories/resolvers (the layout-level maps with each
sibling sub-module's `assign` URI prepended, so a coordinate resolved locally never falls back to the global
repository). `Project` parameterises this over `ProjectModuleDescriptor`, a record that wraps the layout's
base descriptor (`MavenProject.MavenModuleDescriptor` or `ModularProject.ModularModuleDescriptor`) and adds
the project-level flags `tests`, `source`, `javadoc`. The default assembler `JavaMultiProjectAssembler` is
stateless and reads those flags off the descriptor it receives - no `Context` object: a `prepare` step plus a
`JavaModule` is wired against the six descriptor paths and the module's resources, and when `descriptor.test()`
is set and the module's `module.properties` flags it as a test variant a `TestModule` sub-module is wired
alongside the `JavaModule`, with optional `sources` and `javadoc` steps appended when the matching flag is set. The `MAVEN` and `MODULAR_TO_MAVEN` layouts wrap the
user's assembler with a `PomAwareAssembler` that emits a per-project `pom` step seeded with project-wide
metadata read once from `metadata.properties` (when configured); `MODULAR` does not. Each layout adds a
top-level `pin` module (sibling of `build`) that walks the BUILD outputs and rewrites every discovered
`pom.xml` / `module-info.java` so the full transitive closure (with checksums where available) is pinned at
source level. Pin is opt-in - it's not part of the default target - and it skips coordinates that come from
within the project (i.e. anything advertised through an `assign` step's `identity.properties`), so internal
modules never leak into the dependency-management block.

Layouts always combine their built-in repositories and resolvers (e.g. a Maven default for `MAVEN`, a chained
Jenesis module repository for `MODULAR`) with any user-provided ones. The merged map then has each sub-module's `assign`
URI prepended inside `MavenProject.make` / `ModularProject.make` and is handed to the assembler per call. User
entries with the same key override the layout default.

| Layout | Pipeline | Mirrors |
| -------------------- | ----------------------------------------------------------------------------------------- | ---------------------- |
| `Layout.MAVEN` | **Input: `pom.xml`. Output: classic JAR + `pom.xml`.** `MavenProject` scan + per-project `JavaModule` + per-module `Pom` step + `MavenRepositoryStaging` + `MavenRepositoryExport` | `build/Maven.java` |
| `Layout.MODULAR` | **Input: `module-info.java`. Output: modular JAR (no `pom.xml`).** `ModularProject` over `JenesisModuleRepository` (public overlay, cached under `.jenesis/cache/`) with `JenesisModuleRepository.ofLocal()` prepended + per-project `JavaModule` + `ModularStaging` + `JenesisModuleRepositoryExport` | `build/Modular.java` |
| `Layout.MODULAR_TO_MAVEN` | **Input: `module-info.java`. Output: modular JAR + `pom.xml`.** `DownloadModuleUris` + `ModularProject` against a `MavenDefaultRepository` (`MavenPomResolver` translated through `MavenUriParser`), with a per-module `Pom` step on top of the assembler, plus `MavenRepositoryStaging` + `MavenRepositoryExport` | `build/ModularToMaven.java` |
| `Layout.AUTO` (default) | Detection: a root `pom.xml` → `MAVEN`; else any `module-info.java` under the root → `MODULAR`. Trees rooted at a nested `.jenesis.build` marker are skipped. Falling through throws. | - |

`MODULAR_TO_MAVEN` resolves the transitive closure through `MavenPomResolver` against a `MavenDefaultRepository`,
so versions follow Maven's nearest-wins rules and dependency management - not the Java module system's
single-binding requirement. The resolver does not check that every transitive jar carries a `module-info.class`
or a manifest `Automatic-Module-Name`, and Maven coordinates do not encode a Java module name, so the resolved
set may include plain classpath jars or coordinates whose filename is not a legal automatic module name. The
artifact may still be module-path-consumable in practice; the layout simply does not prove it. For this reason
the layout omits `prefix.module` from `inventory.properties` and `Execute` launches the staged jar on the
classpath rather than via `--module-path`. `MODULAR` is the layout that resolves only against a module-name
registry and so guarantees a module-path-consumable closure.

All three concrete layouts run a `stage` step that depends directly on `BUILD` and materializes the staged
tree under `target/stage/output/` by walking every per-module `inventory.properties` the assembler produced.
The staging shape differs by layout:

- `MAVEN` and `MODULAR_TO_MAVEN` use `MavenRepositoryStaging`. For each main module it parses `prefix.pom`
for `groupId` / `artifactId` / `version` and hardlinks the artifacts as
`///-.` (suitable for upload to a Maven
repository). Test variants (those whose inventory carries a `prefix.test=` marker) are
routed onto the main coordinate with a `-tests` classifier, and the test module's `pom.xml` is parsed for
its dependencies, which are appended to the staged main POM with `test`. The follow-up
`MavenRepositoryExport` step copies the staged tree into the local Maven repository (default
`~/.m2/repository`, overridable via `MAVEN_REPOSITORY_LOCAL`) with the right `maven-metadata-local.xml` and
`_remote.repositories` markers.
- `MODULAR` uses `ModularStaging`. For each module's inventory it reads `prefix.module` (the Java module system module name)
and the optional `prefix.version`, then hardlinks the artifacts as `/.jar` (plus
`-sources.jar` / `-javadoc.jar` siblings when produced). When `prefix.version` is present, the version is
inserted as one extra path segment: `//.jar`. There is no `pom.xml` to anchor a
Maven coordinate. The follow-up `JenesisModuleRepositoryExport` step copies that staged tree into the
local Jenesis module repository (default `~/.jenesis`, overridable via the `JENESIS_REPOSITORY_LOCAL`
environment variable), preserving the same `[/]/` shape. When a module is versioned, its
files are *also* mirrored to the unversioned `/` root so the module root always reflects the most
recently built version (a subsequent build of the same module overwrites the root regardless of which
version it produces). Each target directory written in a run is cleaned of pre-existing regular files
before the new ones are linked in, so a build that no longer produces a `-javadoc.jar` does not leave a
stale one behind; sibling version directories (e.g. `/0.9/` while exporting `/1.0.0/`) are
untouched.

Run `java build/jenesis/Project.java stage` to materialize that tree (it's the canonical entry point for
release publishing - see [The stage step](#the-stage-step) for the full release pipeline).

The example scripts (`Minimal`, `Manual`, `Maven`, `Modular`, `ModularToMaven`, `Modules`) under `build/`
illustrate the underlying primitives that `Project` composes; they are not part of the canonical surface.

### Running inside Docker

Set `-Djenesis.project.docker=true` to run the entire build inside a throwaway container instead of directly on
the host JVM:

java -Djenesis.project.docker=true build/jenesis/Project.java

A minimal image is built on demand the first time and cached for subsequent runs. To target a different image,
add `-Djenesis.project.docker.image=`.

### Running a module's main entry

`build/jenesis/Execute.java` is a companion launcher to `Project.java`. It runs the build first, finds the
module that declares a `@jenesis.main` (in its `module-info.java`) or `` (in its `pom.xml`), and spawns a
child `java` process for it, forwarding any trailing arguments to the program:

java build/jenesis/Execute.java arg1 arg2

If exactly one module in the project declares a main, Execute selects it implicitly. If several do, it aborts
and lists the candidates; pass `-Djenesis.execute.module=` (the same path you would use after `+` in a
build selector) and `-Djenesis.execute.mainClass=` to specify the target explicitly. Doing so also
narrows the build to that module's subtree, skipping siblings:

java -Djenesis.execute.module=tools \
-Djenesis.execute.mainClass=org.example.tools.Cli \
build/jenesis/Execute.java --help

Execute can also run the launched program inside a container, independently of whether the build itself was
dockerised. Set `-Djenesis.execute.docker=true` to dispatch the final `java -m /` (or `java -cp
... `) invocation through Docker, with `-Djenesis.execute.docker.image=` overriding the
image. The build runs as usual (locally, or in `jenesis.project.docker.image` if set), and only the launch
step crosses the container boundary, so the build image and the runtime image can differ.

Architecture
------------

The lowest primitive is a `BuildStep`, a single unit of work that reads from a set of input folders and writes
into a fresh output folder. It is a functional interface:

```java
CompletionStage apply(Executor executor,
BuildStepContext context,
SequencedMap arguments);
```

Each invocation is handed a `BuildStepContext` and a map of predecessor outputs. The context holds three folder
slots:

- `next`: the folder this invocation writes into. It is created fresh for every run; the step never modifies any
other folder.
- `previous`: the same step's output folder from the prior run, or `null` on a first run. A step can read it to
decide what to copy or hard-link instead of regenerating, but it must not write into it.
- `supplement`: scratch space tied to the step's lifetime, available for intermediate files the step doesn't want
to publish in `next`.

The `arguments` map carries one `BuildStepArgument` per registered predecessor. Each argument exposes the folder to
read from (`argument.folder()`) and a per-file checksum status (`ADDED`, `ALTERED`, `REMOVED`, `RETAINED`) computed
against the previous run. The default `shouldRun(...)` re-runs the step when any input has changed; a step can
override it to express finer-grained dependencies (e.g. `Bind` only re-runs when files matching its bound paths
changed).

Steps are organised into a graph by `BuildExecutor`:

- `addSource(name, path)` registers an external folder as an input.
- `addStep(name, BuildStep, predecessors…)` adds a step whose `arguments` will be populated from the named
predecessors. Predecessors are addressed by their registered names; cross-module references use the `../` prefix
(`BuildExecutorModule.PREVIOUS`) to climb out of the current sub-graph.
- `execute(selectors…)` runs the graph on a virtual-thread executor, scheduling each node as soon as its
predecessors have completed. With no selectors, the full graph runs. Otherwise each selector is a slash-delimited
path of identities (`module/step`) that restricts execution to the named steps and their preliminaries; `:`
matches any one path segment and `::` matches any depth (zero or more). Wildcards are lenient - branches without
a match are silently skipped - while a literal path that doesn't resolve throws.

Once a step is matched, its **transitive preliminary closure** runs unconditionally (no further selector filtering)
so its inputs are real folders, not lenient-skipped placeholders. Modules along the path are different from steps
here: a module's `accept(...)` always runs (modules aren't cached), and `accept` is allowed to read its predecessor
folders to wire its sub-graph dynamically. So whenever a module is reached by any selector - including via lenient
`::` propagation - its step preliminaries are pinned and run normally (cache-checked but not lenient-skipped),
guaranteeing those folders exist when `accept` reads them. Sibling modules whose subtree contains no match still
have their `accept` invoked and their declared step preliminaries run; the engine can't determine "no match here"
without descending, since module substructure is registered by `accept` itself. In practice this is a hash check
per preliminary on a warm cache. If you know the path you want, prefer literal selectors (`module/step`) over
`::/leaf` to avoid that residual work on unrelated subtrees.

A `BuildExecutorModule` is a sub-graph factory, also a functional interface, with
`accept(BuildExecutor, inherited)` populating a nested `BuildExecutor` with its own steps and (transitively) its
own sub-modules. The `inherited` map exposes the predecessor folders the parent passed in, addressed under their
`../`-prefixed identifiers. Modules can rename their published outputs by overriding `resolve(...)`. Composing
steps into modules turns commonly-recurring patterns (compile + jar + test, resolve + checksum + download, scan a
multi-project tree, …) into reusable units that take only their inputs as configuration.

Unlike steps, modules are not cached: `accept(...)` runs on every build to (re-)register its sub-graph, and only
the registered steps are then content-hashed and considered for skipping. Logic that lives inside the module body
itself - file scans, classpath assembly, conditional step wiring - therefore executes unconditionally on every
run; wrap it in a step if you need it skipped on unchanged inputs.

Three properties of the model give incremental builds and reproducibility for free:

- **Each step's output folder is immutable once produced.** A step only ever writes into its own `next`; downstream
steps see predecessor outputs as read-only inputs. There is no shared mutable state, so a step's result is a pure
function of its inputs.
- **Inputs and outputs are content-hashed.** Every output folder is checksummed when the step finishes; on the next
run, those checksums become the predecessors' input checksums. If they all match and `shouldRun(...)` returns
`false`, the step's previous output is reused unchanged. Anywhere along the chain that the hashes diverge (a
source edit, an upstream re-run, a different dependency), the affected step (and only the affected step) is
re-executed into a fresh `next` folder, which transparently replaces its predecessor.
- **Each step's configuration is content-hashed too.** `BuildStep extends Serializable`, and a
`BuildStepHashFunction` digests the step's serialized form alongside the output checksums (in
`/checksum/step`). When a step is reconstructed with different field values - a different `Jar.Sort`,
a different `Resolver`, a different placement function - its hash changes and the step re-runs even if its
inputs are unchanged. Configuration that should *not* count as part of the build's identity (a `Repository` that
by contract returns the same artifact for the same coordinate, a JDK service factory, a `MavenPomEmitter`) is
marked `transient` so it never reaches the digest. Lambdas held by step fields use intersection bounds
(` & Serializable>`) at the constructor so the compiler generates them serializable. The
hash stream also installs a `replaceObject` hook that substitutes any `java.nio.file.Path` for its `toString()`,
making `Path`-typed step fields a first-class part of the configuration hash by design - the JDK's `Path`
interface is not declared `Serializable`, so without this substitution any step that held a `Path` would fail
serialization. Steps that still hold genuinely non-serializable state throw `NotSerializableException` at hash
time so the bug surfaces at the first run instead of silently breaking cache invalidation.

Declaring an explicit `serialVersionUID` on a `BuildStep` is the Java-native equivalent of adding a manual
version field to the type: it replaces the JVM-computed shape fingerprint with a pinned value the author
maintains by hand. The trade-off is real, because the auto-computed UID is the only part of the default
serialization stream that tracks method signatures at all. The class descriptor itself records only the class
name, flags, non-transient field shape, and superclass chain, never methods or bodies. Pinning a UID therefore
removes the cache's only handle on behavioural changes: a step whose `execute(...)` gains parameters, whose
helpers change signature, or whose superclass adds a method then hashes identically to the prior version, and
stale outputs may be reused. The author is then responsible for bumping the UID by hand on every
behaviour-affecting change. The implicit UID is not perfect either, since it does not recurse into superclasses
or interfaces and ignores method bodies, but it catches more accidental drift than a pinned value and is the
default `BuildStep` authors should rely on. Pin one only when stream stability across JVMs or compiler versions
outweighs the loss of automatic discovery, and treat the value as something you bump by hand thereafter; once
an explicit UID is declared the JDK no longer computes the implicit one, and there is no supported way to ask
`ObjectOutputStream` what it would have been.

The executor places a `.jenesis.build` marker at the build root so source scanners (`MavenProject`,
`ModularProject`) can skip nested builds, stores all per-step state under `target/`, and uses `.jenesis/cache/`
by convention for cross-build caches such as downloaded module URIs.

### Best practice: communicate through file/folder conventions, not step names

A step or module should treat its `inherited` map as an opaque set of **input folders** and discover what to
read by looking for files and folders at well-known relative paths inside each input. It should not pattern-match
on the keys themselves to infer which predecessor an input came from. The same applies to its outputs: a step
writes file and folder layouts that downstream consumers look up by name, never expecting the consumer to know
how the step was wired.

Concretely:

- **Don't filter `inherited.sequencedKeySet()` by step-name patterns.** If a module needs to distinguish two
categories of inputs (e.g. compile-side vs. runtime-side), let the caller wire each category to a distinct
predecessor or pass an explicit predicate; don't have the module sniff `key.split("/").contains("runtime")` to
guess.
- **Don't compose `inherited` keys with extra `BuildExecutorModule.PREVIOUS` (`../`) prefixes** to chase a
predecessor that lives one level higher than the descriptor states. Instead, do the lookup at the level where
the descriptor's path strings apply directly (typically the outer assembler lambda) and capture the result for
any inner sub-module that needs it.
- **A module's exposed steps must not publish the same file at the same relative path in more than one of
them.** Exposing several intermediate steps is *not* a problem by itself - a consumer that doesn't recognise
a given file/folder convention just ignores those entries. The problem is when two of a module's exposed
steps both write, say, `versions.properties` at the same relative path: a consumer iterating
`inherited.values()` and resolving `folder.resolve(BuildStep.VERSIONS)` will find that file twice with
possibly different content (typically an early-pipeline placeholder and a later-pipeline refined version),
and which one wins depends on iteration order. Override `resolve(String path)` to return `Optional.empty()`
for any leaf whose exposure would create such a collision, keeping only the step that holds the **final**
state of each file. A chain like `Resolve` -> `Download` where each step rewrites `requires.properties` /
`versions.properties` should expose only the downstream `Download` leaf; the upstream `Resolve` output stays
available to its in-module successor by name but disappears from the module's published map. Leaves whose
files don't collide with any sibling can stay exposed unchanged. `ExternalModule` is the strict end of the
spectrum: it hides every internal node (`coordinate`, `dependencies`, `external`, `delegate`) and republishes
the delegated module's leaves under its own registered name (see [`ExternalModule`](#externalmodule)).
- **Define each step-name constant once, at the class that adds the step**, and have all consumers reference
that constant. `MultiProjectModule.IDENTIFIER` / `.COMPOSE` / `.MODULE` belong on `MultiProjectModule`
because that's the framework that wires those sub-modules; `DependenciesModule.RESOLVED` / `.ARTIFACTS`
belong on `DependenciesModule` because that's where the steps are added. The per-scope sub-module folder
names are derived from `DependencyScope.label()` rather than living as separate constants. A class that
wants to point at a predecessor's leaf step uses the owner's constant - no separate "same string" duplicate.
- **`*.properties` files exchanged between steps in different files should have a documented schema.** The
conventional files (`identity.properties`, `module.properties`, `metadata.properties`, `requires.properties`,
`versions.properties`, `scopes.properties`) are listed in the table below with their produced/consumed keys
and value semantics. The filenames live as constants on `BuildStep`; each property key's contract belongs in
the README rather than as a magic string scattered across writer and reader sites.
- **Paths inside a properties file should be self-anchored: written relative to that file's own folder.** A
consumer resolves the path with `.resolve().normalize()` and never depends on
the absolute layout of `target/` or on where the file happens to live in the build graph. Writers achieve
this by `context.next().relativize(absolutePath)` before storing the value. This is what `process/*.properties`
does for command-line path fragments, what `identity.properties` does for assigned artifact paths, and what
`inventory.properties` does for `artifact*`, `pom`, and `runtime`. The convention is load-bearing for
reproducible builds: it means the same folder tree linked, copied, or mounted under a different absolute
prefix continues to work without rewriting any properties file, and a step's output is therefore safe to
hard-link into another build's cache, ship between machines, or move between `target/` directories. The
inverse - storing absolute paths or paths anchored to some shared root - couples the file's validity to
its physical location and breaks the moment the build tree moves.
- **Schema-level vocabulary in those properties files is matched as literal strings.** The values written to
`scopes.properties` (e.g. `COMPILE`, `RUNTIME`) are an open-ended token set documented in the table below;
new steps and producers are free to introduce additional tokens without touching the shared `DependencyScope`
enum. Today's producers and consumers happen to use `DependencyScope.COMPILE.name()` /
`DependencyScope.RUNTIME.name()` to derive the string, which keeps writer and reader spellings in sync
without forcing every participant to depend on the enum (the wire format is the string, not the enum value).
Property-file tokens are written in upper case (`COMPILE`, `RUNTIME`) to keep them visually distinct from
the lower-case sub-module folder names (`compile/`, `runtime/`) that share the same root word and to reduce
the chance of a typo silently matching. The general infrastructure (`BuildExecutor`, `BuildStep`, the
`scopes.properties` file format) does not enforce a closed token set: only the bundled `MavenProject.make` /
`ModularProject.make` wiring and its helpers (`MultiProjectDependencies`, `Pom`) reference
`DependencyScope`, and they only consume the tokens they know about. A custom project type or layout that
supplies its own `Manifests` step, its own per-scope prepare step, and its own consumer (or skips `Pom`
entirely) can introduce additional scope tokens with no framework-level changes; the `DependencyScope` enum
is a convenience for the bundled flow, not a global registry.

The exception is **inline sub-modules of the same enclosing module**: a class that adds several sub-modules and
steps in its own `accept(...)` may reference its own sub-module/step names by their (private) constants, since
the wiring lives in one file and never crosses the module boundary. `ExternalModule`'s references to its inner
`EXTERNAL`, `DEPENDENCIES`, `DELEGATE` step names; `MavenProject`'s references to its private `MODULE`,
`DEPENDENCIES`, `PREPARE` constants; and `MultiProjectModule`'s references to its `IDENTIFIER`, `COMPOSE`,
`MODULE`, `GROUP` sub-module names are all of this shape.

Conventional folders and files
------------------------------

Every step writes its output into `context.next()`. The conventions below define the names a step uses for the
artifacts it produces and the names downstream steps look for. The canonical names are constants on `BuildStep`;
others are declared next to the step that emits them.

| Path | Constant | Purpose |
| -------------------------- | -------------------------------- | ----------------------------------------------------------------------------------------------- |
| `sources/` | `BuildStep.SOURCES` | A directory tree of `.java` source files (mirroring their package structure) consumed by compilation and documentation tooling. The same folder name is also the conventional output location for the packaged source jar produced by `Jar.tool(Jar.Sort.SOURCES)`, which writes a single `sources.jar` file alongside the tree at `sources/sources.jar`. A sources jar is not a deployable artifact, so it lives next to the source tree rather than in `artifacts/`. |
| `resources/` | `BuildStep.RESOURCES` | A directory tree of non-source files (configuration, message bundles, static assets) that should appear on the classpath alongside compiled classes and be embedded into produced jars. |
| `classes/` | `BuildStep.CLASSES` | A directory tree of compiled `.class` files in their package layout, plus any non-source companion files copied verbatim from `sources/`. Forms a class- or module-path entry for downstream compilation, packaging and execution. |
| `artifacts/` | `BuildStep.ARTIFACTS` | A flat directory holding **the module's own produced binary jars** (typically just `classes.jar`, emitted here by `Jar.tool(Jar.Sort.CLASSES)`). Downloaded dependency jars deliberately do not live here, see `dependencies/`. Source jars and documentation jars do not live here either, since they are not deployable binaries, see `sources/` and `documentation/`. |
| `dependencies/` | `BuildStep.DEPENDENCIES` | A flat directory holding **downloaded dependency jars** that `Download` placed for this step (every transitive jar pulled in for the configured scope, whether external Maven or a sibling module's binary that was resolved by coordinate). Downstream classpath/module-path consumers (`Javac`, `Java`, `Javadoc`, `TestEngine`) walk both `artifacts/` and `dependencies/` to assemble the full set of jars. |
| `javadoc/` | `Javadoc.JAVADOC` | A generated Javadoc tree (HTML, CSS and supporting resources), ready to be archived into a documentation jar or served as static content. |
| `documentation/` | `BuildStep.DOCUMENTATION` | Conventional output location for packaged documentation. `Jar.tool(Jar.Sort.JAVADOC)` writes `documentation/javadoc.jar` here, distinct from both the generated tree under `javadoc/` and from `artifacts/` (a javadoc jar is documentation, not a deployable binary). |
| `groups/` | `Group.GROUPS` | One `.properties` file per identified group, listing the other groups whose coordinates the group transitively depends on so cross-project wiring can be derived purely from on-disk state. |
| `pom/` | `MavenProject.POM` | A mirror of the directory layout of a Maven multi-module project, with each `pom.xml` hard-linked from its original location to give downstream tooling a stable, sandboxed snapshot of the project's POM tree. |
| `maven/` | `MavenProject.MAVEN` | One properties file per discovered Maven module (`module-.properties` for the main artifact, `test-module-.properties` for the test artifact), holding the parsed coordinate, source/resource directories, packaging and dependency list extracted from a single `pom.xml`. |
| `identity.properties` | `BuildStep.IDENTITY` | `/` keys (e.g. `maven/groupId/artifactId/[type/[classifier/]]version` or `module/`) mapped to either an empty value (artifact not yet built; identifies the project's own coordinate) or the absolute filesystem path of an already-built jar. |
| `requires.properties` | `BuildStep.REQUIRES` | Same `/` keys as `identity.properties`, mapped to either an empty value (no integrity validation requested) or an `/` content checksum that `Download` verifies against the downloaded artifact (mismatch fails the build). Checksums are pinned in source by the user: a `` comment inside a POM `` element, or a `` inside `` (which propagates to whichever transitive resolves to that coord), or an `@jenesis.pin /` Javadoc tag in `module-info.java`. Checksums are computed once, by the `pin` step: `PinPom` / `PinModuleInfo` rehash every resolved jar in the upstream `artifacts/` folders using `-Djenesis.project.pinAlgorithm` (default `SHA-256`) and write the result back into `pom.xml`'s `` comments or `module-info.java`'s `@jenesis.pin /` Javadoc tags. `Download` then validates every subsequent fetch against the pinned checksum (mismatch fails the build); a coordinate that still has no pinned checksum is downloaded without integrity validation - or, when strict pinning is enabled (via `Project.strictPinning(true)` / `-Djenesis.project.strictPinning=true`, propagated through `MavenProject.make` / `ModularProject.make` / `DependenciesModule` and through `ProjectModuleDescriptor` / `JavaMultiProjectAssembler` / `TestModule` into every `Download` instance), the build fails. After `Resolve` runs, module-style coordinates carry an optional trailing `/` segment (`module/org.junit.jupiter/5.11.3`) reflecting the version a resolver chose for that module. |
| `versions.properties` | `BuildStep.VERSIONS` | `/=[ /]` entries that act as a *bill of materials* for the resolution that follows: every resolver receives this map alongside `requires.properties` and uses the version part to pin any (declared or transitive) dependency that matches the bare coordinate. The optional space-separated `/` suffix is the pre-pinned content checksum for that coordinate; resolvers carry it through into the resolved `requires.properties` value so `Download` validates the bytes against it. For Maven the key is `groupId/artifactId[/type[/classifier]]`; for modules it is the bare Java module name. The file is written next to `requires.properties` by producers that have version data to contribute (`ModularProject` from `@jenesis.pin` Javadoc tags, `MavenProject` from ``). |
| `scopes.properties` | `BuildStep.SCOPES` | Sibling of `requires.properties` produced by the `Manifests` steps in `ModularProject` and `MavenProject`. Each key is a `/` from `requires.properties`; the value is a comma-separated list of scope tokens describing in which scopes the dependency is visible. The token set is open-ended (matched as literal strings) so additional steps can introduce their own scope tokens later. The currently recognized tokens are the upper-case `DependencyScope` enum names `COMPILE` and `RUNTIME`: compile-only entries (Maven `provided`, Java `requires static`) carry just `COMPILE`; runtime-only entries (Maven `runtime`) carry just `RUNTIME`; entries visible in both carry `COMPILE,RUNTIME`. `MultiProjectDependencies` filters `requires.properties` against the `DependencyScope` it is bound to; `Pom` reads it to decide whether each dependency is emitted as `compile`, `provided`, or `runtime`. The upper-case spelling distinguishes property-file content from the lower-case sub-module folder names (`compile/`, `runtime/`) that share the same root word. |
| `exclusions.properties` | `BuildStep.EXCLUSIONS` | Sibling of `requires.properties` produced by `MavenProject.Manifests` (only when a dependency declaration in a `pom.xml` carries ``). Each key is a `/` from `requires.properties`; the value is a comma-separated list of `/` patterns that the resolver must subtract from this dependency's transitive closure (so e.g. `mockito-core net.bytebuddy/byte-buddy` does not silently re-pull `byte-buddy` through the test classpath). `MultiProjectDependencies` carries the entries through to the per-scope prepare step alongside the matching `requires.properties` rows; `Resolve` reads the file from its arguments and threads the exclusion set per coordinate into `Resolver.dependencies`, where `MavenPomResolver` populates `MavenDependencyValue.exclusions` so the transitive walk honours them. `ModularJarResolver` rejects any non-empty exclusion set up front because Java modules have no exclusion concept. `Pom` reads the file to emit each `` with its declared `` so consumers of the published POM keep the same closure. The file is omitted entirely when no dependency in the module declares exclusions. |
| `module.properties` | `BuildStep.MODULE` | Per-module **graph-state** descriptor written by every `Manifests` step. Carries only keys the framework manages, never the user. Always present with `path=` (the source folder housing this module's `pom.xml` / `module-info.java`). `ModularProject.Manifests` also writes `module=`. Test variants additionally carry `test=` (or the empty string for the deprecated bare `@jenesis.test` form); the key is absent on main modules, and consumers (`Pom`, `JavaMultiProjectAssembler`, `Inventory`) use that absence/presence as the test-variant signal, with `Inventory` mirroring the value into `inventory.properties` as `prefix.test` so `MavenRepositoryStaging` and `ModularStaging` can route test modules at staging time. Modules with an entry point carry `main=` on the **main** variant (omitted on test variants): `ModularProject.Manifests` populates it from an `@jenesis.main ` Javadoc tag on `module-info.java`, `MavenProject`'s per-module manifests step populates it from a `...` entry in the module's `pom.xml`. `JavaMultiProjectAssembler` runs a `prepare` step that translates `main` into a `process/jar.properties` file with a `--main-class=` flag; the existing `ProcessBuildStep` plumbing then prepends that flag to the `jar` command line, which makes the produced `classes.jar` carry both a manifest `Main-Class:` entry and a `ModuleMainClass` attribute on the bundled `module-info.class`. `Project.PinModule` reads `path` from every input folder that carries this file to discover which source files to pin without pattern-matching graph paths. |
| `metadata.properties` | `BuildStep.METADATA` | Per-module **POM coordinates and descriptive metadata** written by every `Manifests` step. Always carries the three coordinate keys `project=`, `artifact=`, `version=`: `MavenProject`'s per-module manifests step copies them straight from the `pom.xml`, while `ModularProject.Manifests` derives them from the Java module system module name (first two dot-separated segments for `project`, the full name for `artifact`) and defaults `version` to `0-SNAPSHOT`. On top of the coordinates the step adds whatever descriptive metadata is available: `ModularProject.Manifests` parses `name` and `description` from the module-info Javadoc; `MavenProject`'s manifests step lifts ``, ``, ``, every `` (as `license..name` / `license..url`, where `` is the license name lowercased with spaces and dots replaced by `_`), every `` (as `developer..name` / `developer..email`), and the `` block (`scm.connection`, `scm.developerConnection`, `scm.url`) from the module's `pom.xml`. After the framework's own defaults are written, the step folds any upstream `metadata.properties` from its input folders on top (later puts win), which is how user-supplied overrides take precedence over both the framework defaults and the POM-extracted values. `Pom` consumes the file as the single source of truth for the emitted pom and throws if any of `project` / `artifact` / `version` is missing. The optional project-root override file (conventionally `project.properties`, pointed at via `-Djenesis.project.metadata=`) uses the same key schema and is bound into the executor's `metadata` module so its entries reach every per-module `metadata.properties` as upstream input; `-Djenesis.project.version=` is appended last and overrides any `version` from either layer. |
| `inventory.properties` | `Inventory.INVENTORY` | Per-module **launchable and stageable summary** written by `Inventory`. Each module produces one file whose keys carry a single-segment prefix derived from the module's path: `module` for the root module (empty `path`), `module-` otherwise (e.g. `module-core`). Keeping the prefix dot-free lets a consumer recover the prefix from any key by taking the substring up to the first `.`. The three folder-listing keys each carry a comma-separated list of files found in the matching folder among the inventory's predecessors: `prefix.artifacts` (the contents of every `artifacts/` folder, i.e. the module's produced binary jars; the staging steps require exactly one entry ending in `.jar`), `prefix.sources` (the contents of every `sources/` folder, typically just the produced `sources.jar`; the staging steps require at most one entry ending in `.jar`), `prefix.documentation` (the contents of every `documentation/` folder, typically just the produced `javadoc.jar`; same at-most-one-jar rule). Plus the existing scalar keys: `prefix.pom` (path to the generated `pom.xml` when the layout emits one), `prefix.version` (mirror of `metadata.properties`' `version`), `prefix.test` (mirror of `module.properties`' `test`, set only on test modules), `prefix.module` (mirror of `module.properties`' `module`, omitted under `MODULAR_TO_MAVEN` because that layout publishes via Maven coordinates and consumers may not place the artifact on the module path), `prefix.mainClass` (mirror of `module.properties`' `main`), and `prefix.runtime` (comma-separated jar paths: the binary artifact followed by every file in any `dependencies/` folder — the runtime classpath that `Execute` uses). All path values are **self-anchored**: written relative to the inventory file's own folder, and consumers resolve them with `.resolve(value).normalize()`. Any key whose value would be empty is omitted entirely. A consumer that reads several modules' inventories can `putAll` them into one `Properties` map without key collisions, then group by prefix to recover per-module records. Consumers: `Execute` picks candidates with `prefix.mainClass` set and assembles the classpath/modulepath from `prefix.runtime`; `MavenRepositoryStaging` parses `prefix.pom` for coordinates, routes by `prefix.test`, validates the folder-listing keys, and hardlinks the single jars into the Maven repository layout; `ModularStaging` reads `prefix.module` plus optional `prefix.version`, validates the folder-listing keys, and hardlinks the single jars under `/[/]`. |
| `uris.properties` | `DownloadModuleUris.URIS` | `/` keys mapped to an absolute jar URL; populated from line-based `=` registries (default: sormuras/modules) and used during dependency resolution to translate a Java module name into a download URL. When a versioned coordinate is requested (e.g. `org.assertj.core/3.27.0`) and the bare name is mapped to a URL whose final path segments follow the Maven repository layout (`...///-[-].`), an opt-in version-resolver function (`MavenDefaultRepository.versionResolver()`) supplied by the caller rewrites the path's version segment and the filename's version segment to the pinned value, so a single-URL registry still satisfies version pins. Without that function, `Repository.ofUris` performs strict literal lookup only; if the version resolver is supplied but returns `Optional.empty()` for a versioned coordinate (e.g. the registered URL is not in Maven layout), the fetch is a clean miss - the bare-name URL is **not** silently substituted, so a build that asked for `foo/1.2.3` will never quietly receive the registry's default version. The standalone example script `build/Modular.java` passes this resolver explicitly when wiring `Repository.ofProperties`, since the dominant Java module URL registries (sormuras/modules and most internal mirrors) point at Maven Central -- making the Maven layout assumption visible at the use site rather than baked into the generic `Repository` infrastructure. The shipped `Layout.MODULAR` does not consume `uris.properties` directly anymore; its `module` prefix is served by the `https://repo.jenesis.build/modules/` overlay (which performs the same version rewrite internally), with `JenesisModuleRepository.ofLocal()` prepended. |
| `process/.properties` | `ProcessBuildStep.PROCESS` (folder) | Command-line fragments contributed to a downstream `ProcessBuildStep` whose tool name matches `` (`java`, `javac`, `jar`, `javadoc`). Keys are flags (e.g. `--add-modules`); values are flag values, with literal `\n` inside a value emitting the same flag once per piece. Each input folder's file is processed independently and its entries are appended to the command line in folder order, so the same key in two folders becomes two flag instances. Values that name filesystem paths are written relative to the file's containing folder (paths are not resolved until the consumer step needs them), which keeps the on-disk content position-independent so build outputs can be relocated or shared between caches without rewriting. |
| `pom.xml` | `Pom.POM` | A generated Maven Project Object Model, ready to be packaged alongside a built jar so the artifact can be published to and consumed from any Maven-aware repository. |
| `target/` | (passed to `BuildExecutor.of`) | The root folder under which every step's per-run output and the executor's incremental bookkeeping (output checksums and predecessor checksum snapshots used to decide whether a step needs to re-run) live. Safe to delete to force a clean build. |
| `.jenesis/cache/` | by convention | A project-root folder for caches that outlive a single build, hardlink-shared with `target/`. The `MODULAR` layout populates `.jenesis/cache/.jar` via `Repository.cached(...)` so module jars survive a `target/` wipe; `MAVEN` and `MODULAR_TO_MAVEN` cache into `~/.m2/repository` instead. Relocatable via `Project.cache(Path)` or `-Djenesis.project.cache=`. See the *Repositories and resolvers* and *The `.jenesis/cache/` folder* sections below for the full picture. |
| `.jenesis.build` | `BuildExecutor.BUILD_MARKER` | An empty marker file placed at the root of an active build directory. Project-tree walkers honour it as a stop signal so nested builds aren't re-discovered as part of the parent build's project graph. |

Build steps
-----------

The steps listed here are pre-implemented for convenience; the build tool itself does not depend on any of them, and a build is free to ignore them and supply its own `BuildStep` implementations.

| Step | What it does | Inputs (per predecessor folder) | Outputs (under `context.next()`) |
| -------------------------- |------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| `Bind` | Hard-links files from each predecessor into a target layout under `context.next()`, driven by a `Map` that mirrors specific subtrees under canonical names (used by the static factories `asSources()`, `asResources()`, `asIdentity(...)`, `asRequires(...)`). | a source folder, a named properties file, or any other predecessor subtree named in the map | `sources/`, `resources/`, `identity.properties`, `requires.properties`, or any layout produced by the configured map |
| `Javac` | Compiles each predecessor's `sources/` with the `javac` tool, using their `classes/` and `artifacts/` as class- or module-path entries; writes the resulting `.class` files to `classes/`. | `sources/`, `classes/`, `artifacts/` | `classes/` |
| `Jar` | Packages the folders selected by the configured `Jar.Sort` into a single jar at the convention path that matches the sort: `CLASSES` writes `artifacts/classes.jar` (the deployable binary), `SOURCES` writes `sources/sources.jar` (alongside the source tree), `JAVADOC` writes `documentation/javadoc.jar` (alongside the docs tree); the latter two stay out of `artifacts/` since they are not deployable binaries. | per `Jar.Sort`: `CLASSES` reads `classes/` + `resources/`; `SOURCES` reads `sources/` + `resources/`; `JAVADOC` reads `javadoc/` | `artifacts/classes.jar`, `sources/sources.jar`, or `documentation/javadoc.jar` (depending on `Jar.Sort`) |
| `Javadoc` | Invokes the `javadoc` tool over each predecessor's `sources/` and writes the generated documentation tree to `javadoc/`. | `sources/` | `javadoc/` |
| `Java` | Runs `java` with each predecessor's `classes/`, `resources/` and the jars in `artifacts/` assembled into a class- and module-path; the entry point and command line are supplied by subclasses or `Java.of(...)`. | `classes/`, `resources/`, `artifacts/` | runs `java`; no canonical output |
| `Resolve` | Reads `requires.properties` and (when present) `versions.properties`, asks each prefixed group's `Resolver` for the transitive closure with the version map as a pin set, and writes the resolved coordinates to a fresh `requires.properties` (module-style coordinates pick up a trailing `/` segment when a version is known). Checksums supplied via the `versions.properties` `version checksum` suffix - or via comments in transitive POMs the resolver visits - are propagated as the value for matching resolved coordinates, so the downstream `Download` step can validate them. | `requires.properties`, `versions.properties` | `requires.properties` (transitively resolved, per-prefix `Resolver`) |
| `Download` | Reads `requires.properties` and downloads each coordinate's artifact into `dependencies/`, validating against the recorded checksum where present (mismatch fails the build) and reusing a previous run's file when valid. Coordinates with empty values are downloaded without integrity validation. Writing to `dependencies/` (instead of `artifacts/`) keeps the module's own produced binary separated from its downloaded deps. | `requires.properties` | `dependencies/-.jar`, plus an empty `requires.properties` |
| `Translate` | Rewrites the keys of `requires.properties` (and `versions.properties` when present, with the same translator) through user-supplied per-prefix translator functions (e.g. Java module name → Maven coordinate). | `requires.properties`, `versions.properties` | `requires.properties`, `versions.properties` (keys remapped per-prefix) |
| `Versions` | Walks each predecessor's `classes/`, hard-links every non-`module-info.class` file under `context.next()/classes/`, and rewrites every `module-info.class` so each `requires ` directive gets a `compiledVersion` set from the matching entry in the resolved `requires.properties` (module-style `//` coordinates). Uses the JDK's `java.lang.classfile` API; module flags (`OPEN`), the module's own version, `exports`, `opens`, `uses` and `provides` round-trip unchanged. | `classes/`, `requires.properties` | `classes/` (non-`module-info` hard-linked, `module-info.class` rewritten in-place) |
| `Group` | Reads each predecessor's `identity.properties` and `requires.properties`; for each identified group, writes a `groups/.properties` listing the other groups whose coordinates it depends on. | `identity.properties`, `requires.properties` | `groups/.properties` |
| `Assign` | Fills the empty values of `identity.properties` with paths to the jars in the predecessors' `artifacts/`, finalising the coordinate → file mapping. | `identity.properties`, `artifacts/` | `identity.properties` (empty values filled with artifact paths) |
| `Inventory` | Builds a per-module **launchable and stageable summary**: scans each predecessor for `module.properties` (`path`, `main`, `module`, `test`), `metadata.properties` (`version`), any `artifacts/` subdir (each file goes into `prefix.artifacts`), any `sources/` subdir (each file into `prefix.sources`), any `documentation/` subdir (each file into `prefix.documentation`), any `dependencies/` subdir (each file appended to the runtime classpath), and a top-level `pom.xml` (the generated POM when the layout emits one). Emits one `inventory.properties` with a single-segment prefix (`module` for the root, `module-` otherwise); all path values are self-anchored to the inventory file's folder. See the row in the conventions table above for the recognised keys. | `module.properties`, `metadata.properties`, `artifacts/`, `sources/`, `documentation/`, `dependencies/`, `pom.xml` | `inventory.properties` |
| `DownloadModuleUris` | Fetches the configured remote URL lists and concatenates them into a single `uris.properties`. The default registry is [sormuras/modules](https://github.com/sormuras/modules), a community-maintained map of Java module names to Maven Central jar URLs; its refresh is manual, so a brand-new upstream version may not appear in the registry until the next refresh is published. | none (fetches the configured URLs) | `uris.properties` |
| `MultiProjectDependencies` | Merges per-project `requires.properties` (and looks up sibling-project paths in their `identity.properties`) into one unified `requires.properties`. Sibling-built coordinates are written with empty values (no integrity validation; trust is implicit within the same build); externally-pinned coordinates pass through with their declared checksums intact. | per-predecessor `identity.properties` or `requires.properties`, partitioned by predicate | unified `requires.properties` |
| `Pom` | Emits a Maven `pom.xml`, taking the project's own coordinate from the empty entry in `identity.properties` and its dependencies from `requires.properties` entries that share the same prefix. | `identity.properties` (self coordinate = empty value), `requires.properties` | `pom.xml` |
| `MavenRepositoryStaging` | Per-module inventory walker that stages the contents of every `inventory.properties` it sees into a Maven-repository tree under `context.next()`. For each main module it parses `prefix.pom` for `groupId`/`artifactId`/`version`, validates that `prefix.artifacts` lists exactly one `.jar` and `prefix.sources`/`prefix.documentation` each list at most one `.jar`, then hardlinks the binary plus the (optional) sources/documentation jars plus the pom as `///-.`. For each test module (`prefix.test=`) it routes the jars onto the named main's coordinate with a `-tests` classifier; the test module's POM is parsed for additional dependencies and merged into the staged main POM with `test`. Refuses duplicate main artifactIds and multiple test modules pointing at the same main. | every `inventory.properties` reachable through the predecessors | `///-[-].{jar,pom}` |
| `ModularStaging` | Per-module inventory walker that stages Java-module-named artifacts. For each inventory it reads `prefix.module` (the Java module system module name) and optional `prefix.version`, validates that `prefix.artifacts` lists exactly one `.jar` and `prefix.sources`/`prefix.documentation` each list at most one, then hardlinks them under `/[/]{,-sources,-javadoc}.jar`. Test modules (`prefix.test` set) are skipped by default and emitted under their own Java module system name when `includeTests` is enabled. | every `inventory.properties` reachable through the predecessors | `/[/]{,-sources,-javadoc}.jar` |
| `MavenRepositoryExport` | Publishes a staged Maven-repository tree to an external target path (default `~/.m2/repository`, overridable via the `MAVEN_REPOSITORY_LOCAL` environment variable). Always re-runs (`shouldRun = true`) since the destination is outside the executor's control. Walks each predecessor for `.pom` files, copies every sibling in the version directory into the matching target path with `REPLACE_EXISTING`, then writes the `mvn install`-equivalent metadata: a `maven-metadata-local.xml` per artifact (`` set to the highest non-SNAPSHOT version by Maven semantics, `` sorted ascending, `` timestamp), an `_remote.repositories` marker per version directory, and a `modelVersion="1.1.0"` `maven-metadata-local.xml` inside each `-SNAPSHOT` version directory listing per-extension/classifier ``. | a staged Maven-repository tree (typically `MavenRepositoryStaging`'s output) | files copied under the configured target path; nothing is written under `context.next()` |
| `PinPom` | Reads each predecessor's `versions.properties` and `requires.properties`, filters entries by a configured prefix (typically `maven`), and rewrites the configured `pom.xml` source file(s) so that the `` block lists every entry as a `` (with ``/`` when present) plus a `` comment when the value carries one. Replaces the existing block in place if present, inserts one before `` (or before ``) if absent. Also strips any `` comments from direct `` entries outside ``, since the rewritten BOM is the single source of truth for those checksums. Accepts either a single `Path` or a `List` of pom.xml files to update. Always re-runs (`shouldRun = true`) and writes back to the source file outside `context.next()`. | `versions.properties` and/or `requires.properties` (resolved with-version coords) from each predecessor | none under `context.next()`; mutates the configured `pom.xml` file(s) |
| `PinModuleInfo` | Reads each predecessor's `versions.properties` and `requires.properties`, filters entries by a configured prefix (typically `module`), and rewrites the configured `module-info.java` source file(s) so that the preceding Javadoc block contains `@jenesis.pin [ /]` tags for every entry. Replaces the existing `@jenesis.pin` lines in place if present (preserving other block tags like `@jenesis.release`, `@jenesis.test`); inserts a fresh Javadoc block above the module declaration if none exists. Accepts either a single `Path` or a `List` of module-info.java files. Always re-runs (`shouldRun = true`) and writes back to the source file outside `context.next()`. | `versions.properties` and/or `requires.properties` (resolved with-version coords) from each predecessor | none under `context.next()`; mutates the configured `module-info.java` file(s) |

`ProcessBuildStep` and `Java` are abstract bases (used by `Javac`, `Jar`, `Javadoc`, and the inner `executed`
step that `TestModule` registers); `Java.of(...)` gives an ad-hoc command runner. `DependencyTransformingBuildStep`
is the shared base for `Resolve`, `Download`, and `Translate`; they all parse `requires.properties` into
`(prefix, coordinate)` groups, transform them, and write `requires.properties` back.

Before launching its tool, every `ProcessBuildStep` walks each input folder for `process/.properties`
(where `` is the tool name supplied to the constructor - `java`, `javac`, `jar`, `javadoc`), loads each
folder's file into its own map keyed by argument, and passes the per-folder maps to `process(...)`. Whatever the
subclass leaves behind in those maps is then materialised as command-line tokens prepended to the command line
the subclass produced, in folder order: each entry becomes a `--key value` pair, and `\n` inside a value emits
the same flag once per piece (so a predecessor can write `--add-modules=foo\nbar` to repeat a flag, and the same
key contributed by two predecessors yields two flag instances).

Path-shaped values are stored as paths relative to the file's containing folder and never resolved by
`ProcessBuildStep` itself - keeping the on-disk content position-independent in the same way `requires.properties`
does for coordinates. Resolution is the consumer's job. `Java` does this in its own per-folder iteration: in
addition to scanning each predecessor's `classes/`/`resources/`/`artifacts/`, it pulls `--module-path` and
`--class-path` entries out of that folder's properties map, splits each value on `\n`, resolves each piece against
the same `argument.folder()`, and folds the result into the path lists it ultimately joins with the platform
path-separator into a single `--module-path` / `-classpath` argument. Removing the keys from the per-folder map as
it consumes them keeps `ProcessBuildStep` from also materialising them as repeated flag instances the JVM would
treat as last-wins overrides.

`MavenRepositoryExport` is the one step that intentionally breaks two of the conventions that every other step
holds to. Its job is to publish a build's staged outputs outside the `target/` tree (typically into the user's
local Maven repository), so it cannot honour the "immutable, content-hashed output folder" invariant that drives
incremental builds:

- **Writes outside `context.next()`.** The destination is supplied as a `Path` to the constructor and lives
wherever the user wants it - `~/.m2/repository` by default, otherwise a network share, an existing distribution
layout, or any other target. Files are copied (not hard-linked, since the target may be on a different
filesystem) and `REPLACE_EXISTING` always overwrites whatever is at the destination. `context.next()` itself is
left empty.
- **Always re-runs.** `shouldRun(...)` returns `true`, so even if all inputs are unchanged the export is performed
again. The reason is that the destination is outside the executor's control - anything could have edited or
removed those files between builds - so the only safe assumption is that the export needs redoing every time.
The step's serialized form is still hashed (config-aware cache invalidation still applies), but consistent
predecessor checksums just shorten the diff the step sees, not whether it runs.

`MavenRepositoryExport` consumes a tree already shaped by `MavenRepositoryStaging`. It walks each predecessor for
every `.pom` file, takes the file's parent directory as the version directory of one artifact, and copies every
sibling there (`-[-].{jar,pom}`) into the matching target path. After copying,
the same step writes the `mvn install`-equivalent metadata next to each artifact: a `maven-metadata-local.xml`
per artifact (`` set to the highest non-SNAPSHOT version by Maven semantics, `` sorted
ascending, `` timestamp), an `_remote.repositories` marker per version directory, and a
`modelVersion="1.1.0"` `maven-metadata-local.xml` inside each `-SNAPSHOT` version directory listing per-extension
/classifier ``. Unhandled today: checksum sidecars (`.sha1`/`.md5`), GPG signatures, and
`` version inheritance - the `Pom` step always emits an explicit ``, so the last is fine in
practice for artifacts produced by this build.

Build executor modules
----------------------

The modules listed here are pre-implemented for convenience; the build tool itself does not depend on any of them, and a build is free to ignore them and supply its own `BuildExecutorModule` implementations.

In every diagram below, blue rounded nodes are inputs (folders or files), yellow rectangles are steps, and
purple rectangles are nested sub-modules.

### `JavaModule`

Used for compiling and packaging a single Java module from its sources and its resolved dependencies. Between
compilation and packaging it runs a `Versions` step that consults the compile-scope `requires.properties` and
rewrites every `module-info.class` to embed the resolved versions on each `requires` directive, so the produced
jar carries the same versions that were used to assemble its module path. The record's single `process` flag
chooses between the in-process tool APIs (`Javac.tool()` / `Jar.tool(...)`) and out-of-process invocations
(`Javac.process()` / `Jar.process(...)`); the latter is what `JavaMultiProjectAssembler` selects when
`-Djenesis.java.process=true` is set so the build can run under a stricter sandbox. Running the compiled tests
is not part of `JavaModule` itself - that is wired separately by `JavaMultiProjectAssembler` as a sibling
`TestModule` when the project enables tests and the module is flagged as a test variant (see *`TestModule`*
below).

```mermaid
flowchart LR
classDef input fill:#dbeafe,stroke:#1e40af,color:#1e3a8a;
classDef step fill:#fef3c7,stroke:#92400e,color:#78350f;
src(["sources/"]):::input
arts(["dependency artifacts/"]):::input
req(["dependency requires.properties"]):::input
classes["compiled
(Javac)"]:::step
versions["classes
(Versions)"]:::step
artifacts["artifacts
(Jar, Sort.CLASSES)"]:::step
src --> classes
arts --> classes
classes --> versions
req --> versions
versions --> artifacts
arts --> artifacts
```

### `TestModule`

A `BuildExecutorModule` that runs a configured `TestEngine` (e.g. JUnit 5) against the compiled tests of its
predecessors. Construction requires `repositories` and `resolvers` maps; the runner is fetched on the side via
an inlined `Resolve` → `Download` pipeline, so the user never has to declare it as a compile-time `requires`
of their test module. Empty maps are valid when the runner is already present on the inherited class- or
module-path - the `Requires` step then writes an empty `requires.properties` and nothing is fetched:

- `resolved` (`TestModule.Requires`) writes the runner's coordinate to `requires.properties`, picking the
first entry in `TestEngine.coordinates()` whose `` is served by one of the configured resolvers -
`JUnit5` ships both `module/org.junit.platform.console` and
`maven/org.junit.platform/junit-platform-console/`, so the same engine works across `Modular`,
`ModularToMaven`, and `Manual`-style builds. Versions baked into the coordinate act as defaults; upstream
`versions.properties` (project `dependencyManagement` / `module-info` pins) wins for any matching managed
key during the downstream resolve. If the runner is already visible on an input folder
(`TestEngine.hasRunner(...)`), nothing is written.
- `required` (`Resolve`) takes the runner coordinate *together* with every upstream
`requires.properties` (the project's already-transitively-resolved compile/runtime deps) and runs the
resolve a second time across the combined set. The resolver dedups by coordinate key and negotiates a
single version per key, so a transitive dependency the runner pulls in that the project already resolved
collapses to one entry rather than producing two clashing module-path entries downstream.
- `artifacts` (`Download`) fetches the unified resolved set into `artifacts/`, validating checksums when
present and hard-linking from the local cache when available so the second resolve doesn't re-fetch jars
the project's own resolve already brought down.
- `executed` (`TestModule.Run` extends `Java`) accepts a `filter` argument: a comma-separated list of Java
regex entries, each `` or `#`. When wired by
`JavaMultiProjectAssembler`, the filter is sourced from the `-Djenesis.java.test=` system property;
callers that construct `TestModule` directly pass the filter explicitly. Class entries are emitted via the
engine's `prefix()` (e.g. JUnit 5's `-select-class=`); method entries via `methodPrefix()` (e.g.
`-select-method=`). The filter is part of the step's serialized state (so changing it invalidates the cache);
when set, the step is also forced to re-run regardless of cache consistency. `Java` scans each argument's
`artifacts/` for jars and dispatches them to `--module-path` or `--class-path` based on its own `modular`
flag.

```mermaid
flowchart LR
classDef input fill:#dbeafe,stroke:#1e40af,color:#1e3a8a;
classDef step fill:#fef3c7,stroke:#92400e,color:#78350f;
arts(["inherited classes/
+ artifacts/
+ requires.properties"]):::input
resolved["resolved
(Requires)"]:::step
required["required
(Resolve)"]:::step
artifacts["artifacts
(Download)"]:::step
executed["executed
(Java/Run)"]:::step
arts --> resolved
resolved --> required
arts --> required
required --> artifacts
artifacts --> executed
arts --> executed
```

### `DependenciesModule`

Used for resolving and downloading external dependencies declared in `requires.properties`. The pipeline is
`Resolve` (transitive closure) → `Download` (jars). `Download` validates each downloaded jar against the
checksum recorded in `requires.properties` when one is present (sourced from POM/`module-info` pins, see
the [`requires.properties`](#conventional-folders-and-files) row); coordinates without a pinned checksum
are downloaded without integrity validation.

```mermaid
flowchart LR
classDef input fill:#dbeafe,stroke:#1e40af,color:#1e3a8a;
classDef step fill:#fef3c7,stroke:#92400e,color:#78350f;
deps(["requires.properties"]):::input
resolved["resolved
(Resolve)"]:::step
artifacts["artifacts
(Download)"]:::step
deps --> resolved
resolved --> artifacts
```

### `MultiProjectModule`

Used as the generic shape behind multi-project layouts. An *identifier* sub-module discovers the projects in a
source tree and writes their coordinates and dependencies; a `Group` step partitions the cross-project
dependency graph; a *factory* then assembles one sub-module per discovered project, wiring cross-project edges
between them. Each per-project closure receives a `ModuleDescriptor` exposing `name()` and `dependencies()`; the
concrete subtype (`ModularModuleDescriptor` or `MavenModuleDescriptor`) also exposes the standardised inherited
keys as helpers (`sources()`, `manifests()`, `coordinates()`, `artifacts(DependencyScope)`, `resolved(DependencyScope)`), so a closure doesn't need to know how
the identifier laid out its outputs. The example below shows two projects `A` and `B` where `A` requires `B`,
so `B` is built first and its output flows into `A`.

```mermaid
flowchart LR
classDef input fill:#dbeafe,stroke:#1e40af,color:#1e3a8a;
classDef step fill:#fef3c7,stroke:#92400e,color:#78350f;
classDef module fill:#ede9fe,stroke:#7c3aed,color:#4c1d95;
inh(["inherited inputs"]):::input
subgraph "identifier"
direction TB
idA["module-A
(identifier)"]:::module
idB["module-B
(identifier)"]:::module
end
subgraph "build"
direction LR
group["group
(Group step)"]:::step
subgraph "module"
direction LR
projB["B
(factory output)"]:::module
projA["A
(factory output; requires B)"]:::module
projB --> projA
end
group --> projA
group --> projB
end
inh --> idA
inh --> idB
idA --> group
idB --> group
idA --> projA
idB --> projB
```

### `MavenProject`

Used to drive a build from a Maven project layout. As the identifier inside a `MultiProjectModule`, it mirrors
every `pom.xml` into `pom/`, parses each into a per-module `maven/.properties`, and emits one
`module-X` sub-module per discovered POM containing source folders, optional resource folders, and a
`manifests` step that writes the project's own coordinate (`identity.properties`) and its declared Maven
dependencies (`requires.properties`). Each POM's `` block is captured into the same
manifests step's `versions.properties`, so the resolver sees the project's BOM entries the same way it would
see them if they had been declared in a top-level POM under resolution - pinning applies uniformly to declared
dependencies and to transitives that aren't directly required. A `` element
in the POM is captured by the same manifests step into a `process/javac.properties` sidecar with `--release=`,
which `ProcessBuildStep` forwards to `javac`. A `` comment placed inside any
`` element is parsed as an optional pre-pinned content checksum for that artifact and lands in
`requires.properties` as the dependency's value (instead of empty); the same comment placed inside a
`` `` lands as the optional `version checksum` suffix in
`versions.properties` and is propagated by the resolver to whichever transitive resolves to that coordinate.
`Download` validates non-empty `requires.properties` values against the downloaded bytes and fails the build on
mismatch; coordinates without a pinned checksum are downloaded without integrity validation. There is no
on-the-fly hash computation in the build - validation is opt-in by declaring hashes in source. The same
comment may also be placed inside a `` element or a `` `` with
`import` (a BOM import); when the resolver downloads that referenced POM during resolution,
it streams the bytes through a digest and fails the build if they do not match the pinned hash, so the
integrity story extends to POMs the build pulls in for reference, not just to artifact jars.
`MavenProject.make(...)` returns the full wrapped `MultiProjectModule` whose factory runs
`prepare` (`MultiProjectDependencies`), `dependencies` (`DependenciesModule`: `Resolve` then `Download`),
`build` (caller-supplied, typically `JavaModule`), `assign` (`Assign`), and `inventory` (`Inventory`,
producing the per-module `inventory.properties` consumed by `Execute`) for each project.

```xml

org.junit.jupiter
junit-jupiter
5.11.3
test

```

```mermaid
flowchart LR
classDef input fill:#dbeafe,stroke:#1e40af,color:#1e3a8a;
classDef step fill:#fef3c7,stroke:#92400e,color:#78350f;
classDef module fill:#ede9fe,stroke:#7c3aed,color:#4c1d95;
tree(["project tree
with pom.xml files"]):::input
subgraph "MavenProject (identifier)"
direction LR
scan["scan
(mirrors pom.xml
into pom/)"]:::step
prepare["prepare
(writes maven/*.properties)"]:::step
subgraph "module"
direction LR
idA["module-A
(sources, resources-N,
manifests step)"]:::module
idB["module-B
(sources, resources-N,
manifests step)"]:::module
end
scan --> prepare --> idA
prepare --> idB
end
subgraph "B (per project)"
direction LR
pBprep["prepare
(MultiProjectDependencies)"]:::step
pBdeps["dependencies
(DependenciesModule)"]:::module
pBbuild["build
(caller-supplied)"]:::module
pBassn["assign
(Assign)"]:::step
pBinv["inventory
(Inventory)"]:::step
pBprep --> pBdeps --> pBbuild --> pBassn --> pBinv
end
subgraph "A (per project, requires B)"
direction LR
pAprep["prepare
(MultiProjectDependencies)"]:::step
pAdeps["dependencies
(DependenciesModule)"]:::module
pAbuild["build
(caller-supplied)"]:::module
pAassn["assign
(Assign)"]:::step
pAinv["inventory
(Inventory)"]:::step
pAprep --> pAdeps --> pAbuild --> pAassn --> pAinv
end
tree --> scan
idA --> pAprep
idB --> pBprep
pBassn --> pAprep
```

### `ModularProject`

Used to drive a build from a Java-modular project layout. As the identifier inside a `MultiProjectModule`, it
walks the source tree for `module-info.java` files and emits one sub-module per descriptor, each containing a
`sources` source and a `manifests` step that parses the descriptor and writes `identity.properties` plus
`requires.properties` from the Java `requires` directives. Javadoc tags of the form `@jenesis.pin `
on the module declaration are captured into the same manifests step's `versions.properties` as a BOM-style pin
map - the tag does not have to name a directly-required module, so a transitive can be pinned the same way:

```java
/**
* @jenesis.release 25
* @jenesis.pin org.junit.jupiter 5.11.3
* @jenesis.pin org.junit.platform.commons 1.11.4
*/
open module build.jenesis.test {
requires org.junit.jupiter;
}
```

An `@jenesis.release ` tag on the module declaration (independent of the BOM pins above) is captured by the
manifests step into a `process/javac.properties` sidecar containing `--release=`, which `ProcessBuildStep`
forwards to `javac` when compiling the module.

An optional space-separated `/` after the version on a `@jenesis.pin` Javadoc tag
(e.g. `@jenesis.pin org.junit.jupiter 5.11.3 SHA256/abcdef0123...`) is captured into the same
`versions.properties` value (as `version checksum`); the resolver propagates it to whichever transitive
resolves to that bare module name, so `Download` validates the bytes against it. There is no on-the-fly
hash computation in the build - validation is opt-in by declaring hashes in source.

`ModularProject.make(...)` returns the full wrapped `MultiProjectModule` whose factory runs `prepare`
(`MultiProjectDependencies`), `dependencies` (`DependenciesModule`: `Resolve` then `Download`),
`build` (caller-supplied, typically `JavaModule`), `assign` (`Assign`), and `inventory` (`Inventory`,
producing the per-module `inventory.properties` consumed by `Execute`) for each project.

```mermaid
flowchart LR
classDef input fill:#dbeafe,stroke:#1e40af,color:#1e3a8a;
classDef step fill:#fef3c7,stroke:#92400e,color:#78350f;
classDef module fill:#ede9fe,stroke:#7c3aed,color:#4c1d95;
tree(["project tree
with module-info.java files"]):::input
subgraph "ModularProject (identifier)"
direction LR
idA["module-A
(sources + manifests step
writing identity + requires)"]:::module
idB["module-B
(sources + manifests step
writing identity + requires)"]:::module
end
subgraph "B (per project)"
direction LR
pBprep["prepare
(MultiProjectDependencies)"]:::step
pBdeps["dependencies
(DependenciesModule)"]:::module
pBbuild["build
(caller-supplied)"]:::module
pBassn["assign
(Assign)"]:::step
pBinv["inventory
(Inventory)"]:::step
pBprep --> pBdeps --> pBbuild --> pBassn --> pBinv
end
subgraph "A (per project, requires B)"
direction LR
pAprep["prepare
(MultiProjectDependencies)"]:::step
pAdeps["dependencies
(DependenciesModule)"]:::module
pAbuild["build
(caller-supplied)"]:::module
pAassn["assign
(Assign)"]:::step
pAinv["inventory
(Inventory)"]:::step
pAprep --> pAdeps --> pAbuild --> pAassn --> pAinv
end
tree --> idA
tree --> idB
idA --> pAprep
idB --> pBprep
pBassn --> pAprep
```

### `ExternalModule`

Used for loading a `BuildExecutorModule` from a published modular jar at build time, so a build can pull in
plug-in modules from a repository instead of vendoring them as source. The plug-in must ship as a Java
module that declares `provides build.jenesis.BuildExecutorModule with ...;` in its `module-info.java`.

Given a `/` string, a map of `Repository` instances, and a map of `Resolver` instances,
`ExternalModule` registers three internal nodes:

- `coordinate` writes the requested coordinate (plus any added via `withDependencies(...)`) into a fresh
`requires.properties`.
- `dependencies` is an embedded `DependenciesModule` that resolves + downloads the coordinate's transitive
closure using the supplied repositories and resolvers. The plug-in's own `requires build.jenesis;` is
followed by the resolver, so its copy of Jenesis is fetched alongside its other dependencies.
- `delegate` builds a fresh `ModuleLayer` over the downloaded jars and runs the plug-in's
`BuildExecutorModule.accept(...)` against the same inherited folders `ExternalModule` itself received
(see *Plug-in isolation* below).

`ExternalModule.resolve(...)` hides the three internal nodes from the published output map and strips the
`delegate/` prefix from the delegated module's outputs, so the plug-in's nodes surface under
`ExternalModule`'s registered name. The hidden steps still execute and participate in the cache; only
their published names disappear.

```java
new ExternalModule("module/com.example.plugin", repositories, resolvers)
.withDependencies("module/com.example.extra");
```

### `InternalModule`

Used for loading a `BuildExecutorModule` from a local source folder, so a plug-in can be developed
alongside the project that consumes it without first publishing it to a repository. The source folder
must contain a `module-info.java` that `provides build.jenesis.BuildExecutorModule with ...;` - the
plug-in still has to ship as a Java module, just one built from source instead of pulled from a
repository.

`InternalModule` takes a coordinate `prefix`, a `Path` to the source folder, a `Repository` map, and a
`Resolver` map, and registers:

- `source` binds the source folder as Java sources via `Bind.asSources()`.
- `compile-requires` and `runtime-requires` each parse the source's `module-info.java` and write a
`requires.properties` for the corresponding scope (`requires` for compile, `requires` minus `static` for
runtime). Each entry is keyed `/`, so the resolver under `prefix` can look it up.
Both steps re-run only when `sources/module-info.java` actually changes.
- `compile` and `runtime` are scope-specific `DependenciesModule` instances that download the two
classpaths separately.
- `java` is a `JavaModule` that compiles the source against the compile classpath.
- `delegate` builds a `ModuleLayer` over the compiled jar plus the runtime classpath and runs the
plug-in.

`withDependencies(...)` adds extra coordinates (written verbatim, no prefix added) to both
`requires.properties` files, for plug-ins that need modules not declared in their own `module-info.java`.
The source must declare `requires build.jenesis;` (plus whatever else it uses from Jenesis); the resolver
fetches Jenesis like any other module dependency. `InternalModule` errors at build time if the source
lacks `module-info.java`.

### Plug-in isolation

Both modules load the plug-in into a fresh `ModuleLayer` whose ClassLoader parent is the platform loader,
not the host's application loader. As a result:

- The host's `build.jenesis` classes are invisible to the plug-in. The plug-in pulls in its own copy of
Jenesis via its `requires build.jenesis;` declaration; the resolver downloads it like any other
module. The two copies are different `Class` instances in different loaders.
- Two plug-ins with conflicting transitive dependencies do not clash, because they each get their own
layer with their own copy of every non-platform module.

Because the host can't simply call methods on the loaded plug-in (the types live in a different loader),
`JenesisClassLoaderBridge` mediates: it creates a `java.lang.reflect.Proxy` implementing the *foreign*
`BuildExecutor` and hands that proxy to the plug-in's `accept(...)`. As the plug-in calls `addStep`,
`addModule`, etc. on the proxy, the bridge:

- Forwards default-method calls through `InvocationHandler.invokeDefault`, so the abstract overloads are
all the bridge has to special-case.
- Wraps each `BuildStep` argument as a host `BuildStep` that, on `apply(...)`, translates the host's
`BuildStepContext` / `BuildStepArgument` into foreign records (via `MethodHandle`s bound to the foreign
record constructors), invokes the foreign step's `apply` via `MethodHandle`, and translates the foreign
`BuildStepResult` back. `ChecksumStatus` enum values are mapped across loaders by name.
- Wraps each `BuildExecutorModule` argument as a host `BuildExecutorModule` that recursively re-enters
the same bridge when invoked, so plug-ins can register nested modules.
- Passes everything else (Strings, `Path`s, `SequencedMap`,
`Function>`) through unchanged - those types l