Skip to content

Commit 0641496

Browse files
dmealingclaude
andcommitted
feat(conformance): #37 Java — flattened kitchen-sink byte-match + effective-serialization gating (covers JVM incl. Kotlin)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 98072cc commit 0641496

3 files changed

Lines changed: 152 additions & 55 deletions

File tree

server/java/metadata/src/main/java/com/metaobjects/io/json/CanonicalJsonSerializer.java

Lines changed: 105 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -287,42 +287,38 @@ private static JsonObject serializeBody(MetaData node, boolean effective, String
287287
body.addProperty(KEY_NAME, shortName);
288288
}
289289

290-
// 2. package — emit only where introduced (differs from parent's package).
290+
// 2. package — emit only where the TS oracle sets `model.package`.
291291
//
292292
// Special case: MetaRoot's full name IS the package (e.g. "acme::commerce").
293293
// For all other nodes, getPackage() gives the package prefix of the qualified name.
294294
//
295-
// WHY "differs from parent" rather than a naive "emit if non-empty":
296-
// The TS oracle uses model.package, which is the *authored* package — undefined
297-
// when the author did not write a package key, so "emit if present" works there.
298-
// Java's getPackage() returns the *effective* (inherited) package: every node in
299-
// an "acme::commerce" tree would return "acme::commerce" even when they never
300-
// authored it. Emitting unconditionally would wrongly inject a "package" key on
301-
// every node. Emitting only when the value differs from the parent's resolved
302-
// package approximates "authored here for the first time."
295+
// The TS oracle emits `package` iff `model.package` is set, which happens for:
296+
// (a) the root node (its package is always present);
297+
// (b) a node that explicitly authored a `package` key on its body;
298+
// (c) a MetaField whose parent is NOT a MetaObject (field-at-root /
299+
// abstract shared field) — TS's package-inheritance rule sets
300+
// `model.package` from the file context for these.
303301
//
304-
// Known imprecision (Task 2 follow-up): a child that redundantly re-authors the
305-
// exact same package as its parent would be suppressed here but emitted by TS.
306-
// Tracking the truly-authored package requires a separate flag on MetaData that
307-
// does not exist yet; that is the Task 2 work.
302+
// We must NOT use "differs from parent package" as a trigger: a single
303+
// loader builds ONE MetaRoot for a multi-file load, so children merged
304+
// from a file with a different package would spuriously emit a `package`
305+
// key that TS never emits (it threads authored-package, not effective
306+
// package). Effective package is recovered structurally on reload from
307+
// the parent context, so suppressing it here is byte-correct and
308+
// round-trips.
308309
String nodePackage = resolveNodePackage(node);
309310
if (nodePackage != null && !nodePackage.isEmpty()) {
310-
boolean differsFromParent = (parentPackage == null) || !nodePackage.equals(parentPackage);
311-
// Cross-port: abstract field-type nodes declared at root level
312-
// (e.g. an abstract field.enum bound as a shared type) always emit
313-
// their package, even when it equals the root's package — they are
314-
// addressable library entries that need a stable qualifier so other
315-
// entities can reference them via `extends`. Objects / templates /
316-
// layouts at root do not need the redundant emission.
317-
boolean rootAbstractFieldType = (node.getParent() instanceof MetaRoot)
318-
&& (node instanceof com.metaobjects.field.MetaField)
319-
&& getIsAbstractValue(node);
311+
boolean isRoot = node instanceof MetaRoot;
320312
// Cross-port byte-parity: when the author explicitly wrote a
321-
// `package` key on this node's body (even one that happens to
322-
// equal the parent's package), round-trip it on the way out.
313+
// `package` key on this node's body, round-trip it on the way out.
323314
// CanonicalJsonParser tracks this via MetaData.isPackageAuthored().
324315
boolean explicitlyAuthored = node.isPackageAuthored();
325-
if (differsFromParent || rootAbstractFieldType || explicitlyAuthored) {
316+
// TS package-inheritance rule (c): a MetaField NOT directly inside a
317+
// MetaObject (declared at root, or in another non-object container)
318+
// carries its own package so it stays addressable via `extends`.
319+
boolean fieldOutsideObject = (node instanceof com.metaobjects.field.MetaField)
320+
&& !(node.getParent() instanceof com.metaobjects.object.MetaObject);
321+
if (isRoot || explicitlyAuthored || fieldOutsideObject) {
326322
body.addProperty(KEY_PACKAGE, nodePackage);
327323
}
328324
}
@@ -438,47 +434,111 @@ private static List<MetaData> collectOwnStructuralChildren(MetaData node) {
438434
}
439435

440436
/**
441-
* Returns structural children (own + inherited from super chain) de-duplicated by
442-
* type+name, preserving own children first.
437+
* Returns the effective structural children of {@code node} (own + inherited
438+
* via the super chain), in the cross-port canonical order.
439+
*
440+
* <p>Mirrors the TS oracle's {@code _effectiveChildren} exactly: start from
441+
* the super's effective children, then for each own child either OVERRIDE
442+
* the matching (type, name) super child in place (preserving the super's
443+
* position) or APPEND it at the end when it introduces a new member. This
444+
* places inherited members first, with newly-introduced own members last —
445+
* the byte-order the {@code expected-effective.json} fixtures assert.</p>
446+
*
447+
* <p>Java's {@code getChildren(MetaData.class, true)} de-duplicates by
448+
* type+name but lists own children first, which diverges from the oracle —
449+
* hence this explicit recursive merge.</p>
443450
*/
444451
private static List<MetaData> collectEffectiveStructuralChildren(MetaData node) {
445-
// getChildren(MetaData.class, true) walks the super chain but de-duplicates by type-name key.
446-
List<MetaData> all = node.getChildren(MetaData.class, true);
447-
List<MetaData> result = new ArrayList<>();
448-
for (MetaData child : all) {
449-
if (!(child instanceof MetaAttribute)) {
450-
result.add(child);
452+
return effectiveChildrenMerged(node, new java.util.IdentityHashMap<>());
453+
}
454+
455+
private static List<MetaData> effectiveChildrenMerged(MetaData node,
456+
java.util.Map<MetaData, Boolean> visited) {
457+
MetaData superData = node.hasSuperData() ? node.getSuperData() : null;
458+
459+
if (superData == null || visited.containsKey(superData)) {
460+
return collectOwnStructuralChildren(node);
461+
}
462+
visited.put(superData, Boolean.TRUE);
463+
464+
// Start from the super's effective children (a fresh, mutable copy).
465+
List<MetaData> result = new ArrayList<>(effectiveChildrenMerged(superData, visited));
466+
467+
List<MetaData> appendQueue = new ArrayList<>();
468+
for (MetaData ownChild : collectOwnStructuralChildren(node)) {
469+
int idx = -1;
470+
for (int i = 0; i < result.size(); i++) {
471+
MetaData sc = result.get(i);
472+
if (sc.getType().equals(ownChild.getType())
473+
&& canonicalMatchName(sc).equals(canonicalMatchName(ownChild))) {
474+
idx = i;
475+
break;
476+
}
477+
}
478+
if (idx != -1) {
479+
result.set(idx, ownChild); // in-place override, super's position kept
480+
} else {
481+
appendQueue.add(ownChild);
451482
}
452483
}
484+
result.addAll(appendQueue);
453485
return result;
454486
}
455487

488+
/**
489+
* The name used for cross-port effective-override matching. Mirrors the TS
490+
* oracle, whose auto-named nodes carry {@code name === ""}: an auto-generated
491+
* name (e.g. a {@code source.rdb}'s {@code rdb1}) collapses to the empty
492+
* string so an own auto-named node overrides its inherited counterpart at the
493+
* super's position — exactly as TS matches {@code (type, "")} against
494+
* {@code (type, "")}. Java assigns real {@code rdbN} auto-names (which the
495+
* serializer suppresses), so without this collapse the per-package counter
496+
* would make Product's {@code rdb1} and ProductSummary's {@code rdb2} fail to
497+
* match and the inherited source would wrongly duplicate.
498+
*/
499+
private static String canonicalMatchName(MetaData node) {
500+
if (isAutoGeneratedName(node)) {
501+
return "";
502+
}
503+
String s = node.getShortName();
504+
return s == null ? "" : s;
505+
}
506+
456507
// ---------------------------------------------------------------------------
457508
// Helpers — package resolution
458509
// ---------------------------------------------------------------------------
459510

460511
/**
461512
* Returns the "authoring package" of a node — the package context an author
462-
* would use to write its {@code extends} ref. For root-level nodes this is
463-
* the root's own package; for child nodes it walks up to the nearest
464-
* MetaObject (entity / value) and returns that ancestor's package. Used by
465-
* extends-ref serialization so a field inside an entity in package X
466-
* references a sibling in X by short name (not by FQN).
513+
* would use to write its {@code extends} ref. For a node that is itself a
514+
* top-level type (a child of the MetaRoot — an object / template / abstract
515+
* field), this is the node's OWN package. For a node nested inside an entity
516+
* it walks up to the nearest MetaObject and returns that ancestor's package.
517+
* Used by extends-ref serialization so a sibling in the same package is
518+
* referenced by short name (not by FQN).
519+
*
520+
* <p>Cross-port correctness for the flattened multi-file case: a single
521+
* loader builds ONE MetaRoot whose name is just the first input file's
522+
* package. The other files contribute children in OTHER packages. The
523+
* root's own package is therefore NOT the authoring context for those
524+
* children — each node's own {@link MetaData#getPackage()} (the effective
525+
* package of its FQN) is. So we never fall back to the root's package; we
526+
* fall back to the node's own package.</p>
467527
*/
468528
private static String authoringPackageFor(MetaData node) {
469529
MetaData current = node;
470530
while (current != null) {
471-
if (current instanceof MetaRoot) {
472-
return resolveNodePackage(current);
473-
}
474531
MetaData parent = current.getParent();
475-
if (parent == null) break;
532+
if (parent == null || parent instanceof MetaRoot) {
533+
// `current` is a top-level type — its own package IS the
534+
// authoring context (not the shared root's package).
535+
return current.getPackage();
536+
}
476537
if (parent instanceof com.metaobjects.object.MetaObject) {
477538
// current is a child of an entity → authoring context = entity's package
478539
return parent.getPackage();
479540
}
480-
// Non-MetaObject parent (or MetaRoot): keep walking up; MetaRoot is
481-
// caught on the next iteration's top-of-loop check.
541+
// Non-MetaObject parent: keep walking up.
482542
current = parent;
483543
}
484544
return node.getPackage();

server/java/metadata/src/test/java/com/metaobjects/conformance/ConformanceTest.java

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,11 @@
7878
* so canonical round-trips produce the right top-level {@code package}.</li>
7979
* <li>Warnings: the Java loader has no warning surface yet. Fixtures with
8080
* {@code expected-warnings.json} are ledgered as gaps.</li>
81-
* <li>Effective serialization + script execution: not implemented in the
82-
* Java harness yet. Fixtures using {@code expected-effective.json} or
83-
* {@code script.json} are ledgered.</li>
81+
* <li>Effective serialization: supported. A fixture's
82+
* {@code expected-effective.json} (extends RESOLVED — inherited members
83+
* inlined) is byte-compared against
84+
* {@link CanonicalJsonSerializer#canonicalSerializeEffective(MetaData)}
85+
* when present.</li>
8486
* </ul>
8587
*/
8688
@RunWith(Parameterized.class)
@@ -271,9 +273,9 @@ private static void runConformanceChecks(FixtureDiscovery.Fixture fix,
271273
}
272274
}
273275
}
274-
if (fix.hasExpectedEffective) {
275-
failures.add("expected-effective.json (effective serialization) not supported by Java harness");
276-
}
276+
// expected-effective.json is now supported — the assertion lives below,
277+
// after the loader has run and built the tree (so the effective
278+
// serialization can resolve the extends chain).
277279
// expected-warnings.json is now supported — the assertion lives below,
278280
// after the loader has run (so loader.getWarnings() has a value to
279281
// compare against).
@@ -447,6 +449,26 @@ private static void runConformanceChecks(FixtureDiscovery.Fixture fix,
447449
}
448450
}
449451

452+
// -- expected-effective.json check ----------------------------------
453+
// Mirrors the TS runner: when a fixture ships expected-effective.json,
454+
// emit the EFFECTIVE canonical serialization (extends resolved —
455+
// inherited members inlined) and byte-compare (newline-normalized).
456+
if (fix.hasExpectedEffective) {
457+
String want;
458+
try {
459+
want = new String(Files.readAllBytes(fix.dir.resolve("expected-effective.json")),
460+
StandardCharsets.UTF_8).trim();
461+
} catch (IOException ex) {
462+
failures.add("expected-effective.json read error: " + ex.getMessage());
463+
return;
464+
}
465+
String got = CanonicalJsonSerializer.canonicalSerializeEffective(loader.getRoot()).trim();
466+
if (!want.equals(got)) {
467+
failures.add("effective serialization mismatch:\n--- expected ---\n"
468+
+ want + "\n--- got ---\n" + got);
469+
}
470+
}
471+
450472
// -- expected-warnings check ----------------------------------------
451473
// Match the TS/C# contract: warnings are compared as a multiset of exact
452474
// strings (order-insensitive). If a fixture has NO expected-warnings.json

server/java/metadata/src/test/java/com/metaobjects/io/json/CanonicalJsonSerializerTest.java

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -267,10 +267,18 @@ public void testCanonicalSerializeEffectiveIncludesInheritedAttrs() {
267267
}
268268

269269
// -----------------------------------------------------------------------
270-
// Test 10 — child in a different package emits a "package" key
270+
// Test 10 — package emission follows the cross-port AUTHORED-package rule
271+
//
272+
// The TS oracle emits a node's `package` key iff the author wrote one on the
273+
// node body (or it is the root / a field outside an object). A child object
274+
// whose effective package merely DIFFERS from its parent's does NOT emit a
275+
// redundant `package` key — the package is recovered structurally on reload.
276+
// This is the byte-correct cross-port behavior (a single loader builds ONE
277+
// MetaRoot for a multi-file load, so children merged from a file with a
278+
// different package must not spuriously emit a key TS never emits).
271279
// -----------------------------------------------------------------------
272280
@Test
273-
public void testChildInDifferentPackageEmitsPackageKey() {
281+
public void testChildInDifferentPackageDoesNotEmitPackageKeyUnlessAuthored() {
274282
// Root is "acme::core"; child object lives in "acme::commerce" (different package).
275283
MetaRoot root = new MetaRoot("acme::core");
276284

@@ -279,9 +287,16 @@ public void testChildInDifferentPackageEmitsPackageKey() {
279287

280288
String json = CanonicalJsonSerializer.canonicalSerialize(root);
281289

282-
// The child node must emit its own "package" key because it differs from the root's.
283-
assertTrue("child node must emit 'package' key when it differs from parent's, got: " + json,
290+
// Effective-package difference alone must NOT emit a `package` key.
291+
assertFalse("unauthored child must NOT emit a redundant 'package' key, got: " + json,
284292
json.contains("\"package\": \"acme::commerce\""));
293+
294+
// When the author explicitly wrote the package (parser sets this flag),
295+
// it round-trips on the way out.
296+
product.setPackageAuthored(true);
297+
String authoredJson = CanonicalJsonSerializer.canonicalSerialize(root);
298+
assertTrue("authored child must emit its 'package' key, got: " + authoredJson,
299+
authoredJson.contains("\"package\": \"acme::commerce\""));
285300
}
286301

287302
// -----------------------------------------------------------------------

0 commit comments

Comments
 (0)