jzap architecture: build-tool-agnostic core¶
Decision: the engine knows nothing about Gradle, Maven, or Bazel. Build tools are thin adapters whose only job is to compute a project model and hand it to the engine. Gradle and Maven integrations come after the core is usable from the CLI.
This is the proven shape for this problem. PIT splits into pitest (core),
pitest-entry, and
pitest-command-line
(a main taking --targetClasses, --targetTests, --sourceDirs, --reportDir, and
extra classpath entries), with pitest-maven as a mojo wrapper and
gradle-pitest-plugin forking a JVM
that runs that CLI. The Gradle plugin computes taskClasspath, mutableCodePaths, and
sourceDirs automatically, and passes the classpath as a file by default so Windows
command-line length limits don't bite. We take the same seam and make it explicit and
versioned rather than implicit in a CLI's argument list.
Module layout¶
Nine modules with production code, in dependency order. Nothing below depends on anything
above it, and ModuleBoundariesTest asserts that against the imports rather than against the
build files, so the shape cannot drift one hurried import at a time.
jzap-model the project model and result schemas: the single seam. Depends on
nothing, and does not export Jackson -- a data module that pinned
every consumer to a serialiser's version would be a poor seam.
jzap-wire the controller/minion protocol, and the framing under it.
jzap-agent the java agent: class overrides, coverage probes, the loop guard,
and the schemata dispatch methods.
Both are dependency-free on purpose. They are loaded into the JVM
running the user's tests, where anything they dragged in could clash
with the project's own dependencies, and a test enforces it by
walking the agent jar's constant pool.
jzap-minion the forked JVM that runs the user's tests. Speaks the wire, uses the
agent, and knows nothing of the engine -- which is what lets the
engine treat it as a process to be killed.
jzap-core the engine: discovery, mutators, schemata, coverage, scheduling, the
cache, and the controller's half of the protocol. Reaches the agent
for compile-time constants only, and never git or reporting.
jzap-git a diff to changed line ranges, via JGit.
jzap-report console, jzap's own JSON, the mutation-testing-elements schema, HTML,
and PR annotations.
Siblings of the engine rather than layers of it: both see only the
model. That is what lets a CI system hand the engine a patch file
instead of a repository, and keeps reports independent of how
analysis works.
jzap-cli the composition root, and the only module that knows all of them.
Also hosts the resident daemon, which reuses the wire's framing.
jzap-gradle adapter: source sets, configurations, toolchains -> model.
jzap-maven adapter: reactor, dependency resolution -> model.
Neither links the engine; both fork the CLI. That keeps ASM off a
buildscript classpath and lets the engine's version move
independently of the plugin's.
How much each module exposes¶
The rule is that a type is public only when something outside its own package needs it.
jzap-core is nine public types of thirty-six; jzap-cli exposes one, its Main. Where a
number looks high it is because the module is an interface: jzap-model is twelve of twelve,
since the vocabulary is the whole point, and jzap-agent is seven of eight because instrumented
bytecode calls its statics directly.
jzap-core deliberately keeps thirty-six classes in one package rather than splitting into
cache, bytecode, engine and filter. In Java that trade goes the wrong way: package-private
is the only real encapsulation, so the split would force six of those types public to gain four
directories. The mutator subpackage exists because it is the one boundary with a real contract.
See that package's package-info.java for the grouping and the reasoning.
The seam: a project model¶
One versioned JSON document that any adapter can emit and the CLI/daemon consumes:
{
"schemaVersion": 1,
"modules": [{
"id": ":service:orders",
"mutableCodePaths": ["build/classes/kotlin/main", "build/classes/java/main"],
"sourceRoots": ["src/main/kotlin", "src/main/java"],
"testClassPaths": ["build/classes/kotlin/test"],
"testClasspath": ["...jars and dirs..."],
"analysisClasspath":["...jars and dirs..."],
"modulePath": [],
"javaHome": "/opt/jdk-21",
"jvmArgs": ["-Xmx2g"],
"kotlinVersion": "2.2.0"
}],
"scope": { "kind": "diff", "from": "origin/main", "to": "-Local-", "granularity": "line" },
"cache": { "dir": ".jzap/cache", "shared": false },
"reporters": ["json", "gitci"]
}
Why a file rather than flags:
- Adapters stay dumb and testable. The Gradle plugin's whole job becomes "produce this document", which is unit-testable without running a mutation analysis.
- Bug reports become reproducible: attach the model, run the CLI, done. No Gradle in the loop.
- Model version is independent of engine version, so users can pin a newer engine under
an older plugin — the way
pitestVersionworks for gradle-pitest-plugin. - Sidesteps command-line length limits by construction.
Multi-module in one run. The model takes a list of modules deliberately. One invocation covering the whole reactor lets the daemon warm once and lets cross-module test selection work — the thing that is only partially supported in PIT, and only with explicit configuration, since 1.17.1. Gradle's per-project task model pushes the other way (one task per subproject); the adapter should therefore offer both a per-project task and an aggregate root task that emits one multi-module model. Deciding this late would force a rewrite of the scheduler, so it belongs in the model from day one.
What genuinely leaks, and where it's allowed to¶
Four things resist abstraction. All four live entirely in the adapters:
- Classpath and source-set computation. Gradle source sets, configurations, test fixtures, Android variants, Maven reactor ordering and scopes, shaded jars, JPMS module path versus classpath. This is where nearly all real-world pain lives, and none of it belongs in the core.
- Toolchain selection. Tests must run on the JVM the build chose. The adapter
resolves it and puts
javaHomein the model; the core just obeys. - Up-to-date checks and build caching. Gradle wants declared inputs/outputs on the task; Maven wants nothing. The adapter declares them. The corollary is a hard requirement on the core: identical model plus identical class bytecode must produce identical results, or Gradle's cache will lie. That rules out timing-dependent behaviour leaking into reported results (see risks).
- Daemon lifecycle ownership. See below.
Daemon ownership is the real decision¶
The warm-JVM daemon must be owned by jzap, not by the build tool — keyed by a hash of (module set, classpath, toolchain, engine version), reachable over a local socket, with an idle timeout.
If the daemon were tied to the Gradle daemon, Maven users (no daemon) and CI (cold every time) would get none of the speedup, and the "ultra fast" premise would only hold for one build tool. Owning it means the CLI, both build plugins, IDE runs, and a future file-watcher all hit the same warm process and share the same coverage cache.
Where the core is allowed to be opinionated¶
- Test execution goes through the JUnit Platform launcher, not a build tool's test task. Adapters pass the test classpath; the core discovers and selects individual tests itself, which is a prerequisite for both per-test coverage and kill-test-first ordering.
- Git is a plugin (
jzap-git), not a dependency of the core. The core only understands "these line ranges in these files are in scope". That keeps diff scoping usable from a CI system that hands over a patch file, and keeps the core testable without a repo. - Engine dependencies must be shaded/relocated. PIT relocates its minion dependencies to avoid clashing with the user's; anything sharing a classloader with user code has the same obligation.
Build order¶
jzap-model+jzap-core+jzap-cli. Nothing else can be validated until the engine runs from a hand-written model JSON. Write the model schema first; it is the contract every later module codes against.jzap-git+jzap-report. Diff scoping and PR-shaped output are the product, not a feature. Without them there is no reason to prefer jzap over PIT.jzap-gradle. First, because Kotlin lives on Gradle and because free line-level diff mutation testing does not exist there today (gradle-pitest-plugin #165). That is the underserved audience.jzap-maven. Easier of the two, and PIT already serves those users acceptably viascmMutationCoverage, so the marginal gain is smaller. Do it second.jzap-kotlincompiler-plugin frontend and Bazel support after the above, both strictly optional. The compiler plugin is the one piece that would breach the seam (it has to be wired into compile tasks), so it must degrade to bytecode+filters when absent.
Risks this structure creates¶
- Determinism versus timeouts. Hang detection based on wall-clock timings makes results non-deterministic, which breaks Gradle caching. Mitigation: use hit-count probes as the primary infinite-loop signal and report timeouts as a distinct, non-cached status rather than folding them into killed/survived.
- Model drift. Two adapters computing "the classpath" slightly differently produces bugs that look like engine bugs. Mitigation: a shared conformance test suite that runs the same fixture project through each adapter and diffs the emitted models.
- Over-abstraction. An SPI for every seam costs more than it returns. Keep extension points to exactly three: mutators, test frameworks, reporters.
- Daemon staleness. A warm JVM holding stale classes is the classic source of phantom results. Mitigation: the daemon keys its state on class bytecode hashes and drops any module whose hash moved, rather than trying to patch in place.
See prior-art.md for the research these choices rest on.
Decision records¶
Delivery¶
See delivery-plan.md for the delivery plan and parity-and-benchmarks.md for the PIT comparison harness that gates it.