Status: interface spec, plus a working first pass of the CLI (see Current Implementation).
Lets a user select the exact ApprovalTests entry points they use and build a custom jar that contains only the code reachable from those entry points. Everything else is removed by Tree Shaking.
Users want a trimmed ApprovalTests for any of these reasons:
- Smaller jar: less footprint.
- Fewer dependency conflicts: optional third-party libraries (Gson, Jackson, etc.) are only needed if a selected entry point uses them.
- Smaller audit surface: less code to review for security or license compliance.
- Constrained runtimes: Android, GraalVM native-image, and similar environments where unused code causes problems.
- Develop against the full ApprovalTests jar, as usual.
- Release with the partial jar. The CLI runs at release time, to build a jar containing only what your selection uses.
Discoverability is not a goal of the partial jar. Unused code is simply deleted, not replaced with helpful errors. While developing you have the full jar, so everything is available.
The feature has two parts. They are connected by a single selection config file:
[Selector Page] --writes--> approvaltests-selection.json --read by--> [CLI] --builds--> custom jar (+ pom, + sources)
- The Selector Page is a static web page. It does not build anything.
- The CLI runs locally and does the build.
- The config file is meant to be committed to the user's repo, so the jar can be rebuilt (for example, after upgrading ApprovalTests).
A specific public method overload, identified by its fully qualified signature:
org.approvaltests.Approvals.verify(java.lang.String)
org.approvaltests.Approvals.verify(java.lang.Object, org.approvaltests.core.Options)
org.approvaltests.JsonApprovals.verifyAsJson(java.lang.Object)
Selection is always at the method/overload level. Selecting Approvals.verify(String) does not select Approvals.verify(Object).
A public static field, a class, or a constructor can also be an entry point:
org.approvaltests.reporters.DiffReporter.INSTANCE a field (no parentheses)
org.approvaltests.reporters.UseReporter a class (keeps the class, its fields and constructors)
org.approvaltests.core.Options.Options() a constructor (named like the class)
Classes matter because Java needs every imported or declared type to exist, even if none of its methods are called.
Generic parameters are written as their erasure, e.g. verifyAll(java.lang.String, java.lang.Object[]) for verifyAll(String label, T[] array).
Options methods (scrubbers, file extensions, reporters, etc.) are ordinary entry points too:
org.approvaltests.core.Options.withScrubber(org.approvaltests.core.Scrubber)
org.approvaltests.core.Options.forFile()
Code is removed by Tree Shaking:
- Starting from the selected entry points (and the runtime discoveries set to
keeporremove), find everything that is reachable. - Delete every method and class that is not reachable, including public methods on the API classes. Unselected entry points are deleted, not stubbed.
- Third-party dependencies that only the deleted code used go away with it.
Some behavior is triggered by the environment, reflection, or configuration, not by a direct call. Examples:
- the
CIenvironment variable - default reporter auto-detection, and each diff tool it can find
- the
PackageSettingslookup - other reflectively loaded pieces (namers, writers, etc.) as they are identified
Every runtime discovery is set explicitly to one of three modes. A discovery that is missing from the config is treated as remove.
The key "*" sets the mode for every discovery at once, e.g. "*": "keep".
| Mode | Detection code | When the discovery is triggered |
|---|---|---|
keep |
Kept | It works normally. |
remove |
Kept | It throws a RuntimeException with a message on how to enable it. |
ignore |
Tree-shaken out | Nothing happens. The code is gone, so it behaves as if the discovery was never triggered. |
Example: the CI environment variable
- keep: it works.
- remove: it still checks, and throws an exception if
CIis set in the environment. - ignore: it doesn't check and doesn't care whether
CIis set. That code is gone.
The partial jar keeps the same package names, class names and signatures for everything it contains. It is a drop-in replacement only for code that uses what was selected.
Anything not selected is simply gone:
- Code that calls it fails to compile against the partial jar.
- Code already compiled against the full jar fails with
NoSuchMethodError/NoClassDefFoundError.
So the selection must cover everything your code calls. Run your tests against the partial jar before you release. The codebase detection tool is meant to make the selection complete automatically.
A runtime discovery set to remove still throws a RuntimeException that explains how to enable it:
Runtime discovery "environment.CI" is set to remove in this partial evaluation of ApprovalTests, and the CI environment variable is set.
To enable it, set it in "runtimeDiscoveries" in approvaltests-selection.json and rebuild:
"environment.CI": "keep"
Or use "ignore" to not check for it at all.
- It lists every available entry point, grouped by class (
Approvals,JsonApprovals,CombinationApprovals,Options, ...). - It lists runtime discoveries in their own section. Each one has a
keep/remove/ignorechoice, and the default isremove. - It has search and filter by name.
- Selecting a method shows what else it pulls in: the classes it reaches and any third-party dependencies (e.g. "requires Gson").
- It shows a running summary of the selection: number of entry points, estimated size, and required third-party dependencies.
- It can load an existing config file to edit it.
- It can export the config file (download or copy).
- The list of entry points is generated per ApprovalTests version.
Default name: approvaltests-selection.json
{
"approvaltestsVersion": "31.0.0",
"entryPoints": [
"org.approvaltests.Approvals.verify(java.lang.String)",
"org.approvaltests.Approvals.verify(java.lang.Object, org.approvaltests.core.Options)",
"org.approvaltests.JsonApprovals.verifyAsJson(java.lang.Object)",
"org.approvaltests.core.Options.withScrubber(org.approvaltests.core.Scrubber)"
],
"runtimeDiscoveries": {
"environment.CI": "keep",
"packageSettings": "keep",
"reporters.autoDetection": "keep",
"reporters.DiffReporter": "keep",
"reporters.QuietReporter": "keep",
"reporters.IntelliJReporter": "ignore"
},
"output": {
"directory": "build/approvaltests-custom",
"version": "31.0.0-custom",
"pom": true,
"sources": true,
"install": "none"
}
}The CLI is a standalone executable jar. It is not a Maven or Gradle plugin.
The CLI does not bundle ApprovalTests. For each run it:
- Gets a checkout of
https://github.com/approvals/ApprovalTests.Java.gitat.local/approvaltests-<version>/:- If that folder doesn't exist yet, it shallow-clones the tag that matches
approvaltestsVersion(tags are plain version numbers, e.g.31.0.0). - If it already exists, it fetches the tag, then force-checks it out,
git reset --hardandgit clean. It does not clone again. - With
--current, it uses the latestmasterinstead of the tag, at.local/approvaltests-master/. The report shows the commit, e.g.master@63044b7, and the jar is namedapprovaltests-master-custom.jar.
- If that folder doesn't exist yet, it shallow-clones the tag that matches
- Applies Tree Shaking to that checkout. The checkout is kept, so
git -C .local/approvaltests-<version> diffshows exactly what changed. - Builds the custom jar from that checkout.
Because of this, one CLI can build any ApprovalTests version, and the config version never has to match the CLI's version.
approvaltests-minimizer build --config approvaltests-selection.json [output flags]
approvaltests-minimizer list [--version 31.0.0] [--format text|json]
approvaltests-minimizer explain --config approvaltests-selection.json [--entry-point <signature>]
- build: Reads the config file and produces the custom jar using Tree Shaking.
- list: Prints all available entry points and runtime discoveries for a version. The Selector Page is built from the same data.
- explain: Shows why each class or dependency is included, i.e. which entry point reaches it. Useful for audits and for trimming a selection further.
Output is configurable through flags or the output section of the config file. Flags override the config file.
| Flag | Description |
|---|---|
--output <dir> |
Output directory |
--artifact-version <v> |
Version string for the custom artifact (default <approvaltestsVersion>-custom) |
--pom / --no-pom |
Generate a POM that lists only the third-party dependencies actually needed |
--sources / --no-sources |
Also produce a -sources.jar for the included code |
--install none|maven-local |
Optionally install into the local Maven repository |
All outputs go into output.directory, which is resolved relative to the config file.
approvaltests-<version>.jar:approvaltestsandapprovaltests-utilmerged into one jar. As in the regular ApprovalTests jar, the.javasource files for the included classes sit next to the.classfiles.approvaltests-<version>.pom(optional)approvaltests-<version>-sources.jar(optional)approvaltests-<version>-report.html: an HTML report comparing the partial build to the original (see Report).
The HTML report contains:
- Summary: source files kept / deleted / changed / untouched, lines of source kept, compiled
.classfiles and bytes, jar size, and third-party packages still imported. Each one is compared to the original. - Inputs: the selection file and its location, the ApprovalTests version and tag, the selected entry points in readable form (
Approvals.verify(String)), the runtime discoveries with what each mode means, the output location, and the raw JSON. - By module and By package: files and lines before and after, with a kept/removed bar.
- Third-party dependencies: each third-party package imported by the original, and whether it's still referenced.
- Changed files: every modified file, sorted by lines removed, with an expandable diff against the original.
- Deleted files: grouped by package.
Lives in the ApprovalTests.Java.Minimizer repo, a standalone Maven project (Java 17, artifact com.approvaltests:approvaltests-minimizer). It started as the approvaltests-partial module in ApprovalTests.Java.
From your project folder:
path/to/ApprovalTests.Java.Minimizer/CreateSelectionJson.sh [approvaltests-selection.json]It scans every .java file from the current folder down and writes the selection file.
- Skipped folders:
target,build,out,.git,.local,.idea,.gradleandnode_modules. - Classpath: it needs a
pom.xml. It runsmvn dependency:build-classpath(test scope) to get the ApprovalTests jars and the project's other jars for the symbol solver.approvaltestsVersioncomes from theapprovaltestsjar on that classpath. - What counts as usage (anything in
org.approvaltests,com.spun,org.lambdaororg.packagesettings):- method calls, method references and constructor calls, resolved to their exact overload
- static field reads, e.g.
DiffReporter.INSTANCE - every type used in a declaration, and every import (including unused ones)
- Existing file: it keeps
runtimeDiscoveriesandoutput, and replacesapprovaltestsVersionandentryPoints. A new file gets"*": "keep"and default output settings. - Warnings: usages it can't resolve are printed under "Could not resolve", to check by hand.
For the StarterProject it finds 9 entry points in about 3 seconds, and the jar built from them passes the StarterProject's tests.
From your project folder (the one with approvaltests-selection.json):
path/to/ApprovalTests.Java.Minimizer/CreatePartailApprovals.sh [--current] [approvaltests-selection.json]
path/to/ApprovalTests.Java.Minimizer/CreatePartailApprovals.sh explain [--current] <class or package prefix> [approvaltests-selection.json]- The config file defaults to
approvaltests-selection.jsonin the current folder. - The script compiles the CLI on every run (about 3 seconds), so it never runs a stale build.
--currentbuilds from the latestmasteron GitHub instead of theapprovaltestsVersiontag. Use it to try changes to ApprovalTests before they are released.explainprints, for every kept class matching the prefix, the chain of reasons back to an entry point.- All projects share checkouts in this repo's
.local/folder (set with-Dapprovaltests.partial.local=<dir>), so no.local/folder appears in your project. list, the output flags, and thepom/sources/installsettings are not implemented yet.
Build and test the tool itself with ./build_and_test.sh.
- Check out the tag (clone, or fetch and reset).
- Build the original
approvaltests-utilandapprovaltestsjars, to get the baseline for the report. - Snapshot the original sources, also for the report.
- Tree Shaking (
TreeShaker). - Build again with
-Dmaven.test.skip=true, since the shaken code no longer supports the repo's own tests. - Merge both jars into
approvaltests-<version>.jar(JarBuilder), and write the HTML report (HtmlReport).
For the StarterProject (Approvals.verify(String), JsonApprovals.verifyAsJson(Object) and DiffReporter.INSTANCE), a run takes about 13 seconds. The result is 231 of 448 files, 44% of the source lines and a 451 KB jar (48% of the original 948 KB), and the shaken code compiles.
Tree Shaking works on the source code, using JavaParser and its symbol solver. Runtime discoveries are all treated as keep for now.
Mark (find what's reachable, starting from the entry points):
- Method calls are resolved with the symbol solver. If a call can't be resolved (e.g. an argument comes from a third-party library), every method with that name and number of arguments is kept, to be safe. This is limited to the class the call names, e.g.
Approvals.verify(...)only keepsApprovals.verifyoverloads. A call with no class prefix uses the enclosing classes and static imports. If nothing matches there, it falls back to keeping by name everywhere. - When a call passes a lambda or method reference, every overload with the same name and number of arguments in that class is kept. The symbol solver sometimes picks the wrong overload for lambdas.
- Any type referenced from reachable code is kept, along with all its fields, initializers and constructors.
- Instance methods of kept types are kept if they are abstract,
@Override, implement an inherited abstract method, have a name that is called anywhere, or belong to a type with a third-party supertype. - A string literal that equals one of our fully qualified class names keeps that class. This is the stand-in for reflection.
Sweep (remove what isn't reachable):
- Files with no kept types are deleted.
- Every unreached method and nested type is deleted, including unselected public methods on API classes like
Approvals. Nothing is stubbed. - Imports pointing at removed types or members are removed.
- Files are rewritten with formatting preserved, so the diff stays minimal.
- Too many dependencies are kept. Of 16 third-party packages, 9 are still imported (Gson, servlet, Apache, JUnit, Quartz, SLF4J, ...). This comes from the safe choices above: unresolved calls kept by name, and all fields and constructors of kept classes kept.
- Computed class names are missed. Reflection by class names built at runtime (e.g. reporters from
APPROVAL_TESTS_USE_REPORTER) isn't detected. - Runtime behavior is untested. The partial jar compiles but hasn't been run against the StarterProject's tests yet.
- Codebase detection tool: a first version exists (
CreateSelectionJson.sh, see Creating the Selection File). Still to do: Gradle projects, pre-filling the Selector Page, and detecting runtime discoveries.