S8 integrate pinned Palantir formatting through OpenRewrite

This commit is contained in:
2026-10-30 15:50:51 +01:00
parent 4cfd1d71de
commit 327a365550
10 changed files with 483 additions and 15 deletions

View File

@@ -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/`.

View File

@@ -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"));
}

View File

@@ -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();

49
pom.xml
View File

@@ -24,6 +24,11 @@
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<quarkus.platform.version>3.40.1</quarkus.platform.version>
<format.mode>apply</format.mode>
<format.skip>false</format.skip>
<palantir-java-format.version>2.101.0</palantir-java-format.version>
<rewrite-core.version>8.63.4</rewrite-core.version>
<ortools-java.version>9.15.6755</ortools-java.version>
<lombok.version>1.18.48</lombok.version>
@@ -77,6 +82,50 @@
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.6.3</version>
<inherited>false</inherited>
<executions>
<execution>
<id>format</id>
<phase>validate</phase>
<goals><goal>exec</goal></goals>
</execution>
</executions>
<configuration>
<skip>${format.skip}</skip>
<executable>${java.home}/bin/java</executable>
<includeProjectDependencies>false</includeProjectDependencies>
<includePluginDependencies>true</includePluginDependencies>
<arguments>
<argument>--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED</argument>
<argument>--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED</argument>
<argument>--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED</argument>
<argument>--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED</argument>
<argument>--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED</argument>
<argument>--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED</argument>
<argument>--class-path</argument><classpath/>
<argument>${maven.multiModuleProjectDirectory}/tooling/format/Format.java</argument>
<argument>${maven.multiModuleProjectDirectory}</argument>
<argument>${format.mode}</argument>
</arguments>
</configuration>
<dependencies>
<dependency>
<groupId>com.palantir.javaformat</groupId>
<artifactId>palantir-java-format</artifactId>
<version>${palantir-java-format.version}</version>
</dependency>
<dependency>
<groupId>org.openrewrite</groupId>
<artifactId>rewrite-core</artifactId>
<version>${rewrite-core.version}</version>
</dependency>
</dependencies>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>

View File

@@ -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=<empty-temp-repository> 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.

View File

@@ -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).

131
tooling/format/Format.java Normal file
View File

@@ -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<SourceFile> 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<ExecutionContext>() {
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);
}
}
};
}
}
}

View File

@@ -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)

View File

@@ -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(" <plugin>", 0, pom_text.index("<artifactId>exec-maven-plugin</artifactId>"))
end = pom_text.index(" </plugin>", start) + len(" </plugin>\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")

53
tooling/format/test.py Normal file
View File

@@ -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")