diff --git a/README.md b/README.md
index 2eb1c23..928c2a4 100644
--- a/README.md
+++ b/README.md
@@ -253,3 +253,58 @@ Only successful `main` runs publish images to the Synology registry. See
[the CI management guide](docs/gitea-ci.md) for runner setup, registry secrets,
image names, diagnostics and recovery, and [validation evidence](docs/s4-validation.md)
for the current operational status.
+
+### Java formatting (S8)
+
+Maven is the authoritative formatting path. The root build runs the actual
+Palantir Java Format **2.101.0**, Palantir style, through a custom OpenRewrite
+**8.63.4** text recipe in `tooling/format/Format.java`. The pinned
+`exec-maven-plugin` **3.6.3** launches that helper with JDK 25. Native import
+ordering and unused-import removal are enabled; extra Javadoc formatting is off.
+No OpenRewrite AutoFormat or additional refactoring recipes run.
+
+From the repository root:
+
+```sh
+mvn -N exec:exec@format # apply, without application compilation
+mvn -N exec:exec@format -Dformat.mode=check # check, without application compilation
+mvn compile # applies during validate, before compilation
+mvn -Dformat.mode=check clean verify # CI: fails before compilation on differences
+```
+
+Root `compile`, `test`, `verify` and `install` may rewrite maintained Java files
+in the validate phase. CI's Java orchestration passes `-Dformat.mode=check` and
+never repairs source files. On failure, the output lists paths and the apply
+command. Apply locally, inspect and commit the result, then repeat the check.
+Run root Maven commands; selecting only the backend with `-pl agiledancers-be`
+bypasses the root formatting execution.
+
+The scope is every `.java` file recursively under `agiledancers-be/src/main/java`,
+`agiledancers-be/src/test/java`, `ci` (including the runner helper), and the
+formatter helper itself. Generated `target` files and frontend files are excluded.
+The adapter keeps paths and UTF-8 content, computes changes before writing, and
+uses atomic per-file replacements. Unsupported syntax fails with its path/cause.
+The source launcher compiles the adapter directly in memory from a clean checkout;
+no custom recipe jar needs compiling or installing beforehand. Maven downloads
+only pinned external tooling dependencies on first use.
+
+Successfully checked/formatted content hashes are cached in
+`target/palantir-cache.properties`; new or changed files are always processed.
+The root POM, adapter source, dependency classpath and JDK version invalidate the cache. `clean` removes
+it. Check and apply share the engine, options, scope and successful-content cache;
+a failing check does not write sources or update the cache. For controlled timing
+comparisons only, `-Dformat.skip=true` disables the execution.
+
+Editors are optional. Palantir supplies an IntelliJ plugin (see the
+[upstream instructions](https://github.com/palantir/palantir-java-format#intellij-plugin)).
+Use it only with the same engine version/style and Javadoc setting; if the editor
+cannot match them, use Maven to format instead. Editor import optimization or
+format-on-save must not override Maven's result. Other editors can invoke the
+standalone Maven apply command as an external tool.
+
+Verification and measurements are recorded in
+[the S8 evidence](specs/S8-formatting-evidence.md). Reproduce the isolated failure,
+UTF-8, import, cache and idempotence checks with
+`python3 tooling/format/test.py`, and the sequential end-to-end build measurements
+with `python3 tooling/format/benchmark.py` (requires the ordinary backend test
+prerequisites). Results are written under `target/`.
diff --git a/ci/CiTests.java b/ci/CiTests.java
index f1be880..adfc81a 100644
--- a/ci/CiTests.java
+++ b/ci/CiTests.java
@@ -98,6 +98,8 @@ class CiTests {
f.fake.mavenFailure = true;
try { f.pipeline.verify(); throw new AssertionError("Expected failure"); }
catch (Commands.Failure error) { equal(42,error.exitCode); }
+ equal(List.of("mvn", "-B", "-ntp", "-Dformat.mode=check", "clean", "verify"),
+ f.fake.calls.stream().filter(a -> a.getFirst().equals("mvn")).findFirst().orElseThrow());
equal(false,Files.exists(f.root.resolve("ci/artifacts/verified-commit")));
equal(0L,f.calls("build")); equal(0L,f.calls("push"));
}
diff --git a/ci/Pipeline.java b/ci/Pipeline.java
index f265fe8..a26a91a 100644
--- a/ci/Pipeline.java
+++ b/ci/Pipeline.java
@@ -66,7 +66,7 @@ class Pipeline {
try {
requirePlatform(); isolateDocker();
check(commit.equals(capture("git", "rev-parse", "HEAD")), "Checkout does not match pushed commit");
- run("java", "-version"); run("node", "--version"); run("mvn", "-B", "-ntp", "clean", "verify");
+ run("java", "-version"); run("node", "--version"); run("mvn", "-B", "-ntp", "-Dformat.mode=check", "clean", "verify");
CiTests.run();
Path frontend = root.resolve("agiledancers-fe");
verifyFrontend();
diff --git a/pom.xml b/pom.xml
index bdd4768..22ca652 100644
--- a/pom.xml
+++ b/pom.xml
@@ -24,6 +24,11 @@
UTF-8
3.40.1
+ apply
+ false
+ 2.101.0
+ 8.63.4
+
9.15.6755
1.18.48
@@ -77,6 +82,50 @@
+
+ org.codehaus.mojo
+ exec-maven-plugin
+ 3.6.3
+ false
+
+
+ format
+ validate
+ exec
+
+
+
+ ${format.skip}
+ ${java.home}/bin/java
+ false
+ true
+
+ --add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED
+ --add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED
+ --add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED
+ --add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED
+ --add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
+ --add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED
+ --class-path
+ ${maven.multiModuleProjectDirectory}/tooling/format/Format.java
+ ${maven.multiModuleProjectDirectory}
+ ${format.mode}
+
+
+
+
+ com.palantir.javaformat
+ palantir-java-format
+ ${palantir-java-format.version}
+
+
+ org.openrewrite
+ rewrite-core
+ ${rewrite-core.version}
+
+
+
+
org.apache.maven.plugins
maven-compiler-plugin
diff --git a/specs/S8-formatting-evidence.md b/specs/S8-formatting-evidence.md
new file mode 100644
index 0000000..0725125
--- /dev/null
+++ b/specs/S8-formatting-evidence.md
@@ -0,0 +1,96 @@
+# S8 implementation evidence
+
+Measured on 2026-10-30 on Linux x86_64 (kernel 7.0.0-38-generic), AMD Ryzen 7
+7730U, 16 logical CPUs; Ubuntu OpenJDK 25.0.4.1, Maven 3.9.16. These are local
+wall-clock measurements, not universal performance guarantees.
+
+## Integration and scope
+
+- exec-maven-plugin 3.6.3; OpenRewrite core 8.63.4; Palantir Java Format 2.101.0.
+- Custom `Format.PalantirRecipe` processes OpenRewrite PlainText source files
+ using Palantir's `formatSourceAndFixImports`, Palantir style, `formatJavadoc(false)`.
+ No Java type attribution, OpenRewrite Java parser or AutoFormat is involved.
+- 73 maintained Java files: 65 backend production/test files, 7 CI/runner helpers,
+ and the formatter helper. Recursive discovery excludes generated build output.
+- Root validate executes before compilation; compile/test/verify/install all reach
+ validate. CI explicitly passes check mode to clean verify. Standalone commands
+ run only the root exec execution and do not compile the backend.
+- Bootstrap was first executed with neither Palantir nor OpenRewrite in the local
+ Maven repository. The helper is source-launched, with dependencies supplied by
+ Maven's plugin classpath. There is no project recipe artifact to preinstall.
+ The isolated regression fixture also has no compiled adapter or backend module.
+
+## Correctness
+
+- Full `mvn -B -ntp clean verify` passed after formatting: 33 backend tests,
+ zero failures/errors/skips. Both benchmark clean verifications also pass.
+- `java ci/Ci.java test`: 17 passed, including assertion of the exact check-only
+ Maven invocation and that Maven failure removes the success marker and prevents
+ image build/publication.
+- `python3 tooling/format/test.py` checks an isolated source-launcher checkout:
+ read-only check failure with path and apply command; native unused-import
+ removal; UTF-8 non-ASCII preservation; check/apply agreement; second uncached
+ check with no differences; malformed Java fails with its path and leaves the
+ source bytes unchanged; a newly introduced file cannot evade the content cache.
+- All current Java files have been processed by the real engine on JDK 25,
+ including records, switch expressions/multiple labels, pattern instanceof,
+ lambdas and source-launcher CI classes. Tests compile/run the resulting sources.
+ This demonstrates the syntax used here, not every possible Java 25 construct.
+- Palantir uses javac internals (explicit JVM exports are in the root POM) and its
+ modern AST visitor. Recheck formatter compatibility when upgrading the JDK.
+ Compact source files / instance main methods are a known upstream limitation
+ ([upstream issue](https://github.com/palantir/palantir-java-format/issues/1602));
+ they are not used here. Unsupported future syntax fails, rather than being
+ silently excluded. Extra Markdown/Javadoc formatting is disabled.
+- The initial Java rewrite is committed separately from integration changes;
+ its only transformations are the formatter's native output and import handling.
+
+## Performance
+
+`python3 tooling/format/benchmark.py` runs sequentially, with normal dependencies
+and OS filesystem caches warm. Each configuration receives a compile warmup.
+The baseline temporarily removes the entire exec plugin declaration from the
+root POM, then restores its original bytes before the formatting build. Therefore
+these comparisons include plugin loading, dependency resolution, JVM launch,
+source-launcher compilation, hashing, cache IO and recipe execution. They do not
+compare only the inner formatter function or a skipped plugin against itself.
+
+The successful-content cache is warm for unchanged builds; it is part of the
+integration. Changed/new files are processed by Palantir. Clean builds remove the
+cache and process every maintained Java file. Each run's stdout and elapsed time
+are saved under `target/`; the benchmark restores the root POM and changed helper.
+
+| Repeated compile | Baseline (s) | Integration (s) | Added overhead (s) |
+| --- | ---: | ---: | ---: |
+| 1 | 4.887 | 6.414 | 1.527 |
+| 2 | 4.821 | 5.964 | 1.143 |
+| 3 | 4.559 | 6.024 | 1.465 |
+
+Maximum observed added overhead: **1.527 seconds**, below the agreed three-second
+limit. Baseline compile warmup: 5.307 s; integration warmup: 7.604 s.
+
+The changed-file case appends a newline to `ci/runner/WaitForDocker.java`, then
+runs `mvn -B -ntp compile`: **5.506 s**, successful; Palantir processes that
+changed file. The script restores its original bytes afterward.
+
+Clean baseline `mvn -B -ntp clean verify`: **24.797 s**. Clean integration
+`mvn -B -ntp -Dformat.mode=check clean verify`: **27.747 s**, all 73 files
+processed, no differences, all tests pass. The three-second criterion applies
+to repeated unchanged builds, not the clean-build comparison.
+
+### First-use downloads (separate measurement)
+
+A disposable root POM with the same pinned plugin/dependencies and an empty
+`-Dmaven.repo.local` replaces only the Java executable with `/usr/bin/true`.
+Thus dependency resolution runs without compiling the launcher or executing the
+formatter/application. Command:
+`mvn -N -B -ntp -Dmaven.repo.local= org.codehaus.mojo:exec-maven-plugin:3.6.3:exec@format`.
+Cold resolution took **9.041 s**; the same command with that repository populated
+took **1.826 s**. The **7.215 s** cold-versus-warm difference represents first-use
+network/repository population overhead in this environment, separately from the
+warm-cache build measurements above. Cold timing also includes root POM/BOM and
+plugin bootstrap resolution; it is not reported as pure formatter execution.
+Reproduce with `python3 tooling/format/benchmark-downloads.py` (Linux, network
+access). The initial real source-launcher bootstrap/check also successfully
+processed all files; its expected failure listed formatting differences, rather
+than a missing project recipe or parser/bootstrap error.
diff --git a/specs/refined/S8_palentir-formatting-be b/specs/in_review/S8_palentir-formatting-be
similarity index 90%
rename from specs/refined/S8_palentir-formatting-be
rename to specs/in_review/S8_palentir-formatting-be
index f1cb0e9..be9e5b2 100644
--- a/specs/refined/S8_palentir-formatting-be
+++ b/specs/in_review/S8_palentir-formatting-be
@@ -1,6 +1,6 @@
# S8 — Palantir formatting for Java
-Status: REFINED
+Status: IN_REVIEW
Agreed after the grill-me review; shared understanding confirmed.
@@ -163,30 +163,30 @@ style or silently take precedence over the Maven result.
## Acceptance criteria
-- [ ] The actual Palantir engine formats all maintained backend Java sources,
+- [x] The actual Palantir engine formats all maintained backend Java sources,
Java tests and `ci/*.java`; generated code is excluded.
-- [ ] OpenRewrite integration, custom adapter/recipe and bootstrap are implemented
+- [x] OpenRewrite integration, custom adapter/recipe and bootstrap are implemented
and reproducible from a clean checkout without manual recipe installation.
-- [ ] Plugin, formatter and adapter dependencies are pinned, with matching local
+- [x] Plugin, formatter and adapter dependencies are pinned, with matching local
and CI configuration.
-- [ ] Local Maven builds apply formatting before compilation; `compile`, `test`,
+- [x] Local Maven builds apply formatting before compilation; `compile`, `test`,
`verify` and `install` reach the intended integration.
-- [ ] Standalone apply and check commands work without the full application build.
-- [ ] CI checks only, leaves maintained sources unchanged, reports formatting
+- [x] Standalone apply and check commands work without the full application build.
+- [x] CI checks only, leaves maintained sources unchanged, reports formatting
differences and fails before image publication.
-- [ ] Import ordering and unused-import removal follow Palantir; optional extra
+- [x] Import ordering and unused-import removal follow Palantir; optional extra
Javadoc reformatting remains disabled and no unrelated refactoring is added.
-- [ ] All currently used Java 25 syntax is handled, or causes an explicit failure
+- [x] All currently used Java 25 syntax is handled, or causes an explicit failure
requiring resolution rather than silent exclusion.
-- [ ] Apply/check results agree and a second apply produces no differences.
-- [ ] Initial formatting is isolated in a formatting-only commit; application
+- [x] Apply/check results agree and a second apply produces no differences.
+- [x] Initial formatting is isolated in a formatting-only commit; application
builds and tests remain green.
-- [ ] Equivalent repeated unchanged-build measurements demonstrate at most three
+- [x] Equivalent repeated unchanged-build measurements demonstrate at most three
seconds of additional integration overhead. Changed-file and clean-build
measurements and one-time downloads are reported separately.
-- [ ] Failure to meet the performance bound is reported before changing the route
+- [x] Failure to meet the performance bound is reported before changing the route
or bound; a writing formatter is not killed to force compliance.
-- [ ] README instructions cover local and CI commands, bootstrap, optional IDE
+- [x] README instructions cover local and CI commands, bootstrap, optional IDE
use, authoritative Maven formatting and verification evidence.
## Implementation handoff
@@ -196,3 +196,5 @@ create an adapter, change Maven/CI behavior or reformat source files. Recipe loa
Java 25 compatibility and performance must be demonstrated during implementation;
there is no pre-existing project benchmark establishing the preferred route's
performance.
+
+Implementation and measured acceptance evidence: [S8-formatting-evidence.md](../S8-formatting-evidence.md).
diff --git a/tooling/format/Format.java b/tooling/format/Format.java
new file mode 100644
index 0000000..d8b42da
--- /dev/null
+++ b/tooling/format/Format.java
@@ -0,0 +1,131 @@
+import com.palantir.javaformat.java.Formatter;
+import com.palantir.javaformat.java.JavaFormatterOptions;
+import java.nio.file.*;
+import java.security.MessageDigest;
+import java.util.*;
+import org.openrewrite.*;
+import org.openrewrite.internal.InMemoryLargeSourceSet;
+import org.openrewrite.text.*;
+
+/** Source-launcher bootstrap: no project-specific artifact is resolved or installed. */
+public class Format {
+ public static void main(String[] args) throws Exception {
+ Path root = Path.of(args[0]).toAbsolutePath().normalize();
+ boolean check = switch (args[1]) {
+ case "check" -> true;
+ case "apply" -> false;
+ default -> throw new IllegalArgumentException("format.mode must be apply or check");
+ };
+ Path cacheFile = root.resolve("target/palantir-cache.properties");
+ var cache = new Properties();
+ if (Files.exists(cacheFile)) {
+ try (var input = Files.newInputStream(cacheFile)) {
+ cache.load(input);
+ }
+ }
+ String configuration = digest(Files.readString(root.resolve("pom.xml"))
+ + Files.readString(root.resolve("tooling/format/Format.java"))
+ + System.getProperty("java.version")
+ + System.getProperty("java.class.path"));
+ if (!configuration.equals(cache.getProperty("configuration"))) cache.clear();
+ cache.setProperty("configuration", configuration);
+ List sources = new ArrayList<>();
+ for (String directory :
+ List.of("agiledancers-be/src/main/java", "agiledancers-be/src/test/java", "ci", "tooling/format")) {
+ try (var paths = Files.walk(root.resolve(directory))) {
+ for (Path path : paths.filter(p -> p.toString().endsWith(".java"))
+ .sorted()
+ .toList()) {
+ try {
+ String content = Files.readString(path);
+ String key = root.relativize(path).toString();
+ if (!digest(content).equals(cache.getProperty(key))) {
+ sources.add(PlainText.builder()
+ .sourcePath(root.relativize(path))
+ .text(content)
+ .build());
+ }
+ } catch (Exception error) {
+ throw new IllegalStateException(path + ": " + error.getMessage(), error);
+ }
+ }
+ }
+ }
+ var context = new InMemoryExecutionContext(error -> {
+ throw new IllegalStateException("Formatting failed: " + error.getMessage(), error);
+ });
+ var results = new PalantirRecipe()
+ .run(new InMemoryLargeSourceSet(sources), context)
+ .getChangeset()
+ .getAllResults();
+ for (Result result : results) {
+ System.out.println((check ? "Needs formatting: " : "Formatted: ")
+ + result.getAfter().getSourcePath());
+ }
+ if (check && !results.isEmpty()) {
+ throw new IllegalStateException("Run mvn -N exec:exec@format to apply Palantir formatting.");
+ }
+ // Complete parsing/formatting before writing any maintained file. Each replacement is atomic.
+ if (!check) {
+ for (Result result : results) {
+ Path destination = root.resolve(result.getAfter().getSourcePath());
+ Path temporary = Files.createTempFile(destination.getParent(), ".palantir-", ".tmp");
+ try {
+ Files.writeString(temporary, result.getAfter().printAll());
+ Files.setPosixFilePermissions(temporary, Files.getPosixFilePermissions(destination));
+ Files.move(
+ temporary,
+ destination,
+ StandardCopyOption.ATOMIC_MOVE,
+ StandardCopyOption.REPLACE_EXISTING);
+ } finally {
+ Files.deleteIfExists(temporary);
+ }
+ }
+ }
+ for (SourceFile source : sources) {
+ cache.setProperty(
+ source.getSourcePath().toString(), digest(Files.readString(root.resolve(source.getSourcePath()))));
+ }
+ Files.createDirectories(cacheFile.getParent());
+ try (var output = Files.newOutputStream(cacheFile)) {
+ cache.store(output, "Successfully formatted content only");
+ }
+ System.out.println(
+ "Palantir " + args[1] + ": " + sources.size() + " Java files, " + results.size() + " differences");
+ }
+
+ static String digest(String value) throws Exception {
+ return HexFormat.of()
+ .formatHex(MessageDigest.getInstance("SHA-256")
+ .digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
+ }
+
+ public static final class PalantirRecipe extends Recipe {
+ public String getDisplayName() {
+ return "Palantir Java Format";
+ }
+
+ public String getDescription() {
+ return "Apply the pinned Palantir engine and its native import handling.";
+ }
+
+ public TreeVisitor, ExecutionContext> getVisitor() {
+ return new PlainTextVisitor() {
+ final Formatter formatter = Formatter.createFormatter(JavaFormatterOptions.builder()
+ .style(JavaFormatterOptions.Style.PALANTIR)
+ .formatJavadoc(false)
+ .build());
+
+ @Override
+ public PlainText visitText(PlainText text, ExecutionContext context) {
+ try {
+ return text.withText(formatter.formatSourceAndFixImports(text.getText()));
+ } catch (Exception | LinkageError error) {
+ throw new IllegalStateException(text.getSourcePath() + ": " + error, error);
+ }
+ }
+ };
+ }
+ }
+}
diff --git a/tooling/format/benchmark-downloads.py b/tooling/format/benchmark-downloads.py
new file mode 100644
index 0000000..1dc6b2f
--- /dev/null
+++ b/tooling/format/benchmark-downloads.py
@@ -0,0 +1,31 @@
+"""Measure dependency resolution separately; run from the root with network access.
+
+A disposable copy replaces the Java executable with /usr/bin/true, keeping the
+pinned exec-plugin dependencies. No formatter/application code executes. The
+second invocation uses the populated repository; neither invocation touches the
+normal Maven cache. Logs and measurements are written under target/.
+"""
+from pathlib import Path
+import json
+import shutil
+import subprocess
+import tempfile
+import time
+
+root = Path(__file__).resolve().parents[2]
+(root / "target").mkdir(exist_ok=True)
+with tempfile.TemporaryDirectory(prefix="s8-resolution-") as temporary:
+ fixture = Path(temporary)
+ shutil.copytree(root / ".mvn", fixture / ".mvn")
+ pom = (root / "pom.xml").read_text().replace("${java.home}/bin/java", "/usr/bin/true")
+ (fixture / "pom.xml").write_text(pom)
+ command = ["mvn", "-N", "-B", "-ntp", "-Dmaven.repo.local=" + str(fixture / "repository"),
+ "org.codehaus.mojo:exec-maven-plugin:3.6.3:exec@format"]
+ for label in ("cold-resolution", "warm-resolution"):
+ start = time.monotonic()
+ result = subprocess.run(command, cwd=fixture, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
+ elapsed = time.monotonic() - start
+ (root / "target" / ("s8-" + label + ".log")).write_text(result.stdout)
+ print(json.dumps(dict(label=label, seconds=round(elapsed, 3), exit=result.returncode)), flush=True)
+ if result.returncode:
+ raise RuntimeError(result.stdout)
diff --git a/tooling/format/benchmark.py b/tooling/format/benchmark.py
new file mode 100644
index 0000000..3cd5dcb
--- /dev/null
+++ b/tooling/format/benchmark.py
@@ -0,0 +1,49 @@
+"""Run sequential, warm-cache end-to-end Maven comparisons from the repository root."""
+from pathlib import Path
+import json
+import subprocess
+import time
+
+root = Path(__file__).resolve().parents[2]
+results = []
+pom = root / "pom.xml"
+original_pom = pom.read_bytes()
+pom_text = original_pom.decode()
+start = pom_text.rfind(" ", 0, pom_text.index("exec-maven-plugin"))
+end = pom_text.index(" ", start) + len(" \n")
+baseline_pom = (pom_text[:start] + pom_text[end:]).encode()
+
+def run(label, *arguments):
+ command = ["mvn", "-B", "-ntp", *arguments]
+ start = time.monotonic()
+ baseline = "baseline" in label
+ try:
+ if baseline:
+ pom.write_bytes(baseline_pom)
+ result = subprocess.run(command, cwd=root, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
+ finally:
+ pom.write_bytes(original_pom)
+ elapsed = time.monotonic() - start
+ (root / "target").mkdir(exist_ok=True)
+ (root / "target" / ("s8-" + label + ".log")).write_text(result.stdout)
+ row = dict(label=label, command=command, seconds=round(elapsed, 3), exit=result.returncode)
+ results.append(row)
+ print(json.dumps(row), flush=True)
+ if result.returncode:
+ raise RuntimeError(result.stdout)
+
+run("warmup", "compile")
+run("baseline-warmup", "compile")
+for number in range(3):
+ run("baseline-" + str(number), "compile")
+ run("formatted-" + str(number), "compile")
+source = root / "ci/runner/WaitForDocker.java"
+original = source.read_bytes()
+try:
+ source.write_bytes(original + b"\n")
+ run("changed-file", "compile")
+finally:
+ source.write_bytes(original)
+run("clean-baseline", "clean", "verify")
+run("clean-formatted", "-Dformat.mode=check", "clean", "verify")
+(root / "target/s8-benchmark.json").write_text(json.dumps(results, indent=2) + "\n")
diff --git a/tooling/format/test.py b/tooling/format/test.py
new file mode 100644
index 0000000..2d72f1f
--- /dev/null
+++ b/tooling/format/test.py
@@ -0,0 +1,53 @@
+"""Integration checks against an isolated checkout; run python3 tooling/format/test.py."""
+from pathlib import Path
+import shutil
+import subprocess
+import tempfile
+
+root = Path(__file__).resolve().parents[2]
+with tempfile.TemporaryDirectory(prefix="palantir-test-") as temporary:
+ fixture = Path(temporary)
+ for name in ("pom.xml", ".mvn", "tooling/format"):
+ source = root / name
+ destination = fixture / name
+ if source.is_dir():
+ shutil.copytree(source, destination)
+ else:
+ shutil.copy2(source, destination)
+ for name in ("agiledancers-be/src/main/java", "agiledancers-be/src/test/java", "ci/runner"):
+ (fixture / name).mkdir(parents=True)
+ source = fixture / "ci/runner/Example.java"
+ original = "import java.util.List;\nclass Example{String text=\"héllo\";int value(){return 1;}}\n"
+ source.write_text(original)
+
+ def run(mode, success=True):
+ result = subprocess.run(
+ ["mvn", "-N", "-B", "-ntp", "exec:exec@format", "-Dformat.mode=" + mode],
+ cwd=fixture, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
+ assert (result.returncode == 0) == success, result.stdout
+ return result.stdout
+
+ output = run("check", False)
+ assert "ci/runner/Example.java" in output and "mvn -N exec:exec@format" in output
+ assert source.read_text() == original
+ run("apply")
+ formatted = source.read_text()
+ assert "import java.util.List" not in formatted and "héllo" in formatted
+ run("check")
+ run("apply")
+ assert source.read_text() == formatted
+ # Force a full (uncached) second pass to prove engine idempotence.
+ (fixture / "target/palantir-cache.properties").unlink()
+ run("check")
+ source.write_text(formatted + "class Broken { ??? }\n")
+ invalid = source.read_bytes()
+ output = run("apply", False)
+ assert "ci/runner/Example.java" in output
+ assert source.read_bytes() == invalid
+ # A new file is discovered even after a successful cached check.
+ source.write_text(formatted)
+ extra = fixture / "ci/New.java"
+ extra.write_text("class New{}")
+ run("check", False)
+ assert extra.read_text() == "class New{}"
+ print("PASS: check is read-only, apply/check agree, imports, UTF-8, idempotence, parser errors, new files")