Discover Repeat collections and compatible Components in both hosts

This commit is contained in:
2026-11-13 14:47:21 +01:00
parent 0300062fb6
commit 0d14ac14e0
28 changed files with 969 additions and 44 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ Shared Scene Builder Kit designer for NeitzelLib FX, with minimal standalone and
The first workflow is runnable in both hosts: Build and Refresh publishes immutable snapshots; authenticated target workers discover examples and render parent/Component FXML drafts into the shared Kit canvas. Source and supported canvas edits use host-owned documents and undo/redo. See [the connected workflow](docs/first-connected-workflow.md) for architecture, launch commands, evidence and limits.
The repository builds six shared Maven modules, a Maven capture plugin and a separate IntelliJ adapter plugin. Application code runs only in target JVMs. The shared UI now applies [source-preserving edits](docs/source-preserving-projection.md), supports nested/anonymous Components and fx:root bodies, and provides Source/Design/Split modes. Retained [target sessions, target sizing, interactive preview and Component/Row navigation](docs/target-runtime-sessions.md) now extend that workflow. The shared [binding discovery browser](docs/binding-discovery.md) now lists declared Model paths and direction-aware target compatibility. The [DirectBinding inspector](docs/direct-binding-inspector.md) creates, edits and removes declarations through source-preserving host Undo/Redo. Shared [Converter discovery and binding validation](docs/converter-validation.md) now adds capability-based proposals, document diagnostics and preview gating. [Custom ViewModel discovery and Computed-binding validation](docs/viewmodel-computed-validation.md) now follow the explicitly selected Controller or Component in both hosts. The [Computed-binding inspector](docs/computed-binding-inspector.md) now creates, changes and removes Computed declarations with method suggestions and shared source-preserving Undo/Redo. The [Repeat inspector](docs/repeat-binding-inspector.md) adds source-preserving collection declarations with host Undo/Redo. Broader binding authoring, generation, structural editing and product packaging remain later work.
The repository builds six shared Maven modules, a Maven capture plugin and a separate IntelliJ adapter plugin. Application code runs only in target JVMs. The shared UI now applies [source-preserving edits](docs/source-preserving-projection.md), supports nested/anonymous Components and fx:root bodies, and provides Source/Design/Split modes. Retained [target sessions, target sizing, interactive preview and Component/Row navigation](docs/target-runtime-sessions.md) now extend that workflow. The shared [binding discovery browser](docs/binding-discovery.md) now lists declared Model paths and direction-aware target compatibility. The [DirectBinding inspector](docs/direct-binding-inspector.md) creates, edits and removes declarations through source-preserving host Undo/Redo. Shared [Converter discovery and binding validation](docs/converter-validation.md) now adds capability-based proposals, document diagnostics and preview gating. [Custom ViewModel discovery and Computed-binding validation](docs/viewmodel-computed-validation.md) now follow the explicitly selected Controller or Component in both hosts. The [Computed-binding inspector](docs/computed-binding-inspector.md) now creates, changes and removes Computed declarations with method suggestions and shared source-preserving Undo/Redo. The [Repeat inspector](docs/repeat-binding-inspector.md) adds source-preserving collection declarations with host Undo/Redo and [collection/Component compatibility discovery](docs/repeat-compatibility.md). Broader binding authoring, generation, structural editing and product packaging remain later work.
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 and validation rules.
@@ -14,7 +14,45 @@ public record BindingMetadata(
List<Problem> problems,
String controller,
String viewModel,
List<Computed> computed) {
List<Computed> computed,
List<RepeatOption> collections,
List<RepeatOption> components,
List<RepeatOption> panes) {
public record RepeatOption(String name, String type, String status, String reason) {
public String toString() {
return name + " : " + type + " — " + status + " (" + reason + ")";
}
}
public BindingMetadata(
String model,
String path,
String direction,
List<Attribute> attributes,
List<Target> targets,
List<String> diagnostics,
List<Converter> converters,
List<Problem> problems,
String controller,
String viewModel,
List<Computed> computed) {
this(
model,
path,
direction,
attributes,
targets,
diagnostics,
converters,
problems,
controller,
viewModel,
computed,
List.of(),
List.of(),
List.of());
}
public record Computed(String method, String returnType, String status, String reason) {
public String toString() {
return method + " : " + returnType + " — " + status + " (" + reason + ")";
@@ -78,13 +116,19 @@ public record BindingMetadata(
converters = List.copyOf(converters);
problems = List.copyOf(problems);
computed = List.copyOf(computed);
collections = List.copyOf(collections);
components = List.copyOf(components);
panes = List.copyOf(panes);
if (!Set.of("view", "model", "both").contains(direction))
throw new IllegalArgumentException("Invalid direction");
}
public Map<String, String> encode() {
var m = new TreeMap<String, String>();
m.put("format", "3");
m.put("format", "4");
encodeOptions(m, "rc", collections);
encodeOptions(m, "rt", components);
encodeOptions(m, "rp", panes);
m.put("controller", controller);
m.put("viewModel", viewModel);
m.put("computed", "" + computed.size());
@@ -140,6 +184,33 @@ public record BindingMetadata(
return m;
}
private static void encodeOptions(
Map<String, String> m, String prefix, List<RepeatOption> values) {
m.put(prefix, "" + values.size());
for (int i = 0; i < values.size(); i++) {
var v = values.get(i);
String p = prefix + "." + i + ".";
m.put(p + "name", v.name());
m.put(p + "type", v.type());
m.put(p + "status", v.status());
m.put(p + "reason", v.reason());
}
}
private static List<RepeatOption> decodeOptions(Map<String, String> m, String prefix) {
var result = new ArrayList<RepeatOption>();
for (int i = 0; i < count(m, prefix); i++) {
String p = prefix + "." + i + ".";
result.add(
new RepeatOption(
field(m, p + "name"),
field(m, p + "type"),
field(m, p + "status"),
field(m, p + "reason")));
}
return result;
}
private static String field(Map<String, String> m, String key) {
return Objects.requireNonNull(m.get(key), "Missing " + key);
}
@@ -151,7 +222,7 @@ public record BindingMetadata(
}
public static BindingMetadata decode(Map<String, String> m) {
if (!"3".equals(field(m, "format")))
if (!"4".equals(field(m, "format")))
throw new IllegalArgumentException("Unsupported binding metadata");
var a = new ArrayList<Attribute>();
var t = new ArrayList<Target>();
@@ -212,6 +283,9 @@ public record BindingMetadata(
v,
field(m, "controller"),
field(m, "viewModel"),
methods);
methods,
decodeOptions(m, "rc"),
decodeOptions(m, "rt"),
decodeOptions(m, "rp"));
}
}
@@ -82,8 +82,8 @@ public final class ProtocolSession implements AutoCloseable {
&& supported.contains("interactive-v1")
&& hello.capabilities().contains("interactive-v1")) usable.add(Operation.INTERACTIVE);
if (hello.metadata().major() == 1
&& supported.contains("bindings-v3")
&& hello.capabilities().contains("bindings-v3")) usable.add(Operation.BINDINGS);
&& supported.contains("bindings-v4")
&& hello.capabilities().contains("bindings-v4")) usable.add(Operation.BINDINGS);
var requiredByEither = new HashSet<>(required);
requiredByEither.addAll(hello.required());
if ((requiredByEither.contains("discover-v1") && !usable.contains(Operation.DISCOVER))
@@ -92,7 +92,7 @@ public final class ProtocolSession implements AutoCloseable {
if ((requiredByEither.contains("layout-v1") && !usable.contains(Operation.MEASURE))
|| (requiredByEither.contains("interactive-v1") && !usable.contains(Operation.INTERACTIVE)))
throw new IllegalArgumentException("Required capability version incompatible");
if (requiredByEither.contains("bindings-v3") && !usable.contains(Operation.BINDINGS))
if (requiredByEither.contains("bindings-v4") && !usable.contains(Operation.BINDINGS))
throw new IllegalArgumentException("Required capability version incompatible");
operations = Set.copyOf(usable);
state = State.READY;
@@ -26,7 +26,10 @@ class BindingMetadataTest {
"app.ViewModel",
List.of(
new BindingMetadata.Computed(
"#caption", "java.lang.String", "COMPATIBLE", "Declared type")));
"#caption", "java.lang.String", "COMPATIBLE", "Declared type")),
List.of(new BindingMetadata.RepeatOption("visits", "Address", "COMPATIBLE", "Element")),
List.of(new BindingMetadata.RepeatOption("app.Card", "Address", "COMPATIBLE", "Model")),
List.of(new BindingMetadata.RepeatOption("rows", "VBox", "COMPATIBLE", "Pane")));
assertEquals(
m, BindingMetadata.decode(PreviewMessages.decode(PreviewMessages.encode(m.encode()))));
var id = new Identity("s", "r", "c", "b", Map.of("file", 1L), 0, 0);
@@ -41,14 +44,14 @@ class BindingMetadataTest {
new Identity("s", "r", "c", "b", Map.of(), 0, 0),
new byte[0]));
var invalid = new HashMap<>(m.encode());
invalid.put("converters", "99999");
invalid.put("rc", "99999");
assertThrows(IllegalArgumentException.class, () -> BindingMetadata.decode(invalid));
}
@Test
void negotiationRequiresMetadataVersion() throws Exception {
String token = ProtocolSession.newToken();
var session = new ProtocolSession("s", 1, token, Set.of("bindings-v3"), Set.of("bindings-v3"));
var session = new ProtocolSession("s", 1, token, Set.of("bindings-v4"), Set.of("bindings-v4"));
assertTrue(
session
.authenticate(
@@ -60,11 +63,11 @@ class BindingMetadataTest {
new Version(1, 0),
new Version(1, 0),
new Version(1, 0),
Set.of("bindings-v3"),
Set.of("bindings-v4"),
Set.of()))
.contains(Operation.BINDINGS));
var oldWorker =
new ProtocolSession("s", 1, token, Set.of("bindings-v3"), Set.of("bindings-v3"));
new ProtocolSession("s", 1, token, Set.of("bindings-v4"), Set.of("bindings-v4"));
assertThrows(
IllegalArgumentException.class,
() ->
@@ -77,10 +80,10 @@ class BindingMetadataTest {
new Version(1, 0),
new Version(1, 0),
new Version(1, 0),
Set.of("bindings-v2"),
Set.of("bindings-v3"),
Set.of())));
var incompatible =
new ProtocolSession("s", 1, token, Set.of("bindings-v3"), Set.of("bindings-v3"));
new ProtocolSession("s", 1, token, Set.of("bindings-v4"), Set.of("bindings-v4"));
assertThrows(
IllegalArgumentException.class,
() ->
@@ -93,7 +96,7 @@ class BindingMetadataTest {
new Version(1, 0),
new Version(2, 0),
new Version(1, 0),
Set.of("bindings-v3"),
Set.of("bindings-v4"),
Set.of())));
}
}
@@ -108,7 +108,7 @@ public final class PreviewWorker implements AutoCloseable {
id,
process.pid(),
token,
Set.of("discover-v1", "render-rgba-v1", "layout-v1", "interactive-v1", "bindings-v3"),
Set.of("discover-v1", "render-rgba-v1", "layout-v1", "interactive-v1", "bindings-v4"),
config.requiredCapabilities());
}
drain(process.getInputStream(), log);
@@ -24,6 +24,9 @@ final class BindingEditor extends VBox {
pane = field("pane"),
component = field("component"),
collection = field("collection");
private final ComboBox<BindingMetadata.RepeatOption> repeatCollections = new ComboBox<>(),
repeatComponents = new ComboBox<>(),
repeatPanes = new ComboBox<>();
private final ComboBox<BindingMetadata.Computed> methods = new ComboBox<>();
private final BindingDocument.Kind kind;
private final ComboBox<BindingMetadata.Converter> converters = new ComboBox<>();
@@ -36,7 +39,7 @@ final class BindingEditor extends VBox {
remove = new Button("Remove binding");
private String source = "";
private Integer selected;
private boolean loading, writable, active, followingCanvas;
private boolean loading, writable, active, followingCanvas, updatingChoices;
private final Consumer<String> navigate;
BindingEditor(
@@ -66,7 +69,7 @@ final class BindingEditor extends VBox {
remove.setText("Remove Computed binding");
}
if (repeat) {
inspect.setText("Validate Repeat bindings");
inspect.setText("Discover / Validate Repeat");
fresh.setText("New Repeat binding");
apply.setText("Apply Repeat binding");
remove.setText("Remove Repeat binding");
@@ -141,8 +144,34 @@ final class BindingEditor extends VBox {
field.textProperty().addListener((o, a, b) -> methods.getItems().clear());
}
if (repeat) {
grid.addRow(0, new Label("Pane (fx:id)"), pane, new Label("Collection path"), collection);
grid.addRow(1, new Label("Component class"), component);
repeatCollections.setId("repeat-collections");
repeatComponents.setId("repeat-components");
repeatPanes.setId("repeat-panes");
repeatCollections.setPrefWidth(420);
repeatComponents.setPrefWidth(680);
repeatPanes.setPrefWidth(420);
repeatCollections.setOnAction(
e -> {
var v = repeatCollections.getValue();
if (v != null && !updatingChoices) {
collection.setText(v.name());
discover.accept(values());
}
});
repeatComponents.setOnAction(
e -> {
var v = repeatComponents.getValue();
if (v != null && !updatingChoices) component.setText(v.name());
});
repeatPanes.setOnAction(
e -> {
var v = repeatPanes.getValue();
if (v != null && !updatingChoices) pane.setText(v.name());
});
collection.textProperty().addListener((o, a, b) -> repeatComponents.getItems().clear());
grid.addRow(0, new Label("Pane (fx:id)"), pane, repeatPanes);
grid.addRow(1, new Label("Collection path"), collection, repeatCollections);
grid.addRow(2, new Label("Component class"), component, repeatComponents);
}
getChildren()
.addAll(
@@ -209,6 +238,9 @@ final class BindingEditor extends VBox {
if (source.equals(text) && writable == editable) return;
converters.getItems().clear();
methods.getItems().clear();
repeatCollections.getItems().clear();
repeatComponents.getItems().clear();
repeatPanes.getItems().clear();
source = text;
writable = editable;
loading = true;
@@ -276,16 +308,25 @@ final class BindingEditor extends VBox {
if (kind == BindingDocument.Kind.REPEAT) {
try {
var errors = new BindingDocument(source, kind).errors();
status.setText(
provenance
+ "\n"
+ (errors.isEmpty() ? "No structural binding errors." : String.join("\n", errors))
+ "\n"
+ "Collection and Component compatibility have not been checked. Render drafts to"
+ " test them.");
if (!errors.isEmpty()) status.setText(status.getText() + "\n" + String.join("\n", errors));
} catch (Exception error) {
status.setText("Validation unavailable: " + error.getMessage());
}
status.setText(
status.getText()
+ "\n"
+ "Compatibility uses declarations; construction and runtime values remain preview"
+ " checks.");
if (requested != null && values().equals(requested)) {
updatingChoices = true;
try {
repeatCollections.getItems().setAll(metadata.collections());
repeatComponents.getItems().setAll(metadata.components());
repeatPanes.getItems().setAll(metadata.panes());
} finally {
updatingChoices = false;
}
}
return;
}
if (requested == null || !values().equals(requested)) return;
@@ -303,6 +344,17 @@ final class BindingEditor extends VBox {
}
}
void invalidateDiscovery() {
updatingChoices = true;
try {
repeatCollections.getItems().clear();
repeatComponents.getItems().clear();
repeatPanes.getItems().clear();
} finally {
updatingChoices = false;
}
}
void error(String message) {
status.setText(message);
}
@@ -682,6 +682,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
validationPreview = false;
bindingWorkflow.cancel();
bindingBrowser.invalidate(message);
repeatEditor.invalidateDiscovery();
}
private void editBinding(BindingEditor.Intent intent) {
@@ -744,7 +745,11 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
.bindingDetails(
last,
captured,
values == null ? "" : values.path(),
values == null
? ""
: values.pane().isEmpty() && values.collection().isEmpty()
? values.path()
: values.collection(),
values == null ? "both" : values.direction(),
values == null ? "" : values.control(),
values == null ? "" : values.property())
@@ -727,14 +727,105 @@ public final class ProjectionHostChecks {
java.nio.file.Path.of(document.snapshot().toCompletableFuture().get().document()))
.equals(changed),
"Repeat save changed source");
click(pane, "Validate Repeat bindings");
selectRepeat(pane, "otherVisits");
click(pane, "Discover / Validate Repeat");
await(
() ->
fx(
() ->
((Label) pane.lookup("#repeat-status"))
.getText()
.contains("Collection and Component compatibility")));
.contains("Compatibility uses declarations")));
await(() -> fx(() -> !((ComboBox<?>) pane.lookup("#repeat-components")).getItems().isEmpty()));
fx(
() -> {
var choices =
(ComboBox<de.neitzel.fxdesigner.protocol.BindingMetadata.RepeatOption>)
pane.lookup("#repeat-components");
check(
choices.getItems().stream()
.anyMatch(
c ->
c.name().equals("fixture.components.AddressForm")
&& c.status().equals("COMPATIBLE")),
"Compatible Component choice absent");
check(
choices.getItems().stream()
.anyMatch(
c ->
c.name().equals("fixture.components.WrongRepeatCard")
&& c.status().equals("INCOMPATIBLE")),
"Incompatible Component not explained");
var panes =
(ComboBox<de.neitzel.fxdesigner.protocol.BindingMetadata.RepeatOption>)
pane.lookup("#repeat-panes");
check(
panes.getItems().stream()
.anyMatch(c -> c.name().equals("action") && c.status().equals("INCOMPATIBLE")),
"Non-Pane target accepted");
check(
((TextField) pane.lookup("#repeat-component"))
.getText()
.equals("fixture.components.VisitRow"),
"Discovery replaced explicit Component");
var collections =
(ComboBox<de.neitzel.fxdesigner.protocol.BindingMetadata.RepeatOption>)
pane.lookup("#repeat-collections");
collections.setValue(
collections.getItems().stream()
.filter(c -> c.name().equals("visits"))
.findFirst()
.orElseThrow());
return null;
});
await(() -> fx(() -> !((ComboBox<?>) pane.lookup("#repeat-components")).getItems().isEmpty()));
check(documentText(document).equals(changed), "Repeat discovery changed source");
fx(
() -> {
var choices =
(ComboBox<de.neitzel.fxdesigner.protocol.BindingMetadata.RepeatOption>)
pane.lookup("#repeat-components");
choices.setValue(
choices.getItems().stream()
.filter(c -> c.name().equals("fixture.components.WrongRepeatCard"))
.findFirst()
.orElseThrow());
return null;
});
check(documentText(document).equals(changed), "Component choice applied without review");
click(pane, "Apply Repeat binding");
String mismatched =
changed.replace(
"component=\"fixture.components.VisitRow\"",
"component=\"fixture.components.WrongRepeatCard\"");
await(() -> documentText(document).equals(mismatched));
await(() -> fx(() -> source(pane).getText().equals(mismatched)));
document
.save(document.snapshot().toCompletableFuture().get().revision())
.toCompletableFuture()
.get();
click(pane, "Render drafts");
await(
() ->
fx(
() ->
((Label) pane.lookup("#repeat-status"))
.getText()
.contains("incompatible with Component Model")));
await(
() ->
fx(
() ->
pane.lookupAll(".label").stream()
.anyMatch(
n ->
n instanceof Label l
&& l.getText() != null
&& l.getText().startsWith("Preview blocked:"))));
repeatHistory(pane, document, changed, mismatched);
click(pane, "Undo");
await(() -> documentText(document).equals(changed));
await(() -> fx(() -> source(pane).getText().equals(changed)));
click(pane, "Render drafts");
await(
() ->
@@ -773,6 +864,7 @@ public final class ProjectionHostChecks {
click(pane, "Apply Repeat binding");
String invalid = replaced.replace("collection=\"visits\"/>", "collection=\"invalid path\"/>");
await(() -> documentText(document).equals(invalid));
await(() -> fx(() -> source(pane).getText().equals(invalid)));
document
.save(document.snapshot().toCompletableFuture().get().revision())
.toCompletableFuture()
@@ -23,6 +23,8 @@ public final class BindingDiscovery {
private record TargetType(Class<?> type, boolean writable) {}
private final Map<String, Class<?>> controlTypes = new HashMap<>();
private RepeatDiscovery repeats;
private final Map<String, TargetType> targetTypes = new HashMap<>();
private BindingDiscovery(ClassLoader loader) {
@@ -63,7 +65,7 @@ public final class BindingDiscovery {
return new BindingDiscovery(loader).inspect(fxml, path, direction, control, property, resource);
}
private static Document parse(String xml) throws Exception {
static Document parse(String xml) throws Exception {
if (xml.length() > 512 * 1024) throw new IOException("FXML metadata input exceeds limit");
var f = javax.xml.parsers.DocumentBuilderFactory.newInstance();
f.setNamespaceAware(true);
@@ -75,7 +77,7 @@ public final class BindingDiscovery {
return f.newDocumentBuilder().parse(new org.xml.sax.InputSource(new StringReader(xml)));
}
private static String model(Document doc) {
static String model(Document doc) {
String model = "";
for (Node n = doc.getFirstChild(); n != null; n = n.getNextSibling())
if (n instanceof ProcessingInstruction pi && pi.getTarget().equals("neitzel-model")) {
@@ -238,6 +240,7 @@ public final class BindingDiscovery {
&& owner.controller != null
? owner.controller
: resolve(e, imports);
controlTypes.put(id, control);
String componentModel = "";
var componentResource = control.getResource(control.getSimpleName() + ".fxml");
if (componentResource != null)
@@ -321,6 +324,7 @@ public final class BindingDiscovery {
bypassesConverter(selected)
? List.<BindingMetadata.Converter>of()
: converters.candidates(source, chosen == null ? null : chosen.type(), direction);
repeats = new RepeatDiscovery(loader, attributes, owner, incompleteModel, controlTypes, ids);
return new BindingMetadata(
model,
selected,
@@ -332,7 +336,10 @@ public final class BindingDiscovery {
validate(doc, imports, ids, converters),
owner.controller == null ? "" : owner.controller.getName(),
owner.viewModel == null ? "" : owner.viewModel.getName(),
owner.methods(chosen == null ? null : chosen.type()));
owner.methods(chosen == null ? null : chosen.type()),
repeats.collections(),
repeats.candidates(selected),
repeats.panes());
}
private void selectOwner(Document doc, List<String> imports, String resource, Class<?> model)
@@ -412,8 +419,9 @@ public final class BindingDiscovery {
var nodes = doc.getElementsByTagName("*");
for (int i = 0; i < nodes.getLength(); i++) {
var e = (Element) nodes.item(i);
if (!library(e, "DirectBinding", imports) && !library(e, "ComputedBinding", imports))
continue;
if (!library(e, "DirectBinding", imports)
&& !library(e, "ComputedBinding", imports)
&& !library(e, "Repeat", imports)) continue;
Node parent = e.getParentNode();
if (parent instanceof Element p && p.getTagName().equals("elements"))
parent = p.getParentNode();
@@ -423,6 +431,22 @@ public final class BindingDiscovery {
|| !FX.equals(define.getNamespaceURI())
|| !"define".equals(define.getLocalName())
|| define.getParentNode() != doc.getDocumentElement()) continue;
if (library(e, "Repeat", imports)) {
String pane = e.getAttribute("pane");
boolean nested = false;
for (Node n = e.getFirstChild(); n != null; n = n.getNextSibling())
if (n instanceof Element) nested = true;
if (nested)
problems.add(
new Problem(pane, "children", "WARNING", "Nested Repeat requires Source review"));
else {
if (!occupied.add("Repeat:" + pane))
problems.add(new Problem(pane, "children", "ERROR", "Duplicate Repeat pane"));
problems.addAll(
repeats.validate(pane, e.getAttribute("collection"), e.getAttribute("component")));
}
continue;
}
String control = e.getAttribute("control"),
property = e.getAttribute("property"),
key = control + "." + property;
@@ -555,7 +579,7 @@ public final class BindingDiscovery {
return candidates.iterator().next();
}
private static Class<?> propertyType(Type type, Map<TypeVariable<?>, Type> inherited) {
static Class<?> propertyType(Type type, Map<TypeVariable<?>, Type> inherited) {
Class<?> raw;
var bindings = new HashMap<>(inherited);
if (type instanceof ParameterizedType p && p.getRawType() instanceof Class<?> c) {
@@ -0,0 +1,350 @@
package de.neitzel.fxdesigner.target;
import de.neitzel.fxdesigner.protocol.BindingMetadata.*;
import java.io.*;
import java.lang.reflect.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import java.util.jar.JarFile;
import javafx.beans.property.Property;
import javafx.beans.value.ObservableValue;
import javafx.collections.ObservableList;
import javafx.scene.Parent;
import javafx.scene.layout.Pane;
import org.w3c.dom.*;
/** Signature/resource-only Repeat checks. Never invokes an application accessor or constructor. */
final class RepeatDiscovery {
private final ClassLoader loader;
private final Map<String, RepeatOption> collections = new TreeMap<>();
private final Map<String, Class<?>> controls;
private final Set<String> ids;
private final boolean incomplete;
private final Set<String> nonCollections = new HashSet<>();
private final Map<String, ComponentInfo> components = new TreeMap<>();
private final SortedSet<String> names = new TreeSet<>();
private record ComponentInfo(Class<?> model, Class<?> property, String declared, String error) {}
RepeatDiscovery(
ClassLoader loader,
List<Attribute> attributes,
ControllerMetadata owner,
boolean incomplete,
Map<String, Class<?>> controls,
Set<String> ids)
throws Exception {
this.loader = loader;
this.controls = controls;
this.ids = ids;
this.incomplete = incomplete;
if (!owner.dynamicProperties && !owner.unknownController)
for (var a : attributes)
if (Set.of("VALUE", "BUILTIN", "COMPLEX").contains(a.kind())) nonCollections.add(a.path());
for (var a : attributes) {
if (!a.kind().equals("COLLECTION")
&& !a.kind().equals("VIEWMODEL")
&& !a.kind().equals("UNRESOLVED")) continue;
Class<?> raw;
try {
raw = a.type().equals("?") ? Object.class : load(a.type());
} catch (ClassNotFoundException primitive) {
continue;
}
String element = a.elementType();
boolean wrapped = a.kind().equals("COLLECTION");
if (!wrapped && owner.viewModel != null) {
try {
Method m = owner.viewModel.getMethod(a.path() + "Property");
Type value = argument(m.getGenericReturnType(), ObservableValue.class);
element = element(argument(value, Collection.class));
} catch (NoSuchMethodException ignored) {
}
}
if (raw != Object.class && !Collection.class.isAssignableFrom(raw)) {
if (!owner.dynamicProperties && !owner.unknownController) nonCollections.add(a.path());
continue;
}
if (owner.dynamicProperties || owner.unknownController) element = "";
boolean unknown =
owner.dynamicProperties
|| owner.unknownController
|| element.isEmpty()
|| element.equals(Object.class.getName())
|| (!wrapped && !ObservableList.class.isAssignableFrom(raw));
collections.put(
a.path(),
new RepeatOption(
a.path(),
element.isEmpty() ? "?" : element,
unknown ? "UNVERIFIED" : "COMPATIBLE",
unknown
? "Element type or ObservableList backing is unresolved"
: "Declared collection element type; runtime values may be null"));
}
indexResources();
}
private Class<?> load(String name) throws ClassNotFoundException {
return Class.forName(name, false, loader);
}
List<RepeatOption> collections() {
return List.copyOf(collections.values());
}
List<RepeatOption> panes() {
return ids.stream()
.sorted()
.map(
id -> {
Class<?> type = controls.get(id);
return new RepeatOption(
id,
type == null ? "?" : type.getName(),
type == null
? "UNVERIFIED"
: Pane.class.isAssignableFrom(type) ? "COMPATIBLE" : "INCOMPATIBLE",
type == null
? "Pane type unavailable"
: Pane.class.isAssignableFrom(type)
? "JavaFX Pane"
: "Repeat requires a JavaFX Pane");
})
.toList();
}
List<RepeatOption> candidates(String path) {
return names.stream().map(n -> component(n, path)).toList();
}
RepeatOption collection(String path) {
if (nonCollections.contains(path))
return new RepeatOption(path, "?", "INCOMPATIBLE", "Repeat requires a collection: " + path);
var c = collections.get(path);
return c != null
? c
: new RepeatOption(
path,
"?",
incomplete ? "UNVERIFIED" : "INCOMPATIBLE",
incomplete ? "Collection path unresolved" : "No discoverable collection: " + path);
}
RepeatOption component(String name, String path) {
ComponentInfo info = components.computeIfAbsent(name, this::readComponent);
if (info.error() != null)
return new RepeatOption(name, info.declared(), "INCOMPATIBLE", info.error());
var source = collection(path);
if (source.status().equals("INCOMPATIBLE"))
return new RepeatOption(
name, info.declared(), "UNVERIFIED", "Choose a collection to compare element types");
Class<?> element;
try {
element = source.type().equals("?") ? null : load(source.type());
} catch (ClassNotFoundException e) {
element = null;
}
if (element != null && element != Object.class) {
for (var constraint : Arrays.asList(info.model(), info.property()))
if (constraint != null
&& constraint != Object.class
&& !constraint.isAssignableFrom(element))
return new RepeatOption(
name,
info.declared(),
"INCOMPATIBLE",
"Collection element "
+ element.getName()
+ " is incompatible with Component Model "
+ constraint.getName());
}
boolean verified =
source.status().equals("COMPATIBLE")
&& element != null
&& element != Object.class
&& (info.model() != null || info.property() != null && info.property() != Object.class);
return new RepeatOption(
name,
info.declared(),
verified ? "COMPATIBLE" : "UNVERIFIED",
verified
? "Declared element fits Component Model; construction remains a runtime check"
: "Collection or Component Model type is unresolved");
}
List<Problem> validate(String pane, String path, String component) {
var problems = new ArrayList<Problem>();
if (!ids.contains(pane))
problems.add(new Problem(pane, "children", "ERROR", "Unknown Repeat pane: " + pane));
else if (!controls.containsKey(pane))
problems.add(new Problem(pane, "children", "WARNING", "Repeat pane type unavailable"));
else if (!Pane.class.isAssignableFrom(controls.get(pane)))
problems.add(new Problem(pane, "children", "ERROR", "Repeat requires a JavaFX Pane"));
var c = collection(path);
if (!c.status().equals("COMPATIBLE"))
problems.add(
new Problem(
pane,
"children",
c.status().equals("INCOMPATIBLE") ? "ERROR" : "WARNING",
c.reason()));
var target = component(component, path);
if (!target.status().equals("COMPATIBLE"))
problems.add(
new Problem(
pane,
"children",
target.status().equals("INCOMPATIBLE") ? "ERROR" : "WARNING",
target.reason()));
return problems;
}
private ComponentInfo readComponent(String name) {
String declared = "?";
try {
Class<?> type = load(name);
if (!Parent.class.isAssignableFrom(type)
|| !Modifier.isPublic(type.getModifiers())
|| Modifier.isAbstract(type.getModifiers()))
throw new IllegalArgumentException(
"Repeat Component must be a public concrete JavaFX Parent");
Method accessor = type.getMethod("modelProperty");
if (!Property.class.isAssignableFrom(accessor.getReturnType())
|| !ControllerMetadata.accessible(accessor.getDeclaringClass(), true))
throw new IllegalArgumentException("Component needs an accessible writable modelProperty");
Class<?> property = BindingDiscovery.propertyType(accessor.getGenericReturnType(), Map.of());
var resource = type.getResource(type.getSimpleName() + ".fxml");
if (resource == null)
throw new IllegalArgumentException("Component FXML unavailable: " + name);
Document doc;
try (var in = resource.openStream()) {
byte[] bytes = in.readNBytes(512 * 1024 + 1);
if (bytes.length > 512 * 1024) throw new IOException("Component FXML exceeds limit");
doc = BindingDiscovery.parse(new String(bytes, StandardCharsets.UTF_8));
}
Element root = doc.getDocumentElement();
if (!"http://javafx.com/fxml/1".equals(root.getNamespaceURI())
|| !"root".equals(root.getLocalName()))
throw new IllegalArgumentException("Component FXML requires fx:root");
if (root.hasAttributeNS("http://javafx.com/fxml/1", "controller"))
throw new IllegalArgumentException("Component FXML must not override its Controller");
String base = root.getAttribute("type");
var roots = new HashSet<Class<?>>();
if (base.contains(".")) roots.add(load(base));
else
for (Node n = doc.getFirstChild(); n != null; n = n.getNextSibling())
if (n instanceof ProcessingInstruction pi && pi.getTarget().equals("import")) {
String imp = pi.getData().trim();
try {
if (imp.endsWith(".*")) roots.add(load(imp.substring(0, imp.length() - 1) + base));
else if (imp.endsWith("." + base)) roots.add(load(imp));
} catch (ClassNotFoundException ignored) {
}
}
if (roots.size() != 1 || !roots.iterator().next().isAssignableFrom(type))
throw new IllegalArgumentException("Component fx:root type is incompatible or unresolved");
declared = BindingDiscovery.model(doc);
Class<?> model = declared.isEmpty() ? null : load(declared);
if (declared.isEmpty()) declared = property == null ? "?" : property.getName();
return new ComponentInfo(model, property, declared, null);
} catch (Exception | LinkageError e) {
return new ComponentInfo(
null, null, declared, "Component unavailable: " + name + ": " + e.getMessage());
}
}
private void indexResources() throws IOException {
var entries = new LinkedHashSet<String>();
for (String key : List.of("java.class.path", "jdk.module.path"))
entries.addAll(Arrays.asList(System.getProperty(key, "").split(File.pathSeparator)));
int count = 0;
for (String entry : entries) {
if (entry.isEmpty()) continue;
Path path = Path.of(entry);
if (Files.isDirectory(path)) {
try (var files = Files.walk(path)) {
var it = files.filter(p -> p.toString().endsWith(".fxml")).iterator();
while (it.hasNext()) {
if (++count > 4096) throw new IOException("Too many Component resources");
index(path.relativize(it.next()).toString().replace(File.separatorChar, '/'));
}
}
} else if (Files.isRegularFile(path) && entry.endsWith(".jar")) {
try (var jar = new JarFile(path.toFile(), false, JarFile.OPEN_READ, Runtime.version())) {
var it = jar.versionedStream().filter(e -> e.getName().endsWith(".fxml")).iterator();
while (it.hasNext()) {
if (++count > 4096) throw new IOException("Too many Component resources");
index(it.next().getName());
}
}
}
}
}
private void index(String resource) throws IOException {
if (resource.startsWith("META-INF/")) return;
String name = resource.substring(0, resource.length() - 5).replace('/', '.');
try {
Class<?> type = load(name);
if (Parent.class.isAssignableFrom(type)) names.add(name);
if (names.size() > 256) throw new IOException("Too many Repeat Components");
} catch (ClassNotFoundException | LinkageError ignored) {
}
}
private record Parameterized(Type raw, Type[] arguments) implements ParameterizedType {
public Type getRawType() {
return raw;
}
public Type[] getActualTypeArguments() {
return arguments.clone();
}
public Type getOwnerType() {
return null;
}
}
private static Type substitute(Type t, Map<TypeVariable<?>, Type> vars) {
if (t instanceof TypeVariable<?> v) return vars.getOrDefault(v, v);
if (t instanceof ParameterizedType p)
return new Parameterized(
p.getRawType(),
Arrays.stream(p.getActualTypeArguments())
.map(a -> substitute(a, vars))
.toArray(Type[]::new));
return t;
}
private static Type argument(Type type, Class<?> target) {
if (type == null) return null;
Class<?> raw;
var vars = new HashMap<TypeVariable<?>, Type>();
if (type instanceof ParameterizedType p && p.getRawType() instanceof Class<?> c) {
raw = c;
for (int i = 0; i < c.getTypeParameters().length; i++)
vars.put(c.getTypeParameters()[i], p.getActualTypeArguments()[i]);
} else if (type instanceof Class<?> c) raw = c;
else return null;
if (raw == target) return vars.get(raw.getTypeParameters()[0]);
for (Type parent : raw.getGenericInterfaces()) {
Type value = argument(substitute(parent, vars), target);
if (value != null) return value;
}
return raw.getGenericSuperclass() == null
? null
: argument(substitute(raw.getGenericSuperclass(), vars), target);
}
private static String element(Type t) {
if (t instanceof Class<?> c) return c.getName();
if (t instanceof WildcardType w
&& w.getLowerBounds().length == 0
&& w.getUpperBounds().length == 1
&& w.getUpperBounds()[0] instanceof Class<?> c) return c.getName();
return "";
}
}
@@ -92,7 +92,7 @@ public final class WorkerMain {
new Version(1, 0),
new Version(1, 0),
new Version(1, 0),
Set.of("discover-v1", "render-rgba-v1", "layout-v1", "interactive-v1", "bindings-v3"),
Set.of("discover-v1", "render-rgba-v1", "layout-v1", "interactive-v1", "bindings-v4"),
Set.of()));
var welcome = FrameCodec.read(socket.getInputStream());
if (!(welcome instanceof Welcome w) || !session.equals(w.session()) || w.wire().major() != 1)
@@ -0,0 +1,170 @@
package de.neitzel.fxdesigner.target;
import static org.junit.jupiter.api.Assertions.*;
import de.neitzel.fx.view.*;
import java.util.*;
import javafx.beans.property.*;
import javafx.scene.layout.VBox;
import org.junit.jupiter.api.Test;
class RepeatDiscoveryTest {
public static class Item {}
public static class Model {
static {
System.setProperty("repeat.model.initialized", "yes");
}
public List<Item> getItems() {
throw new AssertionError("getter");
}
public void setItems(List<Item> v) {}
public List getRaw() {
throw new AssertionError("getter");
}
public void setRaw(List v) {}
public String getName() {
throw new AssertionError("getter");
}
public void setName(String v) {}
}
public static class Replacement {
public Replacement(Model m) {
throw new AssertionError("constructor");
}
public ListProperty<Item> itemsProperty() {
throw new AssertionError("accessor");
}
public ObjectProperty<List<Item>> uncertainProperty() {
throw new AssertionError("accessor");
}
public ObjectProperty<String> nameProperty() {
throw new AssertionError("accessor");
}
}
@ViewModelClass(Replacement.class)
public static class Controller {}
public static class Good extends VBox {
static {
System.setProperty("repeat.component.initialized", "yes");
}
public ObjectProperty<Item> modelProperty() {
throw new AssertionError("accessor");
}
}
public static class Wrong extends VBox {
public ObjectProperty<Model> modelProperty() {
throw new AssertionError("accessor");
}
}
public static class Unknown extends VBox {
public ObjectProperty<Object> modelProperty() {
throw new AssertionError("accessor");
}
}
public static class ReadOnly extends VBox {
public ReadOnlyObjectProperty<Item> modelProperty() {
throw new AssertionError("accessor");
}
}
private String xml(String collection, String component, String pane, String controller) {
return "<?neitzel-model "
+ Model.class.getName()
+ "?><?import javafx.scene.layout.VBox?><?import javafx.scene.control.Label?>"
+ "<VBox xmlns:fx='http://javafx.com/fxml/1'"
+ (controller.isEmpty() ? "" : " fx:controller='" + controller + "'")
+ "><fx:define><de.neitzel.fx.view.BindingList>"
+ "<de.neitzel.fx.view.Repeat pane='"
+ pane
+ "' collection='"
+ collection
+ "' component='"
+ component
+ "'/></de.neitzel.fx.view.BindingList></fx:define><VBox fx:id='rows'/><Label"
+ " fx:id='label'/></VBox>";
}
private de.neitzel.fxdesigner.protocol.BindingMetadata inspect(
String path, Class<?> type, String pane, String controller) throws Exception {
return BindingDiscovery.inspect(
xml(path, type.getName(), pane, controller), path, "view", getClass().getClassLoader());
}
@Test
void acceptsDeclaredTypesWithoutInitializingApplicationCode() throws Exception {
var m = inspect("items", Good.class, "rows", "");
assertTrue(m.problems().isEmpty(), m.problems().toString());
assertTrue(
m.collections().stream()
.anyMatch(
c ->
c.name().equals("items")
&& c.type().equals(Item.class.getName())
&& c.status().equals("COMPATIBLE")));
assertTrue(
m.panes().stream()
.anyMatch(p -> p.name().equals("label") && p.status().equals("INCOMPATIBLE")));
assertNull(System.getProperty("repeat.model.initialized"));
assertNull(System.getProperty("repeat.component.initialized"));
}
@Test
void rejectsWrongModelPaneAndMissingCollection() throws Exception {
assertTrue(
inspect("items", Wrong.class, "rows", "").problems().stream()
.anyMatch(p -> p.severity().equals("ERROR") && p.message().contains("incompatible")));
assertTrue(
inspect("items", Good.class, "label", "").problems().stream()
.anyMatch(p -> p.message().contains("JavaFX Pane")));
assertTrue(
inspect("name", Good.class, "rows", "").problems().stream()
.anyMatch(p -> p.severity().equals("ERROR") && p.message().contains("collection")));
assertTrue(
inspect("absent", Good.class, "rows", "").problems().stream()
.anyMatch(p -> p.severity().equals("ERROR")));
assertTrue(
inspect("items", ReadOnly.class, "rows", "").problems().stream()
.anyMatch(p -> p.message().contains("modelProperty")));
}
@Test
void keepsUnknownElementAndComponentModelsUnverified() throws Exception {
for (var m :
List.of(
inspect("raw", Good.class, "rows", ""), inspect("items", Unknown.class, "rows", ""))) {
assertTrue(m.problems().stream().anyMatch(p -> p.severity().equals("WARNING")));
assertTrue(
m.problems().stream().noneMatch(p -> p.severity().equals("ERROR")),
m.problems().toString());
}
}
@Test
void discoversReplacementObservableCollectionsAndWarnsForPlainListBacking() throws Exception {
var m = inspect("items", Good.class, "rows", Controller.class.getName());
assertTrue(m.problems().isEmpty(), m.problems().toString());
assertTrue(
m.collections().stream()
.anyMatch(c -> c.name().equals("items") && c.status().equals("COMPATIBLE")));
assertTrue(m.collections().stream().noneMatch(c -> c.name().equals("raw")));
var uncertain = inspect("uncertain", Good.class, "rows", Controller.class.getName());
assertTrue(uncertain.problems().stream().anyMatch(p -> p.severity().equals("WARNING")));
}
}
@@ -0,0 +1 @@
<fx:root xmlns:fx="http://javafx.com/fxml/1" type="javafx.scene.layout.VBox"/>
@@ -0,0 +1 @@
<fx:root xmlns:fx="http://javafx.com/fxml/1" type="javafx.scene.layout.VBox"/>
@@ -0,0 +1 @@
<fx:root xmlns:fx="http://javafx.com/fxml/1" type="javafx.scene.layout.VBox"/>
@@ -0,0 +1 @@
<fx:root xmlns:fx="http://javafx.com/fxml/1" type="javafx.scene.layout.VBox"/>
+1 -1
View File
@@ -12,7 +12,7 @@ For Person the browser exposes `name`, `rank`, `home`, `home.street`, and `visit
## Metadata boundary
The fresh target worker loads classes without initialization and inspects signatures. It never constructs Models, Controllers, providers, controls or Converters, never invokes getters/property accessors, and never starts JavaFX. Application types stay outside both hosts. The independently negotiated `bindings-v3` capability adds the BINDINGS operation; it uses bounded UTF-8 metadata, build/context identity and document revisions, with zero image dimensions. Existing wire operation ordinals remain unchanged.
The fresh target worker loads classes without initialization and inspects signatures. It never constructs Models, Controllers, providers, controls or Converters, never invokes getters/property accessors, and never starts JavaFX. Application types stay outside both hosts. The independently negotiated `bindings-v4` capability adds the BINDINGS operation; it uses bounded UTF-8 metadata, build/context identity and document revisions, with zero image dimensions. Existing wire operation ordinals remain unchanged.
Model fallback follows NeitzelLib's public read/write JavaBean Attribute rules and simple/complex/collection classification. Nested paths expand to depth four with cycle diagnostics; collection paths expose declared element types without pretending a collection is a single nested Model. Explicit custom BeanInfo is not executed: types requiring it report a generated-metadata diagnostic. Custom/replacement ViewModels, built-in message properties and Computed methods now follow the [signature-based selection rules](viewmodel-computed-validation.md). Generated metadata and arbitrary dynamic property APIs remain unresolved.
+1 -1
View File
@@ -27,7 +27,7 @@ The chooser distinguishes compatible, incompatible, unverified and invalid candi
The worker indexes class headers in captured classpath/module-path directories and JARs, respecting multi-release JAR selection, then loads Converter descendants without initialization. It reads annotations and method signatures without constructing Converters or invoking application methods. The index is bounded to 100,000 classes and 256 candidates; the normal worker deadline and cancellation apply. Application classes never enter a host.
The subsequent [ViewModel/Computed slice](viewmodel-computed-validation.md) now uses `bindings-v3` and metadata format 3, adding candidate and document-problem records to the existing BINDINGS operation without changing its wire ordinal. Older binding capabilities are not silently interpreted as this format. The UTF-8 payload, metadata counts, request identity, build identity and document-revision checks remain bounded.
The subsequent [ViewModel/Computed slice](viewmodel-computed-validation.md) now uses `bindings-v4` and metadata format 4, adding candidate and document-problem records to the existing BINDINGS operation without changing its wire ordinal. Older binding capabilities are not silently interpreted as this format. The UTF-8 payload, metadata counts, request identity, build identity and document-revision checks remain bounded.
The validator covers attribute-form DirectBindings in root BindingLists: discoverable Model paths, control IDs, JavaFX property existence/writability, plain type compatibility, explicit Converter availability/capabilities and duplicate Direct/Computed targets. Known violations are errors. Unresolved types, incomplete/custom Model metadata, missing Model declarations and nested binding syntax produce warnings. Custom/replacement ViewModel signatures and Computed methods are now checked by the [next slice](viewmodel-computed-validation.md). Generated metadata, collection semantics, nested Component draft metadata, dependency injection availability and runtime method behavior remain incomplete. A warning does not certify that preview will succeed. Complete Problems-view integration and broader binding authoring remain later work.
+2 -2
View File
@@ -14,9 +14,9 @@ A second Repeat targeting the same pane requires **Replace conflicting bindings*
## Validation boundary
Structural diagnostics cover missing/ambiguous pane identifiers, duplicate Repeat panes, and missing/malformed Component class or collection names. Known structural errors block new previews; invalid drafts remain saveable and undoable. **Validate Repeat bindings** reports these checks and existing target-side binding diagnostics for the current source. Validation does not apply pending form values.
Structural diagnostics cover missing/ambiguous pane identifiers, duplicate Repeat panes, and missing/malformed Component class or collection names. Known structural errors block new previews; invalid drafts remain saveable and undoable. **Discover / Validate Repeat** reports structural and [target-side compatibility checks](repeat-compatibility.md) for the current source. Validation does not apply pending form values.
This slice accepts typed field values. A collection/Component chooser and complete preflight compatibility discovery are later work. The pane's actual Java type, collection existence and ObservableList value, element/Component Model compatibility, Component loading and injection still require target-runtime loading. The inspector explicitly distinguishes structural validation from those remaining runtime checks. Generated metadata, Table/Column authoring, structural reference refactoring and complete IDE Problems integration also remain open.
The [collection/Component discovery slice](repeat-compatibility.md) now adds choices and declared-type validation in both hosts. Unknown metadata remains a warning; actual values, Component construction and injection remain runtime checks. Generated metadata, Table/Column authoring, structural reference refactoring and complete IDE Problems integration remain open.
## Automated qualification
+37
View File
@@ -0,0 +1,37 @@
# Repeat collection and Component discovery
In **Bindings → Repeat bindings**, use **Discover / Validate Repeat** after Build and Refresh. The shared inspector lists collections with their element types, pane targets, and Component candidates with compatibility explanations. Choosing a collection refreshes the Component comparisons. Choosing a pane or Component only fills the form; **Apply Repeat binding** remains the source-changing action. Discovery preserves explicitly entered Component names and never silently selects a unique candidate.
The same worker-side checks validate saved or unsaved Repeat declarations before new previews. Known errors block preview while Save and ordinary host Undo/Redo remain available. Unknown types produce warnings and allow preview. Form suggestions are checked against the requested collection; document diagnostics describe the existing source, not unapplied field changes. Source/build invalidation clears Repeat choices, and stale responses cannot populate a changed form or document.
## Runtime-aligned metadata
Generic ViewModel Model collections retain the existing nested Attribute paths and generic element metadata. NeitzelLib wraps Model collections as observable lists. Custom and replacement ViewModels use their public JavaFX Property accessor signatures; `ListProperty<T>` and `Property<ObservableList<T>>` provide element information without invoking accessors. A plain `Property<List<T>>` cannot prove its value is observable and remains unverified. Raw collections, unresolved type variables, dynamic property resolvers, missing Model context and absent Component Model constraints are also unverified. Known non-collection properties are rejected.
Component discovery scans conventional FXML resource names on the target classpath/module path, then loads associated classes **without initialization**. It checks public concrete JavaFX Parent classes, writable accessible `modelProperty`, available `ClassName.fxml`, `fx:root`, its base type and the absence of a conflicting Controller. Collection element types are compared with both the Component's `neitzel-model` declaration and its typed Model property. Missing resources/classes, wrong pane types, non-collection paths and known Model mismatches are errors. Pane choices include incompatible controls with explanations rather than hiding why they cannot be used.
The scan uses the target resource loader, including captured draft overlays for existing Component resources. It is bounded to 4,096 scanned FXML entries and 256 candidates. Component FXML parsing is secure and size-limited. No application constructor, getter, accessor, Controller method, provider or static initializer is executed by discovery. Application types never enter the host UI.
## Boundaries
Compatibility means declared type compatibility, not guaranteed runtime success. Injection configuration, constructor/accessor bodies, null collection values, null elements, dynamic list contents and Component binding behavior remain runtime checks. This slice does not alter NeitzelLib's collection-change semantics. Generated metadata, arbitrary generic substitution across a selected ViewModel's inheritance, BeanInfo execution and cross-View incidental ViewModel selection remain outside the signature fallback.
Named modules must expose Component resources to the discovery worker and reflective Model access to NeitzelLib; unavailable metadata is reported rather than assumed compatible.
Candidate enumeration follows conventional `package/ClassName.fxml` resource names; unusual/nested class naming may require typing the class explicitly and validating the declaration. Newly created classes still require Build and Refresh. Table/Column authoring, structural reference refactoring and complete IDE Problems integration remain later work.
## Protocol and automation
Binding metadata format 4 and the negotiated `bindings-v4` capability add bounded collection, Component and pane options. Old metadata capabilities are rejected. Existing build, draft and worker identity checks remain in effect; no host-specific application reflection was added.
Unit tests cover matching and mismatching types, wrong pane targets, missing/non-collection paths, raw element types, writable Model access, replacement ViewModel collections, uncertain plain-List backing, non-initialization and metadata round trips. Real classpath and module-path fixtures qualify Component resource discovery and mismatch diagnostics. The shared live host scenario chooses a collection, checks compatible/incompatible options, preserves explicit choices and source until Apply, saves a mismatched Component, verifies preview blocking and exact Undo/Redo, then renders the corrected draft. Existing inspector and lifecycle scenarios remain enabled. No manual testing is required; previously skipped checks remain skipped.
Run the bootstrap and registered IntelliJ checks documented in the [README](../README.md).
## Qualification — 2026-11-13
The full bootstrap gate passed: 103 unit/adapter tests, 36 target processes with 498 assertions, both native standalone modes, 48 worker lifecycle cycles, connected host checks and plugin packaging. Registered IntelliJ classpath and module-path runs also passed the shared inspector scenarios, exclusive JavaFX 26 native-library checks and worker shutdown.
Earlier registered IntelliJ attempts encountered one initial-render timeout and two immediate saved-file assertions (one Repeat and one pre-existing DirectBinding scenario). The files inspected after failure contained the requested invalid drafts. Waiting for the shared Source view before the Repeat saves did not explain the DirectBinding failure. A subsequent complete registered run passed with unchanged production code and unchanged file-content assertions; temporary diagnostics were removed. The intermittent render/save failures are **not claimed fixed** and remain automation-reliability follow-ups. No speculative save or rendering change was included.
Local run evidence: `/tmp/repeat-compatibility-bootstrap-final.log`, `/tmp/repeat-save-diagnostic.log`, and `target/ide-workflow/{classpath,modulepath}.json`. These generated artifacts are not repository inputs. Previously skipped human checks remain skipped.
+5 -1
View File
@@ -120,4 +120,8 @@ The [Computed authoring slice](computed-binding-inspector.md) adds create/change
## Shared Repeat-binding inspector
The [Repeat authoring slice](repeat-binding-inspector.md) adds create/change/remove, explicit duplicate-pane replacement, structural diagnostics and normal host Undo/Redo in both hosts. Collection/Component compatibility discovery and Table/Column authoring remain subsequent work, alongside generated metadata, reference refactoring and Problems integration.
The [Repeat authoring slice](repeat-binding-inspector.md) adds create/change/remove, explicit duplicate-pane replacement, structural diagnostics and normal host Undo/Redo in both hosts. Collection/Component compatibility discovery is covered by the next completed slice below. Table/Column authoring, generated metadata, reference refactoring and Problems integration remain subsequent work.
## Repeat collection and Component compatibility
The [shared Repeat discovery slice](repeat-compatibility.md) adds collection, pane and Component choices, non-initializing target-side type comparisons, source-preserving explicit selections and document diagnostics with preview gating. Runtime construction and values remain preview checks. Table/Column authoring, generated metadata, reference refactoring and Problems integration remain open.
+1 -1
View File
@@ -20,7 +20,7 @@ Computed validation still runs when a Model declaration is missing: that limits
## Boundaries
Application classes stay in the target worker and are loaded without initialization. The `bindings-v3` capability and metadata format 3 add Controller/ViewModel identity and Computed signatures, plus the captured resource on requests. Old metadata capabilities are rejected rather than silently decoded. Existing protocol identities, payload limits, path/target limits and worker cancellation apply; the method inventory is bounded to 256 entries.
Application classes stay in the target worker and are loaded without initialization. The current `bindings-v4` capability and metadata format 4 retain Controller/ViewModel identity and Computed signatures, plus the captured resource on requests, and add [Repeat options](repeat-compatibility.md). Old metadata capabilities are rejected rather than silently decoded. Existing protocol identities, payload limits, path/target limits and worker cancellation apply; the method inventory is bounded to 256 entries.
Generated metadata, custom BeanInfo execution, arbitrary dynamic property resolvers, full generic inference, cross-View first-use conflicts, nested ViewModel selection caused by other Views/Components, collection authoring and dependency-injection viability remain outside this slice. Method bodies, null results, constructor side effects and Computed refresh behavior are runtime concerns; compatible signatures do not prove that application code will succeed. In particular, discovery does not change which additional properties NeitzelLib observes for Computed refresh. Complete IDE Problems integration remains later work.
@@ -0,0 +1,11 @@
package fixture.components;
public class WrongRepeatCard extends javafx.scene.layout.VBox {
static {
System.setProperty("fixture.repeat.initialized", "yes");
}
public javafx.beans.property.ObjectProperty<Object> modelProperty() {
throw new AssertionError("Discovery invoked Component");
}
}
@@ -0,0 +1 @@
<?neitzel-model fixture.model.Person?><fx:root xmlns:fx="http://javafx.com/fxml/1" type="javafx.scene.layout.VBox"/>
@@ -178,6 +178,49 @@ public final class PreviewChecks {
"fixture.replacement.initialized",
"fixture.badgecard.initialized"))
check(System.getProperty(key) == null, "Discovery initialized " + key);
try (var in = loader.getResourceAsStream("fixture/components/Full.fxml")) {
String full = new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
var repeat = BindingDiscovery.inspect(full, "visits", "view", loader);
check(
repeat.collections().stream()
.anyMatch(c -> c.name().equals("visits") && c.status().equals("COMPATIBLE")),
"Repeat collection absent");
check(
repeat.components().stream()
.anyMatch(
c ->
c.name().equals("fixture.components.AddressForm")
&& c.status().equals("COMPATIBLE")),
"Repeat Component absent");
check(
repeat.components().stream()
.anyMatch(
c ->
c.name().equals("fixture.components.WrongRepeatCard")
&& c.status().equals("INCOMPATIBLE")),
"Wrong Repeat Model accepted");
check(
repeat.panes().stream()
.anyMatch(c -> c.name().equals("visits") && c.status().equals("COMPATIBLE")),
"Repeat pane absent");
check(repeat.problems().isEmpty(), "Valid Repeat rejected: " + repeat.problems());
var bad =
BindingDiscovery.inspect(
full.replace(
"component=\"fixture.components.AddressForm\"",
"component=\"fixture.components.WrongRepeatCard\""),
"visits",
"view",
loader);
check(
bad.problems().stream()
.anyMatch(
p -> p.severity().equals("ERROR") && p.message().contains("incompatible")),
"Repeat mismatch not diagnosed");
check(
System.getProperty("fixture.repeat.initialized") == null,
"Component discovery initialized class");
}
System.out.println("PASS bindings " + checks);
return;
}
@@ -0,0 +1,11 @@
package fixture.components;
public class WrongRepeatCard extends javafx.scene.layout.VBox {
static {
System.setProperty("fixture.repeat.initialized", "yes");
}
public javafx.beans.property.ObjectProperty<Object> modelProperty() {
throw new AssertionError("Discovery invoked Component");
}
}
@@ -0,0 +1 @@
<?neitzel-model fixture.model.Person?><fx:root xmlns:fx="http://javafx.com/fxml/1" type="javafx.scene.layout.VBox"/>
@@ -178,6 +178,49 @@ public final class PreviewChecks {
"fixture.replacement.initialized",
"fixture.badgecard.initialized"))
check(System.getProperty(key) == null, "Discovery initialized " + key);
try (var in = loader.getResourceAsStream("fixture/components/Full.fxml")) {
String full = new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
var repeat = BindingDiscovery.inspect(full, "visits", "view", loader);
check(
repeat.collections().stream()
.anyMatch(c -> c.name().equals("visits") && c.status().equals("COMPATIBLE")),
"Repeat collection absent");
check(
repeat.components().stream()
.anyMatch(
c ->
c.name().equals("fixture.components.AddressForm")
&& c.status().equals("COMPATIBLE")),
"Repeat Component absent");
check(
repeat.components().stream()
.anyMatch(
c ->
c.name().equals("fixture.components.WrongRepeatCard")
&& c.status().equals("INCOMPATIBLE")),
"Wrong Repeat Model accepted");
check(
repeat.panes().stream()
.anyMatch(c -> c.name().equals("visits") && c.status().equals("COMPATIBLE")),
"Repeat pane absent");
check(repeat.problems().isEmpty(), "Valid Repeat rejected: " + repeat.problems());
var bad =
BindingDiscovery.inspect(
full.replace(
"component=\"fixture.components.AddressForm\"",
"component=\"fixture.components.WrongRepeatCard\""),
"visits",
"view",
loader);
check(
bad.problems().stream()
.anyMatch(
p -> p.severity().equals("ERROR") && p.message().contains("incompatible")),
"Repeat mismatch not diagnosed");
check(
System.getProperty("fixture.repeat.initialized") == null,
"Component discovery initialized class");
}
System.out.println("PASS bindings " + checks);
return;
}