Implement isolated Build and Refresh with immutable leased snapshots

This commit is contained in:
2026-11-12 09:28:25 +01:00
parent 85a75383a0
commit e97af99412
27 changed files with 2066 additions and 15 deletions
+2 -1
View File
@@ -1,7 +1,8 @@
# Build output and local designer state
/target/
/*/target/
**/build/
/build/
/*/build/
.gradle/
.neitzel-fx-designer/
# IDE and machine-local settings
+5 -3
View File
@@ -4,10 +4,12 @@ Shared Scene Builder Kit designer for NeitzelLib FX, with planned standalone and
## Current state
The repository builds six shared Maven modules and a separate IntelliJ adapter plugin. Document transactions, native undo, build execution/cancellation, explicit runtime mappings and lifecycle services have initial implementations and tests. The shared UI exposes a Kit construction seam. There is **no runnable designer application or registered IntelliJ designer editor yet**. See [module and host boundaries](docs/module-host-boundaries.md) for the implemented scope and limitations.
The repository builds six shared Maven modules, a Maven capture plugin and a separate IntelliJ adapter plugin. Document transactions, native undo, build execution/cancellation, explicit runtime mappings and lifecycle services have initial implementations and tests. The shared UI exposes a Kit construction seam. There is **no runnable designer application or registered IntelliJ designer editor yet**. See [module and host boundaries](docs/module-host-boundaries.md) for the implemented scope and limitations.
Configuration 1.0 schemas, context selection, local JDK mappings and draft protocol validation are now executable. See [configuration and protocol](docs/configuration-and-protocol.md) for formats, limits and the remaining Maven/worker integration.
The shared [Build and Refresh workflow](docs/build-and-refresh.md) now builds isolated reactor copies and publishes immutable, leased snapshots, with explicit save approval and last-successful-build selection.
The Component builder fix is integrated in NeitzelLib main. A [verified local development snapshot](docs/runtime/neitzellib-baseline.md) is recorded with source/artifact identity; a corrected released version is still unestablished. NeitzelLib sources and prototype implementations are not copied here.
## Validate the foundation
@@ -18,14 +20,14 @@ Requirements: JDK 25 for builds, JDK 21 for target linkage validation, Python 3.
python3 scripts/check-bootstrap.py --idea-home "$IDEA_HOME" --target-java-home "$TARGET_JAVA_HOME"
```
On macOS `IDEA_HOME` points to the IDE application's `Contents` directory. The current SDK check uses its macOS product-info layout; other platforms are not qualified. The command checks runtime artifact hashes, clean-builds/installs the Maven modules, tests the adapters, checks bytecode baselines, runs target linkage under the supplied target JDK, builds the plugin ZIP, and validates documentation. It does not install or publish NeitzelLib.
On macOS `IDEA_HOME` points to the IDE application's `Contents` directory. The current SDK check uses its macOS product-info layout; other platforms are not qualified. The command checks runtime artifact hashes, clean-builds/installs the Maven modules, tests the adapters, checks bytecode baselines, runs target linkage under the supplied target JDK, builds the plugin ZIP, runs the real Maven snapshot smoke fixture, and validates documentation. It does not install or publish NeitzelLib.
Maven is pinned to **3.9.11** (Wrapper **3.3.4**); the separate IntelliJ build uses **Gradle 9.1.0** and IntelliJ Platform Gradle Plugin **2.20.1**. Wrapper distributions have pinned checksums. Shared-module-only validation is `./mvnw -B -ntp clean verify`; it does not qualify IntelliJ packaging. Plugin output is under `designer-intellij/build/distributions/`.
## Implementation references
- [Initial project contract](docs/initial-project-contract.md): module responsibilities, configuration, snapshots, runtimes, preview sessions and communication.
- [Repository bootstrap plan](docs/repository-bootstrap-plan.md): ordered work packages and acceptance gates. Next: implement Build and Refresh with immutable snapshots.
- [Repository bootstrap plan](docs/repository-bootstrap-plan.md): ordered work packages and acceptance gates. Next: validate the preview API and target adapter against real fixtures.
- [Decision provenance](docs/provenance.md): workspace planning and captured prototype evidence.
- [Third-party notices](THIRD-PARTY-NOTICES.md): Maven wrapper provenance.
+2
View File
@@ -7,3 +7,5 @@ The Gradle wrapper in `designer-intellij/` is generated by Gradle 9.1.0; its scr
The shared UI depends on Scene Builder Kit 26.0.0 and OpenJFX 26. The IntelliJ adapter builds against the locally supplied IntelliJ SDK with IntelliJ Platform Gradle Plugin 2.20.1. Target tools depend on the separately recorded NeitzelLib development artifacts. Preserve the upstream license/notice resources in packaged dependencies. Product distribution and any patched Kit source import require their separate notice/provenance review; this foundation does not assign a license to original NeitzelFXDesigner code.
Core configuration validation uses networknt json-schema-validator 1.5.9 and its Jackson dependencies, resolved as Maven artifacts. Preserve their bundled license resources when packaging; [upstream source and license](https://github.com/networknt/json-schema-validator/tree/1.5.9) provide provenance. They do not enter the application-facing preview API or target protocol artifact.
The separate Maven capture plugin compiles against provided Apache Maven 3.9.11 APIs and Maven Plugin Tools 3.15.1 annotations. These build-tool dependencies do not enter the shared designer core or target runtime.
@@ -0,0 +1,417 @@
package de.neitzel.fxdesigner.build;
import de.neitzel.fxdesigner.config.*;
import de.neitzel.fxdesigner.contracts.DocumentSnapshot;
import de.neitzel.fxdesigner.host.*;
import java.io.*;
import java.net.URI;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
import java.util.function.Consumer;
/**
* One context's build/refresh owner. UI callers await stages; they never block the host UI thread.
*/
public final class BuildRefresh implements AutoCloseable {
public enum Status {
READY,
NEEDS_SAVE,
CANCELLED,
FAILED,
STALE
}
public record Request(Path configuration, String context, Path fallbackMaven) {
public Request {
Objects.requireNonNull(configuration);
Objects.requireNonNull(context);
}
}
public record Outcome(
Status status,
String snapshot,
List<DocumentSnapshot> unsaved,
String message,
ContextCatalog.ReactorView reactor) {
public Outcome {
unsaved = List.copyOf(unsaved);
}
}
public final class Run implements AutoCloseable {
private final AtomicBoolean cancelled = new AtomicBoolean();
private final AtomicReference<HostServices.BuildRun> process = new AtomicReference<>();
private final CompletableFuture<Outcome> result = new CompletableFuture<>();
public CompletionStage<Outcome> completion() {
return result;
}
public void cancel() {
if (result.isDone()) return;
cancelled.set(true);
var p = process.get();
if (p != null) p.cancel();
}
public void close() {
cancel();
}
}
private final HostServices host;
private final SnapshotStore store;
private Run active;
private boolean closed;
public BuildRefresh(HostServices host, SnapshotStore store) {
this.host = host;
this.store = store;
host.lifecycle().own(this);
}
public synchronized Run start(
Request request, Map<URI, Long> approvedSaves, Consumer<HostServices.BuildOutput> output) {
if (closed) throw new IllegalStateException("Build owner closed");
if (active != null && !active.result.isDone())
throw new IllegalStateException("A build is already running");
var run = new Run();
active = run;
var approved = Map.copyOf(approvedSaves);
Thread.ofVirtual().start(() -> execute(run, request, approved, output));
return run;
}
private List<DocumentSnapshot> dirty(Path root) throws Exception {
return host.documents().unsaved().toCompletableFuture().get().stream()
.filter(
d -> {
Path p = Path.of(d.document()).toAbsolutePath().normalize();
return settingsPaths().containsValue(p)
|| (p.startsWith(root)
&& InputTree.included(root, root.relativize(p))
&& !p.toString().toLowerCase(Locale.ROOT).endsWith(".fxml"));
})
.toList();
}
private void execute(
Run run,
Request request,
Map<URI, Long> approvals,
Consumer<HostServices.BuildOutput> output) {
Path work = null;
String context = request.context();
try {
Path config = request.configuration().toRealPath(), root = config.getParent();
var unsaved = dirty(root);
if (!unsaved.isEmpty()) {
var actual = new HashMap<URI, Long>();
unsaved.forEach(d -> actual.put(d.document(), d.revision()));
if (!actual.equals(approvals)) {
store.invalidate(context, "Unsaved build inputs");
run.result.complete(
new Outcome(
Status.NEEDS_SAVE,
null,
unsaved,
"Save listed revisions and build, or cancel",
null));
return;
}
for (var draft : unsaved) {
checkCancelled(run);
try (var doc = host.documents().open(draft.document()).toCompletableFuture().get()) {
doc.save(draft.revision()).toCompletableFuture().get();
}
}
}
if (!dirty(root).isEmpty()) throw new IOException("Unsaved inputs changed during save");
checkCancelled(run);
var settings = ConfigurationFiles.load(config);
var selected = settings.contexts().get(context);
if (selected == null) throw new IOException("Unknown context");
var build =
host.runtimes()
.resolve(selected.buildRuntime(), HostServices.RuntimeRole.BUILD)
.toCompletableFuture()
.get();
var target =
host.runtimes()
.resolve(selected.targetRuntime(), HostServices.RuntimeRole.TARGET)
.toCompletableFuture()
.get();
if (build.javaFeature() < selected.buildJava()
|| target.javaFeature() < selected.targetJava())
throw new IOException("Runtime requirements not met");
Path cache = root.resolve(".neitzel-fx-designer/work");
Files.createDirectories(cache);
work = Files.createTempDirectory(cache, "build-");
Path workspace = work.resolve("reactor");
Files.createDirectory(workspace);
Path metadata = work.resolve("metadata");
store.invalidate(context, "Build in progress");
try (var watch = new SourceWatch(root)) {
var inputs = fingerprint(root);
InputTree.copy(root, workspace, InputTree.scan(root));
isolateSettings(work);
if (watch.changed() || !inputs.equals(fingerprint(root)))
throw new Stale("Inputs changed during isolation");
var command = command(workspace, metadata, selected, request.fallbackMaven());
output.accept(new HostServices.BuildOutput(false, "Build plan: " + command));
checkCancelled(run);
var process =
host.builds()
.start(
new HostServices.BuildRequest(
workspace,
command,
Map.of(
"JAVA_HOME",
build.javaHome().toString(),
"PATH",
build.javaHome().resolve("bin")
+ File.pathSeparator
+ System.getenv().getOrDefault("PATH", ""))),
output);
run.process.set(process);
if (run.cancelled.get()) process.cancel();
var result = process.completion().toCompletableFuture().get();
checkCancelled(run);
if (result.cancelled()) throw new CancellationException();
if (result.exitCode() != 0)
throw new IOException("Maven failed with exit code " + result.exitCode());
var captured = MavenCapture.read(metadata, workspace, root, selected);
var details = new TreeMap<String, String>();
details.put("configuration", config.toUri().toString());
details.put(
"contextSpec",
new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(selected));
details.put("buildJavaHome", build.javaHome().toString());
details.put("buildJava", Integer.toString(build.javaFeature()));
details.put("targetJavaHome", target.javaHome().toString());
details.put("targetJava", Integer.toString(target.javaFeature()));
details.put("wireVersion", "1.0");
details.put("manifestVersion", "1");
details.put("coordinates", captured.coordinates().toString());
details.put("command", command.toString());
java.util.function.BooleanSupplier valid =
() -> {
try {
for (var e : captured.dependencyProof().entrySet())
if (!InputTree.signature(e.getKey()).equals(e.getValue())) return false;
return !run.cancelled.get()
&& !watch.changed()
&& inputs.equals(fingerprint(root))
&& dirty(root).isEmpty();
} catch (Exception e) {
return false;
}
};
if (!valid.getAsBoolean()) throw new Stale("Inputs changed while building");
var snapshot =
store.publish(context, inputs, captured.runtime(), details, valid, run.cancelled::get);
run.result.complete(
new Outcome(
Status.READY,
snapshot.id(),
List.of(),
"Successful immutable build snapshot",
captured.reactor()));
}
} catch (Exception e) {
Throwable cause = e;
while (cause instanceof ExecutionException && cause.getCause() != null)
cause = cause.getCause();
Status status =
run.cancelled.get() || cause instanceof CancellationException
? Status.CANCELLED
: cause instanceof Stale ? Status.STALE : Status.FAILED;
String message =
cause.getMessage() == null ? cause.getClass().getSimpleName() : cause.getMessage();
try {
store.invalidate(context, message);
} catch (IOException x) {
message += "; cannot persist stale state";
}
try {
host.diagnostics()
.report(
new HostServices.Diagnostic(
"build-refresh", HostServices.Severity.ERROR, message, Optional.empty()));
} finally {
run.result.complete(new Outcome(status, null, List.of(), message, null));
}
} finally {
run.process.set(null);
if (work != null)
try {
InputTree.delete(work);
} catch (IOException ignored) {
/* An abandoned workspace is never a published snapshot. */
}
}
}
private static Map<String, Path> settingsPaths() {
Path home = Path.of(System.getProperty("user.home"));
return Map.of(
"settings",
canonicalSetting(home.resolve(".m2/settings.xml")),
"toolchains",
canonicalSetting(home.resolve(".m2/toolchains.xml")));
}
private static Path canonicalSetting(Path path) {
try {
return path.toRealPath();
} catch (IOException e) {
return path.toAbsolutePath().normalize();
}
}
private static Map<String, String> fingerprint(Path root) throws IOException {
var map = new TreeMap<>(InputTree.scan(root));
for (var e : settingsPaths().entrySet())
map.put(
"@maven-" + e.getKey(),
Files.exists(e.getValue()) ? InputTree.signature(e.getValue()) : "absent");
return Map.copyOf(map);
}
private static void isolateSettings(Path work) throws IOException {
for (var e : settingsPaths().entrySet()) {
Path dest = work.resolve(e.getKey() + ".xml");
if (Files.exists(e.getValue())) Files.copy(e.getValue(), dest);
else Files.writeString(dest, "<" + e.getKey() + "/>");
}
}
private static final class Stale extends IOException {
Stale(String message) {
super(message);
}
}
private static void checkCancelled(Run run) {
if (run.cancelled.get()) throw new CancellationException("Build cancelled");
}
static List<String> command(
Path workspace, Path metadata, DesignerConfiguration.Context context, Path fallback)
throws IOException {
var modules = new LinkedHashSet<String>();
modules.add(selector(workspace, context.applicationModule()));
if (context.preview().sourceSet().equals("module"))
modules.add(selector(workspace, context.preview().module()));
boolean windows = System.getProperty("os.name").startsWith("Windows");
Path wrapper = workspace.resolve(windows ? "mvnw.cmd" : "mvnw");
Path executable = Files.isRegularFile(wrapper) ? wrapper : fallback;
if (executable == null
|| !executable.isAbsolute()
|| !Files.isRegularFile(executable)
|| !Files.isExecutable(executable))
throw new IOException("Provide an executable Maven wrapper or explicit Maven installation");
var command = new ArrayList<String>();
command.add(executable.toString());
command.addAll(
List.of(
"-s",
workspace.getParent().resolve("settings.xml").toString(),
"-t",
workspace.getParent().resolve("toolchains.xml").toString(),
"-Ddesigner.workspace=" + workspace));
command.addAll(List.of("-B", "-ntp", "-pl", String.join(",", modules), "-am"));
for (String profile : context.mavenProfiles())
if (!profile.matches("[A-Za-z0-9_][A-Za-z0-9_.-]*"))
throw new IOException("Invalid active Maven profile name");
if (!context.mavenProfiles().isEmpty()) {
command.add("-P");
command.add(String.join(",", context.mavenProfiles()));
}
command.addAll(
List.of(
"-DskipTests=true",
"-Dmaven.test.skip=false",
"de.neitzel.fxdesigner:designer-maven-plugin:0.1.0-SNAPSHOT:plan",
"clean",
"de.neitzel.fxdesigner:designer-maven-plugin:0.1.0-SNAPSHOT:dependencies",
"package",
"de.neitzel.fxdesigner:designer-maven-plugin:0.1.0-SNAPSHOT:capture",
"-Ddesigner.captureDir=" + metadata));
return List.copyOf(command);
}
private static String selector(Path root, String value) throws IOException {
Path relative = Path.of(value);
if (relative.isAbsolute()
|| value.contains(",")
|| value.startsWith("-")
|| value.startsWith("!")) throw new IOException("Invalid Maven module selector");
Path resolved = root.resolve(relative).normalize();
if (!resolved.startsWith(root) || !Files.isRegularFile(resolved.resolve("pom.xml")))
throw new IOException("Module outside reactor or missing POM");
String selector = root.relativize(resolved).toString();
return selector.isEmpty() ? "." : selector;
}
public SnapshotStore.Lease previewCurrent(Request request) throws Exception {
var state = store.state(request.context());
if (state.outdated() || state.lastSuccessful() == null)
throw new IllegalStateException("Choose Preview with last successful build explicitly");
var lease = store.lease(state.lastSuccessful());
try {
Path root = request.configuration().toRealPath().getParent();
if (!lease
.snapshot()
.details()
.get("configuration")
.equals(request.configuration().toRealPath().toUri().toString())
|| !runtimeMatches(request, lease.snapshot())
|| !lease.snapshot().inputs().equals(fingerprint(root))
|| !dirty(root).isEmpty())
throw new IllegalStateException(
"Build is outdated; choose last successful build explicitly");
return lease;
} catch (Exception e) {
lease.close();
store.invalidate(request.context(), "Build inputs or runtime mappings changed");
throw e;
}
}
private boolean runtimeMatches(Request request, SnapshotStore.Snapshot snapshot)
throws Exception {
var context =
ConfigurationFiles.load(request.configuration()).contexts().get(request.context());
if (context == null) return false;
var build =
host.runtimes()
.resolve(context.buildRuntime(), HostServices.RuntimeRole.BUILD)
.toCompletableFuture()
.get();
var target =
host.runtimes()
.resolve(context.targetRuntime(), HostServices.RuntimeRole.TARGET)
.toCompletableFuture()
.get();
return build.javaHome().toString().equals(snapshot.details().get("buildJavaHome"))
&& target.javaHome().toString().equals(snapshot.details().get("targetJavaHome"))
&& Integer.toString(build.javaFeature()).equals(snapshot.details().get("buildJava"))
&& Integer.toString(target.javaFeature()).equals(snapshot.details().get("targetJava"));
}
public SnapshotStore.Lease previewLastSuccessful(String context) throws IOException {
var state = store.state(context);
if (state.lastSuccessful() == null) throw new IOException("No successful build available");
return store.lease(state.lastSuccessful());
}
public synchronized void close() {
closed = true;
if (active != null) active.cancel();
}
}
@@ -0,0 +1,95 @@
package de.neitzel.fxdesigner.build;
import java.io.*;
import java.nio.file.*;
import java.nio.file.attribute.*;
import java.security.*;
import java.util.*;
/** Conservative reactor input closure, excluding mutable build/cache and editor metadata. */
final class InputTree {
private static final Set<String> IGNORED =
Set.of(".git", ".idea", ".gradle", ".neitzel-fx-designer");
static boolean included(Path root, Path relative) {
Path parent = root;
for (Path part : relative) {
String name = part.toString();
if (IGNORED.contains(name)
|| (name.equals("target") && Files.isRegularFile(parent.resolve("pom.xml"))))
return false;
parent = parent.resolve(part);
}
return true;
}
static String hash(Path file) throws IOException {
try {
var digest = MessageDigest.getInstance("SHA-256");
try (var in = Files.newInputStream(file)) {
byte[] b = new byte[65536];
for (int n; (n = in.read(b)) != -1; ) digest.update(b, 0, n);
}
return HexFormat.of().formatHex(digest.digest());
} catch (NoSuchAlgorithmException e) {
throw new AssertionError(e);
}
}
static String signature(Path file) throws IOException {
var a = Files.readAttributes(file, BasicFileAttributes.class);
return hash(file) + ":" + a.size() + ":" + a.lastModifiedTime() + ":" + a.fileKey();
}
static Map<String, String> scan(Path root) throws IOException {
var files = new TreeMap<String, String>();
Files.walkFileTree(
root,
new SimpleFileVisitor<>() {
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes a)
throws IOException {
return !included(root, root.relativize(dir))
? FileVisitResult.SKIP_SUBTREE
: FileVisitResult.CONTINUE;
}
public FileVisitResult visitFile(Path file, BasicFileAttributes a) throws IOException {
Path r = root.relativize(file);
if (!included(root, r)) return FileVisitResult.CONTINUE;
if (!a.isRegularFile() || a.isSymbolicLink())
throw new IOException("Unsupported non-regular build input: " + r);
var before =
Files.readAttributes(file, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
String hash = hash(file);
var after =
Files.readAttributes(file, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
if (!before.lastModifiedTime().equals(after.lastModifiedTime())
|| before.size() != after.size()
|| !Objects.equals(before.fileKey(), after.fileKey()))
throw new IOException("Input changed while reading: " + r);
files.put(
r.toString(),
hash + ":" + after.size() + ":" + after.lastModifiedTime() + ":" + after.fileKey());
return FileVisitResult.CONTINUE;
}
});
return Map.copyOf(files);
}
static void copy(Path root, Path destination, Map<String, String> inputs) throws IOException {
for (String name : inputs.keySet()) {
Path source = root.resolve(name), target = destination.resolve(name);
Files.createDirectories(target.getParent());
Files.copy(source, target, StandardCopyOption.COPY_ATTRIBUTES);
if (!hash(target).equals(inputs.get(name).substring(0, 64)))
throw new IOException("Source changed during copy: " + name);
}
}
static void delete(Path root) throws IOException {
if (!Files.exists(root)) return;
try (var paths = Files.walk(root)) {
for (Path p : paths.sorted(Comparator.reverseOrder()).toList()) Files.delete(p);
}
}
}
@@ -0,0 +1,99 @@
package de.neitzel.fxdesigner.build;
import de.neitzel.fxdesigner.config.*;
import java.io.*;
import java.nio.file.*;
import java.util.*;
/** Reads machine-readable metadata emitted by the dedicated Maven plugin, never console parsing. */
final class MavenCapture {
record Result(
List<Path> runtime,
ContextCatalog.ReactorView reactor,
Map<String, String> coordinates,
Map<Path, String> dependencyProof) {}
static List<String> list(Properties p, String key) throws IOException {
int count = Integer.parseInt(p.getProperty(key + ".count"));
if (count < 0 || count > 100000) throw new IOException("Invalid metadata count");
var values = new ArrayList<String>();
for (int i = 0; i < count; i++)
values.add(Objects.requireNonNull(p.getProperty(key + "." + i)));
return values;
}
static Result read(
Path directory, Path workspace, Path original, DesignerConfiguration.Context context)
throws IOException {
var projects = new HashMap<Path, Properties>();
try (var files = Files.list(directory)) {
for (Path file : files.filter(p -> p.toString().endsWith(".properties")).toList()) {
var p = new Properties();
try (var in = Files.newInputStream(file)) {
p.load(in);
}
if (!"2".equals(p.getProperty("format")))
throw new IOException("Unsupported Maven metadata");
Path module = Path.of(p.getProperty("module")).toRealPath();
if (!module.startsWith(workspace)) throw new IOException("Module outside isolated reactor");
projects.put(module, p);
}
}
Path app = workspace.resolve(context.applicationModule()).normalize();
Path preview =
context.preview().sourceSet().equals("module")
? workspace.resolve(context.preview().module()).normalize()
: app;
var ordered = new LinkedHashSet<Path>();
var coords = new TreeMap<String, String>();
var proof = new LinkedHashMap<Path, String>();
var modules = new ArrayList<ContextCatalog.MavenModule>();
for (var e : projects.entrySet()) {
var p = e.getValue();
int artifactCount = Integer.parseInt(p.getProperty("artifact.count"));
if (artifactCount < 0 || artifactCount > 100000)
throw new IOException("Invalid dependency proof count");
for (int i = 0; i < artifactCount; i++) {
Path artifact = Path.of(p.getProperty("artifact." + i + ".path"));
String signature = p.getProperty("artifact." + i + ".signature");
if (proof.putIfAbsent(artifact, signature) != null
&& !proof.get(artifact).equals(signature))
throw new IOException("Dependency identity changed between projects");
coords.put("artifact:" + artifact, p.getProperty("artifact." + i + ".coordinates"));
}
var sources = new ArrayList<Path>();
for (String s : list(p, "sources")) {
Path source = Path.of(s).normalize();
if (!source.startsWith(e.getKey())) throw new IOException("Source root outside its module");
Path originalSource = original.resolve(workspace.relativize(source));
if (Files.isDirectory(originalSource)
&& InputTree.included(original, original.relativize(originalSource)))
sources.add(originalSource);
}
var deps = new HashSet<Path>();
for (String d : list(p, "dependencies"))
deps.add(original.resolve(workspace.relativize(Path.of(d))));
modules.add(
new ContextCatalog.MavenModule(
original.resolve(workspace.relativize(e.getKey())), sources, deps));
coords.put(workspace.relativize(e.getKey()).toString(), p.getProperty("coordinates"));
}
for (Path selected : new LinkedHashSet<>(List.of(preview, app))) {
Properties p = projects.get(selected);
if (p == null) throw new IOException("Selected module absent from Maven result");
boolean test = selected.equals(app) && context.preview().sourceSet().equals("test");
for (String item : list(p, test ? "test" : "runtime")) {
Path path = Path.of(item).toAbsolutePath().normalize();
if (Files.exists(path)) ordered.add(path.toRealPath());
else if (!path.startsWith(workspace))
throw new IOException("Missing runtime dependency: " + path);
}
}
if (ordered.isEmpty()) throw new IOException("Maven returned no runtime outputs");
return new Result(
List.copyOf(ordered),
new ContextCatalog.ReactorView(context.mavenProfiles(), modules),
Map.copyOf(coords),
Map.copyOf(proof));
}
}
@@ -0,0 +1,364 @@
package de.neitzel.fxdesigner.build;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.*;
import java.nio.channels.*;
import java.nio.file.*;
import java.time.*;
import java.util.*;
import java.util.function.BooleanSupplier;
/**
* Atomic private copies with persistent last-successful state and cross-process eviction leases.
*/
public final class SnapshotStore {
private static final ObjectMapper JSON = new ObjectMapper();
public record Snapshot(
String id,
String context,
String created,
Map<String, String> inputs,
Map<String, String> files,
List<String> runtime,
Map<String, String> details) {
public Snapshot {
inputs = Map.copyOf(inputs);
files = Map.copyOf(files);
runtime = List.copyOf(runtime);
details = Map.copyOf(details);
}
}
public record State(String lastSuccessful, boolean outdated, String reason) {}
private final Path root;
public SnapshotStore(Path root) throws IOException {
Files.createDirectories(root);
this.root = root.toRealPath();
Files.createDirectories(this.root.resolve("leases"));
}
interface Action<T> {
T run() throws IOException;
}
private <T> T locked(Action<T> action) throws IOException {
synchronized (root.toString().intern()) {
try (var channel =
FileChannel.open(
root.resolve("store.lock"), StandardOpenOption.CREATE, StandardOpenOption.WRITE);
var lock = channel.lock()) {
return action.run();
}
}
}
private static String key(String context) {
try {
return HexFormat.of()
.formatHex(
java.security.MessageDigest.getInstance("SHA-256")
.digest(context.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
} catch (Exception e) {
throw new AssertionError(e);
}
}
private Path stateFile(String context) {
return root.resolve("context-" + key(context) + ".json");
}
private State readState(String context) throws IOException {
Path p = stateFile(context);
return Files.exists(p)
? JSON.readValue(p.toFile(), State.class)
: new State(null, true, "No successful build");
}
public State state(String context) throws IOException {
return locked(() -> readState(context));
}
private void writeState(String context, State state) throws IOException {
Path temp = Files.createTempFile(root, "state-", ".tmp");
try {
JSON.writeValue(temp.toFile(), state);
Files.move(
temp,
stateFile(context),
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
} finally {
Files.deleteIfExists(temp);
}
}
public void invalidate(String context, String reason) throws IOException {
locked(
() -> {
var old = readState(context);
writeState(context, new State(old.lastSuccessful(), true, reason));
return null;
});
}
public Snapshot publish(
String context,
Map<String, String> inputs,
List<Path> runtime,
Map<String, String> details,
BooleanSupplier valid)
throws IOException {
return publish(context, inputs, runtime, details, valid, () -> false);
}
public Snapshot publish(
String context,
Map<String, String> inputs,
List<Path> runtime,
Map<String, String> details,
BooleanSupplier valid,
BooleanSupplier cancelled)
throws IOException {
return locked(
() -> {
String id = UUID.randomUUID().toString();
Path staging = root.resolve(".staging-" + id);
Files.createDirectory(staging);
boolean moved = false;
try {
var files = new TreeMap<String, String>();
var paths = new ArrayList<String>();
var sourceHashes = new LinkedHashMap<Path, String>();
var trees = new LinkedHashMap<Path, Map<String, String>>();
check(valid);
for (int i = 0; i < runtime.size(); i++) {
check(() -> !cancelled.getAsBoolean());
Path source = runtime.get(i).toRealPath();
String relative =
"runtime/"
+ i
+ "/"
+ (Files.isDirectory(source) ? "classes" : source.getFileName().toString());
Path dest = staging.resolve(relative);
paths.add(relative);
if (Files.isDirectory(source)) {
trees.put(source, tree(source));
Files.createDirectories(dest);
try (var tree = Files.walk(source)) {
for (Path p : tree.toList()) {
if (Files.isSymbolicLink(p)) throw new IOException("Symlink in runtime output");
if (Files.isRegularFile(p))
copy(
p,
dest.resolve(source.relativize(p)),
staging,
files,
sourceHashes,
() -> !cancelled.getAsBoolean());
}
}
} else
copy(source, dest, staging, files, sourceHashes, () -> !cancelled.getAsBoolean());
}
// Recheck every source after all copies: a source changed early in capture invalidates
// the whole set.
for (var e : sourceHashes.entrySet()) {
check(() -> !cancelled.getAsBoolean());
if (!InputTree.hash(e.getKey()).equals(e.getValue()))
throw new IOException("Runtime output/dependency changed during capture");
}
for (var entry : trees.entrySet())
if (!entry.getValue().equals(tree(entry.getKey())))
throw new IOException("Runtime tree changed during capture");
var snapshot =
new Snapshot(id, context, Instant.now().toString(), inputs, files, paths, details);
JSON.writeValue(staging.resolve("manifest.json").toFile(), snapshot);
check(valid);
Files.move(staging, root.resolve(id), StandardCopyOption.ATOMIC_MOVE);
moved = true;
check(valid);
writeState(context, new State(id, false, "Build succeeded"));
return snapshot;
} finally {
if (!moved) InputTree.delete(staging);
}
});
}
private static Map<String, String> tree(Path root) throws IOException {
var map = new TreeMap<String, String>();
try (var paths = Files.walk(root)) {
for (Path p : paths.toList()) {
if (Files.isSymbolicLink(p)) throw new IOException("Symlink in runtime tree");
if (Files.isRegularFile(p)) {
var attrs = Files.readAttributes(p, java.nio.file.attribute.BasicFileAttributes.class);
map.put(
root.relativize(p).toString(),
InputTree.hash(p)
+ ":"
+ attrs.size()
+ ":"
+ attrs.lastModifiedTime()
+ ":"
+ attrs.fileKey());
}
}
}
return map;
}
private static void check(BooleanSupplier valid) throws IOException {
if (!valid.getAsBoolean())
throw new IOException("Build cancelled or inputs changed before publication");
}
private static void copy(
Path source,
Path dest,
Path stage,
Map<String, String> files,
Map<Path, String> sources,
BooleanSupplier valid)
throws IOException {
check(valid);
String before = InputTree.hash(source);
Files.createDirectories(dest.getParent());
Files.copy(source, dest);
String copied = InputTree.hash(dest);
if (!before.equals(copied) || !before.equals(InputTree.hash(source)))
throw new IOException("Runtime file changed while copying");
files.put(stage.relativize(dest).toString(), copied);
sources.put(source, before);
}
public final class Lease implements AutoCloseable {
private final Snapshot snapshot;
private final Path ticket;
private final FileChannel channel;
private final FileLock lock;
private Lease(Snapshot snapshot, Path ticket, FileChannel channel, FileLock lock) {
this.snapshot = snapshot;
this.ticket = ticket;
this.channel = channel;
this.lock = lock;
}
public Snapshot snapshot() {
return snapshot;
}
public List<Path> runtime() {
return snapshot.runtime().stream().map(p -> root.resolve(snapshot.id()).resolve(p)).toList();
}
public synchronized void close() throws IOException {
if (!channel.isOpen()) return;
lock.release();
channel.close();
Files.deleteIfExists(ticket);
}
}
public Lease lease(String id) throws IOException {
return locked(
() -> {
if (id == null || !id.matches("[0-9a-f-]{36}"))
throw new IOException("Invalid snapshot identity");
Path directory = root.resolve(id);
var snapshot =
JSON.readValue(directory.resolve("manifest.json").toFile(), Snapshot.class);
if (!id.equals(snapshot.id())) throw new IOException("Manifest identity mismatch");
for (var e : snapshot.files().entrySet()) {
Path file = directory.resolve(e.getKey()).normalize();
if (!file.startsWith(directory)
|| !file.toRealPath().startsWith(directory)
|| !InputTree.hash(file).equals(e.getValue()))
throw new IOException("Snapshot integrity failure");
}
var actual = new TreeMap<String, String>();
try (var paths = Files.walk(directory.resolve("runtime"))) {
for (Path p : paths.toList()) {
if (Files.isSymbolicLink(p)) throw new IOException("Symlink in published snapshot");
if (Files.isRegularFile(p))
actual.put(directory.relativize(p).toString(), InputTree.hash(p));
}
}
if (!actual.equals(snapshot.files())) throw new IOException("Snapshot file set changed");
for (String p : snapshot.runtime())
if (!directory.resolve(p).normalize().startsWith(directory)
|| !Files.exists(directory.resolve(p)))
throw new IOException("Invalid snapshot runtime path");
Path ticket = Files.createTempFile(root.resolve("leases"), id + "-", ".lock");
var channel = FileChannel.open(ticket, StandardOpenOption.WRITE);
try {
return new Lease(snapshot, ticket, channel, channel.lock());
} catch (Exception e) {
channel.close();
Files.deleteIfExists(ticket);
throw e;
}
});
}
/**
* Removes unpinned snapshots except retained last-successful builds; cleans abandoned staging.
*/
public int evict() throws IOException {
return locked(
() -> {
var retained = new HashSet<String>();
try (var paths = Files.list(root)) {
for (Path p :
paths
.filter(
p ->
p.getFileName().toString().startsWith("context-")
&& p.toString().endsWith(".json"))
.toList()) {
var state = JSON.readValue(p.toFile(), State.class);
if (state.lastSuccessful() != null) retained.add(state.lastSuccessful());
}
}
int removed = 0;
try (var paths = Files.list(root)) {
for (Path p : paths.filter(Files::isDirectory).toList()) {
String id = p.getFileName().toString();
if (id.startsWith(".staging-")) {
InputTree.delete(p);
continue;
}
if (!id.matches("[0-9a-f-]{36}") || retained.contains(id) || pinned(id)) continue;
InputTree.delete(p);
removed++;
}
}
return removed;
});
}
private boolean pinned(String id) throws IOException {
boolean pinned = false;
try (var tickets = Files.list(root.resolve("leases"))) {
for (Path ticket :
tickets.filter(p -> p.getFileName().toString().startsWith(id + "-")).toList()) {
try (var c = FileChannel.open(ticket, StandardOpenOption.WRITE)) {
try (var lock = c.tryLock()) {
if (lock == null) {
pinned = true;
continue;
}
} catch (OverlappingFileLockException e) {
pinned = true;
continue;
}
}
Files.deleteIfExists(ticket);
}
}
return pinned;
}
}
@@ -0,0 +1,49 @@
package de.neitzel.fxdesigner.build;
import java.io.*;
import java.nio.file.*;
import java.util.*;
/** Detects transient changes and overflow in addition to pre/post hashes and file attributes. */
final class SourceWatch implements AutoCloseable {
private final WatchService watch;
private final Map<WatchKey, Path> keys = new HashMap<>();
private final Path root;
SourceWatch(Path root) throws IOException {
this.root = root;
watch = root.getFileSystem().newWatchService();
try (var dirs = Files.walk(root)) {
for (Path dir :
dirs.filter(Files::isDirectory)
.filter(p -> InputTree.included(root, root.relativize(p)))
.toList())
keys.put(
dir.register(
watch,
StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_DELETE,
StandardWatchEventKinds.ENTRY_MODIFY),
dir);
}
}
boolean changed() {
boolean changed = false;
for (WatchKey key; (key = watch.poll()) != null; ) {
Path dir = keys.get(key);
for (var event : key.pollEvents()) {
if (event.kind() == StandardWatchEventKinds.OVERFLOW
|| dir == null
|| InputTree.included(root, root.relativize(dir.resolve((Path) event.context()))))
changed = true;
}
if (!key.reset()) changed = true;
}
return changed;
}
public void close() throws IOException {
watch.close();
}
}
@@ -25,6 +25,11 @@ public record HostServices(
public interface Documents {
CompletionStage<DocumentSession> open(URI document);
default CompletionStage<List<de.neitzel.fxdesigner.contracts.DocumentSnapshot>> unsaved() {
return java.util.concurrent.CompletableFuture.failedFuture(
new UnsupportedOperationException("Host must enumerate known unsaved build inputs"));
}
}
public enum RuntimeRole {
@@ -0,0 +1,164 @@
package de.neitzel.fxdesigner.build;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.atomic.*;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.io.TempDir;
class SnapshotStoreTest {
@TempDir Path root;
@Test
void copiesSurviveOutputReplacementAndLeasesPreventEvictionAcrossStoreInstances()
throws Exception {
Path output = Files.createDirectories(root.resolve("output"));
Files.writeString(output.resolve("A.class"), "old");
var store = new SnapshotStore(root.resolve("store"));
var first =
store.publish("desktop", Map.of("source", "hash"), List.of(output), Map.of(), () -> true);
var lease = store.lease(first.id());
Files.writeString(output.resolve("A.class"), "new");
var second = store.publish("desktop", Map.of(), List.of(output), Map.of(), () -> true);
assertEquals("old", Files.readString(lease.runtime().getFirst().resolve("A.class")));
assertEquals(0, new SnapshotStore(root.resolve("store")).evict());
lease.close();
assertEquals(1, store.evict());
try (var next = store.lease(second.id())) {
assertEquals("new", Files.readString(next.runtime().getFirst().resolve("A.class")));
}
}
@Test
void failedCaptureDoesNotReplaceLastSuccessAndAbandonedStagingIsRecoverable() throws Exception {
Path jar = Files.writeString(root.resolve("lib.jar"), "dependency");
var store = new SnapshotStore(root.resolve("store"));
var first = store.publish("c", Map.of(), List.of(jar), Map.of(), () -> true);
assertThrows(
java.io.IOException.class,
() -> store.publish("c", Map.of(), List.of(jar), Map.of(), () -> false));
assertEquals(first.id(), store.state("c").lastSuccessful());
Path abandoned = Files.createDirectory(root.resolve("store/.staging-crashed"));
store.evict();
assertFalse(Files.exists(abandoned));
store.invalidate("c", "Failed build");
assertTrue(new SnapshotStore(root.resolve("store")).state("c").outdated());
}
@Test
void mutationDuringCaptureAndSnapshotTamperingAreRejected() throws Exception {
Path output = Files.createDirectories(root.resolve("classes"));
Path a = Files.writeString(output.resolve("A.class"), "original");
var store = new SnapshotStore(root.resolve("store"));
var calls = new AtomicInteger();
assertThrows(
java.io.IOException.class,
() ->
store.publish(
"c",
Map.of(),
List.of(output),
Map.of(),
() -> true,
() -> {
if (calls.incrementAndGet() == 3)
try {
Files.writeString(a, "changed");
} catch (Exception e) {
throw new RuntimeException(e);
}
return false;
}));
assertNull(store.state("c").lastSuccessful());
var snapshot = store.publish("c", Map.of(), List.of(output), Map.of(), () -> true);
try (var lease = store.lease(snapshot.id())) {
Files.writeString(lease.runtime().getFirst().resolve("A.class"), "tampered");
}
assertThrows(java.io.IOException.class, () -> store.lease(snapshot.id()));
}
@Test
void inputIsolationRejectsSymlinksAndRecordsAdditionsAndSameContentRewrites() throws Exception {
Path source = Files.createDirectories(root.resolve("source"));
Path file = Files.writeString(source.resolve("A.java"), "same");
var before = InputTree.scan(source);
Files.setLastModifiedTime(
file,
java.nio.file.attribute.FileTime.fromMillis(
Files.getLastModifiedTime(file).toMillis() + 1000));
assertNotEquals(before, InputTree.scan(source));
Files.createSymbolicLink(source.resolve("link"), file);
assertThrows(java.io.IOException.class, () -> InputTree.scan(source));
}
public static final class LeaseKeeper {
public static void main(String[] args) throws Exception {
try (var lease = new SnapshotStore(Path.of(args[0])).lease(args[1])) {
System.out.println("LEASED");
System.out.flush();
System.in.read();
}
}
}
@Test
void workerProcessLeaseSurvivesAnotherHostAndReleasesAfterOwnerDeath() throws Exception {
Path jar = Files.writeString(root.resolve("lib.jar"), "one");
Path cache = root.resolve("store");
var store = new SnapshotStore(cache);
var first = store.publish("c", Map.of(), List.of(jar), Map.of(), () -> true);
var child =
new ProcessBuilder(
Path.of(System.getProperty("java.home"), "bin/java").toString(),
"-cp",
System.getProperty("java.class.path"),
LeaseKeeper.class.getName(),
cache.toString(),
first.id())
.redirectErrorStream(true)
.start();
try {
var line =
java.util.concurrent.CompletableFuture.supplyAsync(
() -> {
try {
return new java.io.BufferedReader(
new java.io.InputStreamReader(child.getInputStream()))
.readLine();
} catch (Exception e) {
throw new RuntimeException(e);
}
})
.get(10, java.util.concurrent.TimeUnit.SECONDS);
assertEquals("LEASED", line);
store.publish("c", Map.of(), List.of(jar), Map.of(), () -> true);
assertEquals(0, store.evict());
child.destroyForcibly();
assertTrue(child.waitFor(10, java.util.concurrent.TimeUnit.SECONDS));
assertEquals(1, new SnapshotStore(cache).evict());
} finally {
child.destroyForcibly();
}
}
@Test
void targetNamedSourcePackageIsNotConfusedWithMavenOutput() throws Exception {
Files.writeString(root.resolve("pom.xml"), "<project/>");
Files.createDirectories(root.resolve("target/classes"));
Files.writeString(root.resolve("target/classes/Old.class"), "old");
Files.createDirectories(root.resolve("src/main/java/example/target"));
Files.writeString(root.resolve("src/main/java/example/target/Model.java"), "class Model {}");
var inputs = InputTree.scan(root);
assertTrue(inputs.containsKey("src/main/java/example/target/Model.java"));
assertFalse(inputs.containsKey("target/classes/Old.class"));
}
@Test
void oldMavenCaptureWithoutDependencyProofIsRejected() throws Exception {
Path metadata = Files.createDirectory(root.resolve("metadata"));
Files.writeString(metadata.resolve("old.properties"), "format=1\n");
assertThrows(java.io.IOException.class, () -> MavenCapture.read(metadata, root, root, null));
}
}
@@ -90,6 +90,26 @@ public final class IntellijDocuments implements HostServices.Documents, AutoClos
});
}
public CompletionStage<List<DocumentSnapshot>> unsaved() {
return edt(
() -> {
var result = new ArrayList<DocumentSnapshot>();
var manager = FileDocumentManager.getInstance();
for (var document : manager.getUnsavedDocuments()) {
var file = manager.getFile(document);
if (file == null || !file.isInLocalFileSystem()) continue;
var handle =
(Handle) open(Path.of(file.getPath()).toUri()).toCompletableFuture().join();
try {
result.add(snapshot(handle.s));
} finally {
handle.release();
}
}
return List.copyOf(result);
});
}
private DocumentSnapshot snapshot(State s) {
return new DocumentSnapshot(
Path.of(s.file.getPath()).toUri(),
@@ -171,7 +191,13 @@ public final class IntellijDocuments implements HostServices.Documents, AutoClos
if (!s.document.isWritable() || !s.file.isWritable())
throw new IllegalStateException("Read-only document");
Path file = Path.of(s.file.getPath());
if (!Files.readString(file).equals(s.disk))
String onDisk = Files.readString(file);
if (!FileDocumentManager.getInstance().isDocumentUnsaved(s.document)
&& onDisk.equals(s.document.getText())) {
s.disk = onDisk;
return IntellijDocuments.this.snapshot(s);
}
if (!onDisk.equals(s.disk))
throw new ConcurrentModificationException("External change; draft retained");
FileDocumentManager.getInstance().saveDocument(s.document);
if (FileDocumentManager.getInstance().isDocumentUnsaved(s.document))
@@ -0,0 +1,35 @@
package de.neitzel.fxdesigner.intellij;
import com.intellij.openapi.command.WriteCommandAction;
import com.intellij.openapi.fileEditor.FileDocumentManager;
import com.intellij.openapi.vfs.LocalFileSystem;
import com.intellij.openapi.vfs.newvfs.impl.VfsRootAccess;
import com.intellij.testFramework.fixtures.BasePlatformTestCase;
import java.nio.file.*;
public final class BuildInputInventoryTest extends BasePlatformTestCase {
public void testNativeJavaDraftInventoryAndAutosaveBeforeExplicitSave() throws Exception {
Path file = Files.createTempFile("designer-build-input-", ".java").toRealPath();
Files.writeString(file, "class Original {}");
VfsRootAccess.allowRootAccess(getTestRootDisposable(), file.toString());
try (var documents = new IntellijDocuments(getProject())) {
var virtual = LocalFileSystem.getInstance().refreshAndFindFileByNioFile(file);
var nativeDocument = FileDocumentManager.getInstance().getDocument(virtual);
WriteCommandAction.runWriteCommandAction(
getProject(), () -> nativeDocument.setText("class Edited {}"));
var draft =
documents.unsaved().toCompletableFuture().get().stream()
.filter(d -> d.document().equals(file.toUri()))
.findFirst()
.orElseThrow();
assertTrue(draft.dirty());
FileDocumentManager.getInstance().saveDocument(nativeDocument);
try (var handle = documents.open(file.toUri()).toCompletableFuture().get()) {
assertFalse(handle.save(draft.revision()).toCompletableFuture().get().dirty());
}
assertEquals("class Edited {}", Files.readString(file));
} finally {
Files.deleteIfExists(file);
}
}
}
+74
View File
@@ -0,0 +1,74 @@
<?xml version="1.0" ?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>de.neitzel.fxdesigner</groupId>
<artifactId>neitzel-fx-designer</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>designer-maven-plugin</artifactId>
<packaging>maven-plugin</packaging>
<prerequisites><maven>3.9.11</maven></prerequisites>
<properties>
<module.java.release>21</module.java.release>
</properties>
<dependencies>
<dependency>
<groupId>org.apache.maven</groupId>
<artifactId>maven-core</artifactId>
<version>3.9.11</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.maven</groupId>
<artifactId>maven-plugin-api</artifactId>
<version>3.9.11</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.maven.plugin-tools</groupId>
<artifactId>maven-plugin-annotations</artifactId>
<version>3.15.1</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<executions>
<execution>
<id>module-dependency-boundary</id>
<goals>
<goal>enforce</goal>
</goals>
<configuration>
<rules>
<bannedDependencies>
<searchTransitive>true</searchTransitive>
<excludes>
<exclude>com.intellij:*</exclude>
<exclude>com.jetbrains:*</exclude>
<exclude>com.jetbrains.intellij.platform:*</exclude>
<exclude>com.gluonhq.scenebuilder:*</exclude>
<exclude>org.openjfx:*</exclude>
<exclude>de.neitzel.lib:*</exclude>
</excludes>
</bannedDependencies>
</rules>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-plugin-plugin</artifactId>
<version>3.15.1</version>
<configuration>
<goalPrefix>neitzel-designer</goalPrefix>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,78 @@
package de.neitzel.fxdesigner.maven;
import java.nio.file.*;
import java.util.*;
import org.apache.maven.execution.MavenSession;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugins.annotations.*;
import org.apache.maven.project.MavenProject;
/** Runs after package in Maven's process; no application class is loaded into a designer host. */
@Mojo(name = "capture", requiresDependencyResolution = ResolutionScope.TEST, threadSafe = true)
public final class CaptureMojo extends AbstractMojo {
@Parameter(defaultValue = "${project}", readonly = true, required = true)
private MavenProject project;
@Parameter(defaultValue = "${session}", readonly = true, required = true)
private MavenSession session;
@Parameter(property = "designer.captureDir", required = true)
private java.io.File directory;
public void execute() throws MojoExecutionException {
try {
var proof = new Properties();
try (var in =
Files.newInputStream(
directory.toPath().resolve(DependencyProof.id(project) + ".dependencies"))) {
proof.load(in);
}
DependencyProof.verify(proof);
var p = new Properties();
p.putAll(proof);
p.setProperty("format", "2");
p.setProperty("module", project.getBasedir().getCanonicalPath());
p.setProperty(
"coordinates",
project.getGroupId() + ":" + project.getArtifactId() + ":" + project.getVersion());
p.setProperty("packaging", project.getPackaging());
list(p, "runtime", project.getRuntimeClasspathElements());
list(p, "test", project.getTestClasspathElements());
var roots = new ArrayList<String>(project.getCompileSourceRoots());
roots.addAll(project.getTestCompileSourceRoots());
project.getResources().forEach(r -> roots.add(r.getDirectory()));
project.getTestResources().forEach(r -> roots.add(r.getDirectory()));
list(p, "sources", roots);
var dependencies = new ArrayList<String>();
for (var a : project.getArtifacts())
for (var other : session.getProjects())
if (a.getGroupId().equals(other.getGroupId())
&& a.getArtifactId().equals(other.getArtifactId())
&& a.getVersion().equals(other.getVersion()))
dependencies.add(other.getBasedir().getCanonicalPath());
list(p, "dependencies", dependencies);
list(p, "profiles", session.getRequest().getActiveProfiles());
Files.createDirectories(directory.toPath());
String id =
HexFormat.of()
.formatHex(
java.security.MessageDigest.getInstance("SHA-256")
.digest(
project
.getBasedir()
.getCanonicalPath()
.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
try (var out = Files.newOutputStream(directory.toPath().resolve(id + ".properties"))) {
p.store(out, "Neitzel FX Designer effective Maven capture");
}
} catch (Exception e) {
throw new MojoExecutionException("Cannot capture effective Maven metadata", e);
}
}
private static void list(Properties p, String name, List<String> values) {
p.setProperty(name + ".count", Integer.toString(values.size()));
for (int i = 0; i < values.size(); i++) p.setProperty(name + "." + i, values.get(i));
}
}
@@ -0,0 +1,29 @@
package de.neitzel.fxdesigner.maven;
import java.nio.file.*;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugins.annotations.*;
import org.apache.maven.project.MavenProject;
@Mojo(name = "dependencies", requiresDependencyResolution = ResolutionScope.TEST, threadSafe = true)
public final class DependenciesMojo extends AbstractMojo {
@Parameter(defaultValue = "${project}", readonly = true, required = true)
private MavenProject project;
@Parameter(property = "designer.captureDir", required = true)
private java.io.File directory;
public void execute() throws MojoExecutionException {
try {
Files.createDirectories(directory.toPath());
try (var out =
Files.newOutputStream(
directory.toPath().resolve(DependencyProof.id(project) + ".dependencies"))) {
DependencyProof.capture(project).store(out, "Dependency identities before compilation");
}
} catch (Exception e) {
throw new MojoExecutionException("Cannot capture build dependency identities", e);
}
}
}
@@ -0,0 +1,58 @@
package de.neitzel.fxdesigner.maven;
import java.nio.file.*;
import java.util.*;
import org.apache.maven.project.MavenProject;
final class DependencyProof {
static String signature(Path path) throws Exception {
var a = Files.readAttributes(path, java.nio.file.attribute.BasicFileAttributes.class);
var hash = java.security.MessageDigest.getInstance("SHA-256");
try (var in = Files.newInputStream(path)) {
byte[] b = new byte[65536];
for (int n; (n = in.read(b)) != -1; ) hash.update(b, 0, n);
}
return HexFormat.of().formatHex(hash.digest())
+ ":"
+ a.size()
+ ":"
+ a.lastModifiedTime()
+ ":"
+ a.fileKey();
}
static Properties capture(MavenProject project) throws Exception {
var p = new Properties();
int n = 0;
for (var a : project.getArtifacts()) {
Path path = a.getFile().toPath().toRealPath();
if (!Files.isRegularFile(path))
throw new IllegalArgumentException("Dependency is not a packaged file: " + path);
p.setProperty("artifact." + n + ".path", path.toString());
p.setProperty("artifact." + n + ".coordinates", a.getId());
p.setProperty("artifact." + n + ".signature", signature(path));
n++;
}
p.setProperty("artifact.count", Integer.toString(n));
return p;
}
static String id(MavenProject project) throws Exception {
return HexFormat.of()
.formatHex(
java.security.MessageDigest.getInstance("SHA-256")
.digest(
project
.getBasedir()
.getCanonicalPath()
.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
}
static void verify(Properties p) throws Exception {
int n = Integer.parseInt(p.getProperty("artifact.count"));
for (int i = 0; i < n; i++)
if (!signature(Path.of(p.getProperty("artifact." + i + ".path")))
.equals(p.getProperty("artifact." + i + ".signature")))
throw new IllegalStateException("Dependency changed during Maven build");
}
}
@@ -0,0 +1,49 @@
package de.neitzel.fxdesigner.maven;
import java.nio.file.*;
import java.util.*;
import org.apache.maven.execution.MavenSession;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugins.annotations.*;
/** Validates Maven's selected effective projects before any application lifecycle phase. */
@Mojo(name = "plan", aggregator = true, threadSafe = true)
public final class PlanMojo extends AbstractMojo {
@Parameter(defaultValue = "${session}", readonly = true, required = true)
private MavenSession session;
@Parameter(property = "designer.workspace", required = true)
private java.io.File workspace;
public void execute() throws MojoExecutionException {
try {
Path root = workspace.toPath().toRealPath();
var activeProfiles = new HashSet<String>();
for (var project : session.getProjects()) {
Path base = project.getBasedir().toPath().toRealPath();
if (!base.startsWith(root))
throw new IllegalArgumentException("Module outside isolated reactor");
var paths = new ArrayList<String>();
paths.add(project.getBuild().getDirectory());
paths.add(project.getBuild().getOutputDirectory());
paths.add(project.getBuild().getTestOutputDirectory());
paths.addAll(project.getCompileSourceRoots());
paths.addAll(project.getTestCompileSourceRoots());
project.getResources().forEach(r -> paths.add(r.getDirectory()));
project.getTestResources().forEach(r -> paths.add(r.getDirectory()));
for (String path : paths)
if (!Path.of(path).toAbsolutePath().normalize().startsWith(base))
throw new IllegalArgumentException(
"Effective source/output path outside module: " + path);
project.getActiveProfiles().forEach(p -> activeProfiles.add(p.getId()));
project.getInjectedProfileIds().values().forEach(activeProfiles::addAll);
getLog().info("Designer scope: " + root.relativize(base) + " (" + project.getId() + ")");
}
if (!activeProfiles.containsAll(session.getRequest().getActiveProfiles()))
throw new IllegalArgumentException("Requested Maven profile did not activate");
} catch (Exception e) {
throw new MojoExecutionException("Unsupported effective Maven build plan", e);
}
}
}
@@ -55,6 +55,15 @@ public final class FileDocuments implements HostServices.Documents, AutoCloseabl
});
}
public CompletionStage<List<DocumentSnapshot>> unsaved() {
return submit(
() ->
documents.values().stream()
.map(FileDocuments::snapshot)
.filter(DocumentSnapshot::dirty)
.toList());
}
private static DocumentSnapshot snapshot(State s) {
return new DocumentSnapshot(
s.file.toUri(), s.revision, s.text, Files.isWritable(s.file), !s.text.equals(s.saved));
@@ -126,7 +135,9 @@ public final class FileDocuments implements HostServices.Documents, AutoCloseabl
Path temp = Files.createTempFile(s.file.getParent(), "designer-save-", ".tmp");
try {
Files.writeString(temp, s.text);
if (Files.getFileAttributeView(s.file, java.nio.file.attribute.PosixFileAttributeView.class) != null) {
if (Files.getFileAttributeView(
s.file, java.nio.file.attribute.PosixFileAttributeView.class)
!= null) {
Files.setPosixFilePermissions(temp, Files.getPosixFilePermissions(s.file));
}
Files.move(
@@ -0,0 +1,205 @@
package de.neitzel.fxdesigner.build;
import de.neitzel.fxdesigner.host.*;
import de.neitzel.fxdesigner.standalone.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
/** Executed by check-bootstrap after the helper plugin and shared artifacts are installed. */
public final class BuildRefreshSmoke {
private static int checks;
private static void check(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
checks++;
}
private static void write(Path root, String name, String text) throws Exception {
Path p = root.resolve(name);
Files.createDirectories(p.getParent());
Files.writeString(p, text);
}
private static String child(String name, String extra) {
return "<project><modelVersion>4.0.0</modelVersion><parent><groupId>fixture</groupId><artifactId>parent</artifactId><version>1</version></parent><artifactId>"
+ name
+ "</artifactId>"
+ extra
+ "</project>";
}
private static final String DEP =
"<dependencies><dependency><groupId>fixture</groupId><artifactId>dep</artifactId><version>1</version></dependency></dependencies>";
public static void main(String[] args) throws Exception {
Path repository = Path.of(args[0]).toRealPath(), javaHome = Path.of(args[1]).toRealPath();
Path root = Files.createTempDirectory("designer-maven-smoke-").toRealPath();
var logs = new java.util.concurrent.CopyOnWriteArrayList<String>();
try {
write(
root,
"pom.xml",
"""
<project><modelVersion>4.0.0</modelVersion><groupId>fixture</groupId><artifactId>parent</artifactId><version>1</version><packaging>pom</packaging><modules><module>dep</module><module>app</module><module>unrelated</module></modules><properties><maven.compiler.release>21</maven.compiler.release></properties><profiles><profile><id>designer-preview</id><modules><module>preview</module></modules></profile></profiles><build><pluginManagement><plugins><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.14.1</version></plugin><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-resources-plugin</artifactId><version>3.3.1</version></plugin><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId><version>3.5.4</version></plugin><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-jar-plugin</artifactId><version>3.4.2</version></plugin></plugins></pluginManagement></build></project>
""");
write(root, "dep/pom.xml", child("dep", ""));
write(
root,
"dep/src/main/java/fixture/Model.java",
"package fixture; public class Model { public static String value(){return \"first\";}"
+ " }");
write(root, "app/pom.xml", child("app", DEP));
write(
root,
"app/src/main/java/fixture/Main.java",
"package fixture; public class Main {public static void main(String[]"
+ " a){System.out.print(Model.value());}}");
write(root, "app/src/main/resources/data.txt", "resource-v1");
write(
root,
"app/src/test/java/fixture/TestPreview.java",
"package fixture; public class TestPreview {public static void main(String[]"
+ " a){System.out.print(\"test-\"+Model.value());}}");
write(
root,
"preview/pom.xml",
child(
"preview",
"<dependencies><dependency><groupId>fixture</groupId><artifactId>app</artifactId><version>1</version></dependency></dependencies>"));
write(
root,
"preview/src/main/java/fixture/Preview.java",
"package fixture; public class Preview {public static void main(String[]"
+ " a){System.out.print(\"preview-\"+Model.value());}}");
write(root, "unrelated/pom.xml", child("unrelated", ""));
write(root, "unrelated/src/main/java/Broken.java", "THIS MUST NEVER COMPILE");
Files.copy(
repository.resolve("mvnw"), root.resolve("mvnw"), StandardCopyOption.COPY_ATTRIBUTES);
Files.createDirectories(root.resolve(".mvn/wrapper"));
Files.copy(
repository.resolve(".mvn/wrapper/maven-wrapper.properties"),
root.resolve(".mvn/wrapper/maven-wrapper.properties"));
Path config = root.resolve("neitzel-fx-designer.json");
write(root, "neitzel-fx-designer.json", configuration(true));
var runtime = new HostServices.RuntimeInstallation(javaHome, 21);
var mapping =
new ConfiguredRuntimes(
Map.of(
new ConfiguredRuntimes.Key("java", HostServices.RuntimeRole.BUILD),
runtime,
new ConfiguredRuntimes.Key("java", HostServices.RuntimeRole.TARGET),
runtime));
try (var host =
new StandaloneHost(
mapping,
l -> CompletableFuture.completedFuture(null),
d -> logs.add(d.message()));
var refresh =
new BuildRefresh(
host.services(),
new SnapshotStore(root.resolve(".neitzel-fx-designer/snapshots")))) {
var request = new BuildRefresh.Request(config, "desktop", null);
var first =
refresh
.start(request, Map.of(), o -> logs.add(o.line()))
.completion()
.toCompletableFuture()
.get(180, TimeUnit.SECONDS);
check(
first.status() == BuildRefresh.Status.READY,
"Module preview build failed: " + first.message());
check(
first.reactor().modules().stream().noneMatch(m -> m.directory().endsWith("unrelated")),
"Unrelated module entered scope");
check(!Files.exists(root.resolve("app/target")), "Build touched original target");
try (var old = refresh.previewCurrent(request)) {
check(
run(javaHome, old, "fixture.Preview").equals("preview-first"),
"Preview module runtime incorrect");
check(
old.runtime().stream().anyMatch(p -> Files.exists(p.resolve("data.txt"))),
"Resources missing from snapshot");
write(
root,
"dep/src/main/java/fixture/Model.java",
"package fixture; public class Model { public static String value(){return"
+ " \"second\";} }");
write(root, "neitzel-fx-designer.json", configuration(false));
var second =
refresh
.start(request, Map.of(), o -> logs.add(o.line()))
.completion()
.toCompletableFuture()
.get(180, TimeUnit.SECONDS);
check(
second.status() == BuildRefresh.Status.READY,
"Test preview build failed: " + second.message());
try (var current = refresh.previewCurrent(request)) {
check(
run(javaHome, current, "fixture.TestPreview").equals("test-second"),
"Test source arrangement missing");
}
check(
run(javaHome, old, "fixture.Preview").equals("preview-first"),
"Rebuild mutated active snapshot");
write(root, "app/src/main/java/fixture/Main.java", "INVALID JAVA");
var failed =
refresh
.start(request, Map.of(), o -> logs.add(o.line()))
.completion()
.toCompletableFuture()
.get(180, TimeUnit.SECONDS);
check(
failed.status() == BuildRefresh.Status.FAILED,
"Compiler failure incorrectly activated");
try (var retained = refresh.previewLastSuccessful("desktop")) {
check(
run(javaHome, retained, "fixture.TestPreview").equals("test-second"),
"Last successful build lost");
}
}
}
System.out.println(
"PASS real Maven Build and Refresh: "
+ checks
+ " checks (Java 21, profile/module preview, test preview, scope, resources,"
+ " isolation, failed build retention)");
} finally {
Path log = repository.resolve("designer-standalone/target/build-refresh-smoke.log");
Files.createDirectories(log.getParent());
Files.write(log, logs);
try (var files = Files.walk(root)) {
for (Path file : files.sorted(Comparator.reverseOrder()).toList())
Files.deleteIfExists(file);
}
}
}
private static String configuration(boolean module) {
return "{\"schemaVersion\":\"1.0\",\"contexts\":{\"desktop\":{\"applicationModule\":\"app\",\"mavenProfiles\":[\"designer-preview\"],\"launchMode\":\"classpath\",\"buildRuntime\":\"java\",\"targetRuntime\":\"java\",\"preview\":{\"sourceSet\":\""
+ (module ? "module" : "test")
+ "\","
+ (module ? "\"module\":\"preview\"," : "")
+ "\"provider\":\"fixture.Preview\"}}}}";
}
private static String run(Path home, SnapshotStore.Lease lease, String main) throws Exception {
String cp =
String.join(
java.io.File.pathSeparator, lease.runtime().stream().map(Path::toString).toList());
var process =
new ProcessBuilder(home.resolve("bin/java").toString(), "-cp", cp, main)
.redirectErrorStream(true)
.start();
if (!process.waitFor(15, TimeUnit.SECONDS)) {
process.destroyForcibly();
throw new AssertionError("Target did not exit");
}
String output =
new String(
process.getInputStream().readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
check(process.exitValue() == 0, output);
return output;
}
}
@@ -0,0 +1,199 @@
package de.neitzel.fxdesigner.build;
import static org.junit.jupiter.api.Assertions.*;
import de.neitzel.fxdesigner.contracts.*;
import de.neitzel.fxdesigner.host.*;
import de.neitzel.fxdesigner.standalone.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.io.TempDir;
class BuildRefreshTest {
@TempDir Path root;
private Path config;
private final AtomicInteger starts = new AtomicInteger();
private final AtomicBoolean failure = new AtomicBoolean();
private Runnable duringBuild = () -> {};
private HostServices host(FileDocuments documents) {
var runtime =
new HostServices.RuntimeInstallation(Path.of(System.getProperty("java.home")), 25);
return new HostServices(
documents,
(request, output) -> {
starts.incrementAndGet();
duringBuild.run();
try {
Path classes = Files.createDirectories(request.directory().resolve("target/classes"));
Files.writeString(
classes.resolve("A.class"),
Files.readString(request.directory().resolve("A.java")));
Path meta =
Path.of(
request.command().stream()
.filter(s -> s.startsWith("-Ddesigner.captureDir="))
.findFirst()
.orElseThrow()
.substring("-Ddesigner.captureDir=".length()));
Files.createDirectories(meta);
var p = new Properties();
p.setProperty("format", "2");
p.setProperty("artifact.count", "0");
p.setProperty("module", request.directory().toString());
p.setProperty("coordinates", "test:app:1");
p.setProperty("packaging", "jar");
for (String list : List.of("runtime", "test")) {
p.setProperty(list + ".count", "1");
p.setProperty(list + ".0", classes.toString());
}
for (String list : List.of("sources", "dependencies", "profiles"))
p.setProperty(list + ".count", "0");
try (var out = Files.newOutputStream(meta.resolve("app.properties"))) {
p.store(out, "");
}
} catch (Exception e) {
throw new java.io.IOException(e);
}
return new HostServices.BuildRun() {
public CompletionStage<HostServices.BuildResult> completion() {
return CompletableFuture.completedFuture(
new HostServices.BuildResult(failure.get() ? 1 : 0, false));
}
public void cancel() {}
};
},
(n, r) -> CompletableFuture.completedFuture(runtime),
l -> CompletableFuture.completedFuture(null),
d -> {},
new SessionScope());
}
@BeforeEach
void setup() throws Exception {
Files.writeString(root.resolve("pom.xml"), "<project/>");
Files.writeString(root.resolve("A.java"), "first");
Files.writeString(root.resolve("View.fxml"), "view");
Path wrapper = Files.writeString(root.resolve("mvnw"), "#!/bin/sh\nexit 0\n");
wrapper.toFile().setExecutable(true);
config = root.resolve("neitzel-fx-designer.json");
Files.writeString(
config,
"""
{"schemaVersion":"1.0","contexts":{"desktop":{"applicationModule":".","launchMode":"classpath","buildRuntime":"build","targetRuntime":"target","preview":{"sourceSet":"test","provider":"Example"}}}}
""");
root = root.toRealPath();
config = config.toRealPath();
}
private BuildRefresh.Request request() {
return new BuildRefresh.Request(config, "desktop", null);
}
private BuildRefresh.Outcome build(BuildRefresh refresh, Map<java.net.URI, Long> approvals)
throws Exception {
return refresh
.start(request(), approvals, o -> {})
.completion()
.toCompletableFuture()
.get(15, TimeUnit.SECONDS);
}
@Test
void unsavedInputRequiresExactApprovalAndFxmlDraftDoesNotForceJavaBuild() throws Exception {
try (var docs = new FileDocuments()) {
var host = host(docs);
var store = new SnapshotStore(root.resolve(".neitzel-fx-designer/snapshots"));
try (var refresh = new BuildRefresh(host, store);
var java = docs.open(root.resolve("A.java").toUri()).toCompletableFuture().get();
var fxml = docs.open(root.resolve("View.fxml").toUri()).toCompletableFuture().get()) {
java.apply(new EditBatch(0, "edit", List.of(new EditBatch.Replacement(0, 5, "draft"))))
.toCompletableFuture()
.get();
var prompt = build(refresh, Map.of());
assertEquals(BuildRefresh.Status.NEEDS_SAVE, prompt.status());
assertEquals(0, starts.get());
assertEquals("first", Files.readString(root.resolve("A.java")));
var approved = prompt.unsaved().getFirst();
assertEquals(
BuildRefresh.Status.READY,
build(refresh, Map.of(approved.document(), approved.revision())).status());
assertEquals("draft", Files.readString(root.resolve("A.java")));
fxml.apply(
new EditBatch(
0, "FXML edit", List.of(new EditBatch.Replacement(0, 4, "draft view"))))
.toCompletableFuture()
.get();
try (var lease = refresh.previewCurrent(request())) {
assertEquals("draft", Files.readString(lease.runtime().getFirst().resolve("A.class")));
}
assertEquals(1, starts.get());
}
}
}
@Test
void failedBuildRetainsExplicitLastSuccessAndNeverFallsBackAutomatically() throws Exception {
try (var docs = new FileDocuments();
var refresh =
new BuildRefresh(
host(docs), new SnapshotStore(root.resolve(".neitzel-fx-designer/snapshots")))) {
assertEquals(BuildRefresh.Status.READY, build(refresh, Map.of()).status());
failure.set(true);
assertEquals(BuildRefresh.Status.FAILED, build(refresh, Map.of()).status());
assertThrows(IllegalStateException.class, () -> refresh.previewCurrent(request()));
try (var lease = refresh.previewLastSuccessful("desktop")) {
assertEquals("first", Files.readString(lease.runtime().getFirst().resolve("A.class")));
}
}
}
@Test
void inputEditDuringBuildRejectsActivationEvenThoughBuildUsedIsolatedSources() throws Exception {
duringBuild =
() -> {
try {
Files.writeString(root.resolve("A.java"), "second");
} catch (Exception e) {
throw new RuntimeException(e);
}
};
var store = new SnapshotStore(root.resolve(".neitzel-fx-designer/snapshots"));
try (var docs = new FileDocuments();
var refresh = new BuildRefresh(host(docs), store)) {
assertEquals(BuildRefresh.Status.STALE, build(refresh, Map.of()).status());
assertNull(store.state("desktop").lastSuccessful());
}
}
@Test
void cancellationPreventsPublication() throws Exception {
var entered = new CountDownLatch(1);
var release = new CountDownLatch(1);
duringBuild =
() -> {
entered.countDown();
try {
release.await(5, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
};
var store = new SnapshotStore(root.resolve(".neitzel-fx-designer/snapshots"));
try (var docs = new FileDocuments();
var refresh = new BuildRefresh(host(docs), store)) {
var run = refresh.start(request(), Map.of(), o -> {});
assertTrue(entered.await(5, TimeUnit.SECONDS));
run.cancel();
release.countDown();
assertEquals(
BuildRefresh.Status.CANCELLED,
run.completion().toCompletableFuture().get(10, TimeUnit.SECONDS).status());
assertNull(store.state("desktop").lastSuccessful());
}
}
}
+80
View File
@@ -0,0 +1,80 @@
# Build and Refresh with immutable snapshots
Work package 5 implements the shared build coordinator, effective Maven capture helper and persistent snapshot store. This is usable through host services; wiring the editor action, discovery restart and preview workers remains work package 7. No application class runs inside the IDE or shared UI.
## Workflow
`BuildRefresh.start` returns a cancellable run with a completion stage. It accepts a configuration/context, an optional explicitly selected fallback Maven executable, exact approved document revisions, and an output callback. Callers must not block an IDE/UI thread waiting for the result. Source editing remains available throughout.
```mermaid
flowchart LR
Inputs[Known drafts / runtime choices] --> Gate{Unsaved build inputs?}
Gate -->|Yes| Save[Review exact revisions: Save and build or Cancel]
Save --> Inputs
Gate -->|No| Copy[Isolated reactor copy]
Copy --> Plan[Maven effective plan]
Plan --> Build[Scoped clean / dependencies / package / capture]
Build --> Verify[Check inputs and dependency identities]
Verify --> Publish[Atomic snapshot publication]
Publish --> Lease[Lease one build identity for discovery and preview]
Build -->|Failure or cancellation| Retain[Retain old build as outdated]
Retain --> Explicit[Explicit Preview with last successful build]
```
The result is READY, NEEDS_SAVE, CANCELLED, FAILED or STALE. NEEDS_SAVE includes the document URI and revision list and launches no Maven process. A later call must approve exactly the current list; newer edits require another review. The host saves through its own Document services. An autosave that already persisted the approved IntelliJ revision is accepted. Hosts without unsaved-input inventory fail closed instead of assuming disk is current.
The initial input closure is deliberately conservative: known non-FXML drafts throughout the reactor, plus user Maven settings/toolchains. It can request saving an unrelated reactor draft, although Maven builds only selected modules and dependencies. This avoids guessing profile-dependent build inputs before Maven has evaluated the model. Another process's unsaved buffers are not observable. Unsaved FXML is reserved for the later draft-overlay mechanism and does not by itself require Java rebuilding; edits to saved resources do affect freshness.
## Maven integration and scope
`designer-maven-plugin` is a separate Java 21 Maven plugin, installed by the bootstrap command and excluded from IDE/application runtime dependencies. It runs in the selected build JDK. It uses [MavenProject's effective model and runtime/test classpaths](https://maven.apache.org/ref/3.9.11/maven-core/apidocs/org/apache/maven/project/MavenProject.html), rather than reconstructing dependency graphs from POM text or parsing console output.
The coordinator prefers the reactor wrapper. Without one, an absolute executable Maven path must be selected; there is no PATH fallback. Maven 3.9.11 is the qualified baseline. JAVA_HOME and the leading PATH entry are set from the named build runtime; the target runtime is resolved and recorded separately. Profiles are explicitly passed, and `-pl` selects the application and optional preview module with `-am` for required reactor dependencies. The command and output are observable.
The helper goals run in this order:
1. `plan`: inspect the selected effective projects, check profile activation, and reject configured source/resource/output paths outside each isolated module before executing application lifecycle phases.
2. `clean`: remove copied stale outputs, including custom in-module output directories.
3. `dependencies`: capture resolved dependency coordinates, content hashes and file attributes before compilation for each project.
4. `package`, with tests skipped but test compilation enabled: supports both a dedicated preview module and application test-source previews.
5. `capture`: verify dependency identities again and emit effective runtime/test classpaths, source/resource roots, profiles, reactor relationships and coordinates as machine-readable properties outside the build outputs.
The returned `ReactorView` contains the selected effective closure for context matching. Generated roots outside the original source tree are not offered as editable source roots. Module-path settings are retained in the snapshot's context specification; real module-path preview launch/access remains work package 6.
## Consistency and capture
Each run creates a private copy below `.neitzel-fx-designer/work`. Conventional Maven `target` outputs, Git/editor metadata and designer caches are excluded. Ordinary source packages named `target` remain inputs. The original reactor's output directories are not modified by this build.
User `settings.xml` and `toolchains.xml` are copied into the private run directory and selected explicitly. If absent, empty settings are used. Their fingerprints are included in the input identity, but their contents are not published into the snapshot. Standard Maven distribution/global configuration and plugin-controlled external state remain build-environment inputs; this implementation is not a sandbox or a promise of reproducible arbitrary custom plugins.
Input content hashes, file identity/size/mtime and filesystem change notifications are checked around isolation, compilation and publication. Watch overflow or detected changes invalidate the build. A private source copy prevents later original edits from changing the files being compiled. This combines checks across the operation; it does not rely on one timestamp taken after compilation. Filesystem observations cannot provide an adversarial, atomic multi-file transaction against external tools that deliberately restore content and all metadata; supported usage assumes ordinary editor/build activity.
After Maven succeeds, runtime class directories, packaged reactor dependencies, external JARs, compiled resources and test output (when selected) are copied into a separate snapshot staging directory. No runtime entry points at a mutable original `target` directory or dependency-cache file. File hashes and complete directory membership are rechecked after capture. Dependency signatures recorded before compilation must still match at publication. Cancellation or changed inputs prevent current activation; partial staging is never a selectable build.
A JSON manifest records:
- Snapshot UUID, context and creation time.
- Source/settings input identities.
- Ordered snapshot-relative runtime entries and per-file SHA-256 hashes.
- Effective project/dependency coordinates and the context configuration.
- Build/target runtime paths and feature versions, command, manifest and wire versions.
Publication moves the complete staging directory atomically and then atomically updates the context's last-successful pointer. If interrupted between those operations, an unreferenced complete snapshot can be evicted; a partial snapshot cannot become the current build. Snapshot data is immutable through the store interface and independent of later build outputs. The cache is not a security boundary against a process intentionally modifying its files; lease acquisition verifies its contents and file set.
## Freshness, explicit fallback and lifetime
Starting a build marks previous results outdated. A failed, cancelled or invalidated build preserves the last successful ID and reason. `previewCurrent` rechecks source identities, known unsaved inputs, configuration identity and resolved runtime mappings before returning a lease. It throws for outdated state. Only `previewLastSuccessful` explicitly opts into the retained build; no failed build silently activates old output.
A lease supplies ordered private runtime paths and one manifest/build identity for discovery and rendering. Each lease holds a filesystem lock in a unique ticket; eviction checks those locks across threads, store instances and processes. Published last-successful snapshots are retained even without a lease. Closing a lease permits eviction of superseded snapshots. Process death releases its OS lock; abandoned tickets and staging directories are recoverable. Normal run workspaces are removed when the run finishes; abandoned private workspaces after a host crash are ignored, not activated, and may be removed when no build process owns them. There is no automatic age/size quota yet.
Cancellation stops the owned Maven process and observed descendants through the shared process runner. Capture checks cancellation between files. Cancellation after the publication commit point can complete as READY; it does not retroactively delete a successfully published build. The caller must keep the lease alive for the full worker lifetime and release it after worker shutdown; worker integration is a later gate.
## Validation
The normal [bootstrap check](../README.md#validate-the-foundation) installs the helper, runs the tests and then executes a real Maven smoke fixture using Java 21. Its four-module reactor includes a deliberately uncompilable unrelated module. Checks prove that only the selected application/dependency/preview scope builds, profile-selected module previews and test-source previews work, resources are present, original outputs remain untouched, old snapshots keep running after a rebuild, and compiler failure preserves explicit last-successful preview.
Unit/integration regressions additionally cover exact save approval, FXML draft freshness, input changes during build, cancellation, dependency/output changes during capture, staging recovery, snapshot integrity, same-JVM and separate-process leases, owner death, native IntelliJ dirty-Java inventory and autosave. The earlier configuration, protocol, bytecode, runtime artifact identity and plugin packaging checks remain required.
Acceptance on 2026-11-12: **45 tests passed** (41 Maven, four IntelliJ), with no failures/errors/skips, plus **14 real Maven/Java 21 smoke checks**. The full clean bootstrap command also passed artifact identity, bytecode/dependency isolation, target linkage, plugin packaging and documentation checks. The previously documented intentional IntelliJ until-build warning remains.
The next bootstrap step validates the typed preview API and target adapter against real NeitzelLib classpath/module-path fixtures. Setup UI, worker transport, discovery/render restart, draft overlays and the connected two-host workflow remain later gates.
+2 -2
View File
@@ -16,7 +16,7 @@ Validated examples cover [classpath with a preview module](../designer-core/src/
## Effective reactor and context selection
`ContextCatalog` consumes `ReactorView` values keyed by context. Each view carries the requested Maven profile list and the effective reactor's modules, source/resource roots and reactor dependency relationships. Profile lists must match exactly. The convenience collection constructor is for unprofiled contexts only. Obtaining these effective models from Maven belongs to Build and Refresh (work package 5); this implementation does not parse raw POMs and guess profile inheritance.
`ContextCatalog` consumes `ReactorView` values keyed by context. Each view carries the requested Maven profile list and the effective reactor's modules, source/resource roots and reactor dependency relationships. Profile lists must match exactly. The convenience collection constructor is for unprofiled contexts only. The [Build and Refresh helper](build-and-refresh.md) now obtains the selected effective closure from Maven; no raw-POM profile inheritance is guessed.
All module selections resolve relative to the configuration's canonical directory. Modules must exist, contain a POM and belong to the supplied reactor. Traversal/symlink escapes are rejected. Source roots and dependencies are validated, and matching includes the application's reactor dependency closure plus its preview module. The current model requires existing roots inside their module directories; externally generated/out-of-reactor roots need an explicit later support decision.
@@ -97,4 +97,4 @@ The standard bootstrap command runs the configuration/schema/context/runtime tes
Verified on 2026-11-12: **33 tests passed** (30 Maven, three IntelliJ), with zero failures, errors or skipped tests. The full clean bootstrap check also passed Java 21 target linkage, bytecode/dependency checks, plugin packaging and documentation validation. No socket/worker or rendered UI qualification is implied.
Next: scoped Maven build planning and immutable successful-build snapshots. Real provider discovery, metadata payload schemas, target rendering, socket integration, setup UI, OS qualification and the first connected two-host workflow retain their later gates.
Scoped Maven build planning and immutable successful-build snapshots are now [implemented](build-and-refresh.md). Real provider discovery, metadata payload schemas, target rendering, socket integration, setup UI, OS qualification and the first connected two-host workflow retain their later gates.
+3
View File
@@ -27,6 +27,7 @@ The artifact names below are established by work package 3. See [implemented bou
| `designer-intellij` | FXML editor, IDE Documents/commands, project/build/navigation integration and focus | Adapts the same core/UI; qualify build and packaging against the selected IDE SDK |
| `designer-preview-api` | Typed example descriptors and fresh preview-session lifecycle | Small project-facing artifact; no Kit or IntelliJ dependency |
| `designer-target-tools` | Discovery and NeitzelLib loading/rendering adapter in the application runtime | Uses contracts/preview API and target-compatible library dependencies; never execute application classes in a host |
| `designer-maven-plugin` | Effective project/dependency/runtime capture in the selected Maven build JVM | No Kit/IntelliJ or target application runtime dependency |
| `integration-fixtures/` | Real classpath and module-path Maven applications, examples and failure cases | Same scenarios consumed by both hosts; normal artifact dependencies |
Shared contracts may be Java artifacts without implying identical JDK/JavaFX versions in hosts and workers. Select bytecode/API baselines per module from the supported target fixtures; verify that application-facing artifacts load on the oldest initial target before publishing them. The IntelliJ packaging toolchain must be pinned and tested; a Maven reactor for shared artifacts does not assume that ordinary Maven JAR packaging produces a supported plugin.
@@ -83,6 +84,8 @@ Local settings hold installed JDK mappings, absolute machine paths and remembere
## Build and snapshot contract
Work package 5 now implements the shared [Build and Refresh workflow](build-and-refresh.md), including its validation and explicitly bounded input closure. Worker activation and UI wiring remain work package 7.
1. Resolve the selected context and required reactor dependencies, including configured preview sources. Prefer the Maven wrapper; expose the plan, command/output and cancellation. Avoid unrelated applications unless dependency resolution requires them.
2. Identify known unsaved Java, Maven and designer-configuration inputs affecting the build. Offer **Save affected files and build** or **Cancel**, listing the affected files. Do not implicitly build older disk versions. Account for IDE autosave; another process's unsaved buffers are not observable.
3. Capture the relevant input identity/revision manifest and execute the build with the selected build runtime. FXML-only draft edits have separate revisions and need not trigger Java compilation.
+2 -1
View File
@@ -12,6 +12,7 @@ Work package 3 implements a buildable service foundation. It does not register a
| designer-kit-ui | 25 | Core and Scene Builder Kit 26.0.0 (JavaFX 26); FX-thread construction seam |
| designer-standalone | 25 | Shared Kit UI; file-backed drafts and undo adapter |
| designer-target-tools | 21 | Contracts, preview API, recorded NeitzelLib snapshot; linkage smoke entry point |
| designer-maven-plugin | 21 | Maven 3.9.11 provided APIs; effective plan/dependency/runtime capture inside the build JVM |
| designer-intellij | 25 | Shared Kit UI; actual IDE Documents, commands, navigation, project disposal |
```mermaid
@@ -35,7 +36,7 @@ Document operations return completion stages. Callers must not block UI threads
Save checks the expected revision and compares disk content with the captured baseline. Conflicts preserve both draft and external content. This is initial conflict detection, **not** atomic coordination with arbitrary external writers: another writer can race the check. IntelliJ autosave/external reload reconciliation and complete encoding/line-ending fidelity need the later document integration gate. Standalone currently reads/writes UTF-8 and retains history for the lifetime of its host.
Build execution accepts an already resolved command/environment/working directory, drains both output streams and supports cancellation of the process and observed descendants, with forced termination after a bounded grace period. The lifecycle scope closes owned resources once in reverse order and continues cleanup after individual failures. Runtime lookup requires an explicit name/role mapping and has no fallback JVM. Navigation and diagnostics remain host callbacks. Build planning, immutable snapshots, worker authentication and owner-death IPC are not implemented by this process runner.
Build execution accepts an already resolved command/environment/working directory, drains both output streams and supports cancellation of the process and observed descendants, with forced termination after a bounded grace period. The lifecycle scope closes owned resources once in reverse order and continues cleanup after individual failures. Runtime lookup requires an explicit name/role mapping and has no fallback JVM. Navigation and diagnostics remain host callbacks. The shared [Build and Refresh coordinator](build-and-refresh.md) now owns planning and snapshots above this process runner. Worker transport and owner-death IPC remain later work.
## Validation and packaging
+3 -1
View File
@@ -1,6 +1,6 @@
# NeitzelFXDesigner repository bootstrap plan
Status: repository foundation implemented by the initial commit; work package 2 has a verified development artifact; work package 3 implements the module/service foundation; work package 4 implements configuration and protocol validation; work packages 5–7 remain planned. Consolidated 2026-11-12. Scope is the ordered implementation start for the [accepted initial contract](initial-project-contract.md); the initial commit creates the foundation only and does not publish a library release.
Status: repository foundation implemented by the initial commit; work package 2 has a verified development artifact; work package 3 implements the module/service foundation; work package 4 implements configuration and protocol validation; work package 5 implements Build and Refresh with immutable snapshots; work packages 6–7 remain planned. Consolidated 2026-11-12. Scope is the ordered implementation start for the [accepted initial contract](initial-project-contract.md); the initial commit creates the foundation only and does not publish a library release.
## Starting-point evidence before bootstrap
@@ -66,6 +66,8 @@ Implement JSON schema, context resolution and local runtime mappings. Turn the i
### 5. Implement Build and Refresh with immutable snapshots
Implemented: shared coordinator, scoped effective Maven capture, explicit draft save approval, isolated builds, consistent private runtime copies, atomic publication, persistent outdated/last-successful state and cross-process snapshot leases. See [implementation and limits](build-and-refresh.md). The UI and worker restart consume these interfaces in work package 7.
Resolve scoped Maven builds, preview source arrangements, explicit save/cancel and runtime choices. Capture successful outputs/resources/dependencies with an input/output manifest. Validate consistency and publish atomically; pin active snapshots against eviction. Provide explicit last-successful-build selection and stale diagnostics.
**Exit:** success, cancellation, failed build, input edits during build/capture, mutable output replacement, and snapshot-lifetime tests pass. Known unsaved build inputs cannot silently fall back to disk.
+1 -1
View File
@@ -10,7 +10,7 @@
<modules>
<module>designer-contracts</module><module>designer-preview-api</module>
<module>designer-core</module><module>designer-kit-ui</module>
<module>designer-standalone</module><module>designer-target-tools</module>
<module>designer-standalone</module><module>designer-target-tools</module><module>designer-maven-plugin</module>
</modules>
<name>Neitzel FX Designer</name>
<description>Repository foundation for the shared Kit designer and its standalone and IntelliJ hosts.</description>
+5 -2
View File
@@ -24,7 +24,7 @@ wrapper = ROOT / ("mvnw.cmd" if os.name == "nt" else "mvnw")
run([sys.executable, ROOT / "scripts/check-neitzellib-artifact.py"])
run([wrapper, "-B", "-ntp", "clean", "install"])
run([wrapper, "-B", "-ntp", "-pl", "designer-target-tools", "org.apache.maven.plugins:maven-dependency-plugin:3.6.1:build-classpath", "-DincludeScope=runtime", "-Dmdep.outputFile=target/runtime-classpath.txt"])
for module in ("contracts", "preview-api", "core", "kit-ui", "standalone", "target-tools"):
for module in ("contracts", "preview-api", "core", "kit-ui", "standalone", "target-tools", "maven-plugin"):
name = "designer-" + module
expected = 69 if module in ("kit-ui", "standalone") else 65
with zipfile.ZipFile(ROOT / name / "target" / (name + "-0.1.0-SNAPSHOT.jar")) as jar:
@@ -37,11 +37,14 @@ version = subprocess.run([str(target_java), "-version"], capture_output=True, te
if not re.search(r'version "21[.\"]', version.stderr):
sys.exit("Target linkage gate requires Java 21")
run([target_java, "-cp", classpath, "de.neitzel.fxdesigner.target.TargetToolInfo"])
run([wrapper, "-B", "-ntp", "-pl", "designer-standalone", "org.apache.maven.plugins:maven-dependency-plugin:3.6.1:build-classpath", "-DincludeScope=test", "-Dmdep.outputFile=target/smoke-classpath.txt"])
smoke_classpath = os.pathsep.join([str(ROOT / "designer-standalone/target/test-classes"), str(ROOT / "designer-standalone/target/classes"), (ROOT / "designer-standalone/target/smoke-classpath.txt").read_text().strip()])
run([Path(os.environ["JAVA_HOME"]) / "bin/java", "-cp", smoke_classpath, "de.neitzel.fxdesigner.build.BuildRefreshSmoke", ROOT, args.target_java_home])
run([ROOT / "designer-intellij" / ("gradlew.bat" if os.name == "nt" else "gradlew"), "-p", "designer-intellij", "--no-daemon", "-PideaHome=" + str(idea), "clean", "test", "buildPlugin", "verifyPluginProjectConfiguration"])
with zipfile.ZipFile(next((ROOT / "designer-intellij/build/distributions").glob("*.zip"))) as plugin:
names = plugin.namelist()
assert any("javafx-graphics-26-mac" in n for n in names), "Missing host JavaFX implementation"
assert not any("designer-target-tools" in n or "designer-preview-api" in n or "/fx-1.0" in n for n in names), "Target dependency in IDE host"
assert not any("designer-target-tools" in n or "designer-preview-api" in n or "designer-maven-plugin" in n or "/fx-1.0" in n for n in names), "Target dependency in IDE host"
for document in [ROOT / "README.md", ROOT / "AGENTS.md", ROOT / "THIRD-PARTY-NOTICES.md", *sorted((ROOT / "docs").rglob("*.md"))]:
content = document.read_text(encoding="utf-8")
for example in re.findall(r"```json\n(.*?)\n```", content, re.S):