Skip to content

Commit 91723f1

Browse files
dmealingclaude
andcommitted
fix(metadata): #37 — Java echoes authored extends-ref verbatim (cross-port parity) + fixture gates same-package-FQN extends
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 43b8ac2 commit 91723f1

7 files changed

Lines changed: 112 additions & 70 deletions

File tree

fixtures/conformance/flattened-kitchen-sink/expected-effective.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -585,7 +585,7 @@
585585
{
586586
"object.entity": {
587587
"name": "ProductSummary",
588-
"extends": "Product",
588+
"extends": "acme::catalog::Product",
589589
"@description": "Read-only projection: aggregates, passthrough and a collection origin.",
590590
"@uiHints": {
591591
"group": "inventory",

fixtures/conformance/flattened-kitchen-sink/expected.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -509,7 +509,7 @@
509509
{
510510
"object.entity": {
511511
"name": "ProductSummary",
512-
"extends": "Product",
512+
"extends": "acme::catalog::Product",
513513
"@description": "Read-only projection: aggregates, passthrough and a collection origin.",
514514
"children": [
515515
{

fixtures/conformance/flattened-kitchen-sink/input/meta.catalog.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@
221221
{
222222
"object.entity": {
223223
"name": "ProductSummary",
224-
"extends": "Product",
224+
"extends": "acme::catalog::Product",
225225
"@description": "Read-only projection: aggregates, passthrough and a collection origin.",
226226
"children": [
227227
{ "source.rdb": { "@kind": "view", "@view": "v_product_summary" } },

server/java/metadata/src/main/java/com/metaobjects/MetaData.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,15 @@ public static void registerTypes() {
302302
// byte-parity with the TS / Python oracles.
303303
private boolean packageAuthored = false;
304304

305+
// The raw `extends` (super) reference string EXACTLY as authored in the
306+
// source file (e.g. "Product", "acme::catalog::Product", a relative ref).
307+
// The parser resolves this string to a concrete super node (getSuperData),
308+
// but the canonical serializer must echo the AUTHORED form verbatim — never
309+
// a recomputed short-vs-FQN form — to stay byte-identical with the TS / C# /
310+
// Python oracles (which all preserve and re-emit the raw `superRef`). See
311+
// CanonicalJsonSerializer's `extends` emission and the TS `model.superRef`.
312+
private String authoredSuperRef = null;
313+
305314
/**
306315
* Constructs a MetaData object with enhanced type system integration.
307316
*
@@ -871,6 +880,25 @@ public void setPackageAuthored(boolean packageAuthored) {
871880
this.packageAuthored = packageAuthored;
872881
}
873882

883+
/**
884+
* Returns the raw {@code extends} (super) reference string exactly as it was
885+
* authored in the source file, or {@code null} if no {@code extends} was
886+
* authored on this node. The canonical serializer echoes this verbatim. See
887+
* the {@code authoredSuperRef} field doc for context.
888+
*/
889+
public String getAuthoredSuperRef() {
890+
return authoredSuperRef;
891+
}
892+
893+
/**
894+
* Records the raw, as-authored {@code extends} (super) reference string.
895+
* Called by the canonical JSON / YAML parser when the body declares an
896+
* {@code "extends"} key, so the serializer can re-emit it verbatim.
897+
*/
898+
public void setAuthoredSuperRef(String authoredSuperRef) {
899+
this.authoredSuperRef = authoredSuperRef;
900+
}
901+
874902
/**
875903
* Retrieve the MetaObject short name
876904
* @return the short name of this metadata without package prefix

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

Lines changed: 16 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -323,27 +323,23 @@ private static JsonObject serializeBody(MetaData node, boolean effective, String
323323
}
324324
}
325325

326-
// 3. extends — emit when super data is set
327-
// Use the short name when the super is in the same package as the node;
328-
// use the fully-qualified name when the super lives in a different package
329-
// so that round-trip loading can resolve it correctly.
326+
// 3. extends — echo the AUTHORED super-ref string VERBATIM.
327+
//
328+
// Cross-port contract: TS / C# / Python all preserve the raw `superRef`
329+
// the parser read and re-emit it unchanged (TS `model.superRef`). Java
330+
// must do the same. Recomputing a short-vs-FQN form (the prior behavior)
331+
// only happened to byte-match fixtures that authored cross-package
332+
// extends as FQN and same-package extends as short names — a same-package
333+
// extends authored as a full FQN (or a relative ref) would diverge.
334+
//
335+
// The parser stores the as-authored string via setAuthoredSuperRef when
336+
// it sees an `extends` key; fall back to the resolved super FQN only when
337+
// no authored string is available (e.g. a programmatically-built tree).
330338
if (node.hasSuperData()) {
331-
MetaData superData = node.getSuperData();
332-
// For "same-package" detection we want the NODE's AUTHORING context
333-
// (closest MetaObject/MetaRoot ancestor's package — a field `status`
334-
// inside `Order` in `acme` has getPackage() == "acme::Order" but
335-
// authors think of it as living in "acme") compared against the
336-
// SUPER's own declared package (from its FQN), so synthetic
337-
// tree-misalignments still emit FQN extends when the names diverge.
338-
String authoringPackage = authoringPackageFor(node);
339-
String superPackage = resolveNodePackage(superData);
340-
String superRef;
341-
if (authoringPackage != null && !authoringPackage.isEmpty()
342-
&& authoringPackage.equals(superPackage)) {
343-
superRef = superData.getShortName();
344-
} else {
345-
superRef = superData.getName();
346-
}
339+
String authoredSuperRef = node.getAuthoredSuperRef();
340+
String superRef = (authoredSuperRef != null && !authoredSuperRef.isEmpty())
341+
? authoredSuperRef
342+
: node.getSuperData().getName();
347343
if (superRef != null && !superRef.isEmpty()) {
348344
body.addProperty(KEY_EXTENDS, superRef);
349345
}
@@ -508,42 +504,6 @@ private static String canonicalMatchName(MetaData node) {
508504
// Helpers — package resolution
509505
// ---------------------------------------------------------------------------
510506

511-
/**
512-
* Returns the "authoring package" of a node — the package context an author
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>
527-
*/
528-
private static String authoringPackageFor(MetaData node) {
529-
MetaData current = node;
530-
while (current != null) {
531-
MetaData parent = current.getParent();
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-
}
537-
if (parent instanceof com.metaobjects.object.MetaObject) {
538-
// current is a child of an entity → authoring context = entity's package
539-
return parent.getPackage();
540-
}
541-
// Non-MetaObject parent: keep walking up.
542-
current = parent;
543-
}
544-
return node.getPackage();
545-
}
546-
547507
/**
548508
* Returns the "canonical package" for a node.
549509
*

server/java/metadata/src/main/java/com/metaobjects/loader/parser/json/CanonicalJsonParser.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,15 @@ private void processNode(MetaData parent, String type, String subType,
656656
md.setPackageAuthored(true);
657657
}
658658

659+
// Preserve the raw, as-authored `extends` reference so the canonical
660+
// serializer can echo it VERBATIM (matching the TS / C# / Python oracles
661+
// which all re-emit the raw superRef). Without this Java would recompute
662+
// a short-vs-FQN form and diverge on, e.g., a same-package extends that
663+
// the author wrote as a full FQN.
664+
if (md != null && superRef != null && !superRef.isEmpty()) {
665+
md.setAuthoredSuperRef(superRef);
666+
}
667+
659668
if (md == null) {
660669
log.warn("createOrOverlayMetaData returned null for [{}:{}:{}] in file [{}]",
661670
type, subType, name, getFilename());

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

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,13 @@ public void testAttrsInAlphabeticalOrder() {
213213
}
214214

215215
// -----------------------------------------------------------------------
216-
// Test 7 — extends emitted when super data is set
216+
// Test 7 — extends emitted when super data is set (no authored ref → FQN)
217+
//
218+
// Cross-port contract (matches TS / C# / Python): the serializer echoes the
219+
// AUTHORED `extends` string verbatim. A programmatically-built tree that sets
220+
// super via setSuperData() (never the parser) carries NO authored ref, so the
221+
// serializer falls back to the resolved super FQN. The serializer must never
222+
// recompute a short-vs-FQN form — that was the latent divergence (#37).
217223
// -----------------------------------------------------------------------
218224
@Test
219225
public void testExtendsEmittedWhenSuperDataSet() {
@@ -229,7 +235,46 @@ public void testExtendsEmittedWhenSuperDataSet() {
229235

230236
String json = CanonicalJsonSerializer.canonicalSerialize(root);
231237

232-
assertTrue("expected extends key with short name, got: " + json, json.contains("\"extends\": \"BaseEntity\""));
238+
// No authored ref on this programmatic tree → fall back to resolved FQN.
239+
assertTrue("expected extends key with resolved FQN, got: " + json,
240+
json.contains("\"extends\": \"test::pkg::BaseEntity\""));
241+
}
242+
243+
// -----------------------------------------------------------------------
244+
// Test 7b — extends echoes the AUTHORED ref VERBATIM (cross-port contract)
245+
//
246+
// When the authored `extends` string is preserved (as the canonical parser
247+
// does via setAuthoredSuperRef), the serializer re-emits it byte-for-byte —
248+
// regardless of whether the super lives in the same or a different package.
249+
// This is the contract gated by the flattened-kitchen-sink fixture's
250+
// same-package-FQN extends; here we assert it directly on a synthetic tree.
251+
// -----------------------------------------------------------------------
252+
@Test
253+
public void testExtendsEchoesAuthoredRefVerbatim() {
254+
MetaRoot root = new MetaRoot("test::pkg");
255+
256+
ValueMetaObject base = ValueMetaObject.create("test::pkg::Base");
257+
base.addMetaAttr(BooleanAttribute.create(MetaData.ATTR_IS_ABSTRACT, true));
258+
root.addChild(base);
259+
260+
// Same package as the super, but authored as a FULL FQN — must echo FQN.
261+
ValueMetaObject derivedFqn = ValueMetaObject.create("test::pkg::DerivedFqn");
262+
derivedFqn.setSuperData(base);
263+
derivedFqn.setAuthoredSuperRef("test::pkg::Base");
264+
root.addChild(derivedFqn);
265+
266+
// Same package, authored as a SHORT name — must echo the short name.
267+
ValueMetaObject derivedShort = ValueMetaObject.create("test::pkg::DerivedShort");
268+
derivedShort.setSuperData(base);
269+
derivedShort.setAuthoredSuperRef("Base");
270+
root.addChild(derivedShort);
271+
272+
String json = CanonicalJsonSerializer.canonicalSerialize(root);
273+
274+
assertTrue("authored FQN extends must echo verbatim, got: " + json,
275+
json.contains("\"extends\": \"test::pkg::Base\""));
276+
assertTrue("authored short extends must echo verbatim, got: " + json,
277+
json.contains("\"extends\": \"Base\""));
233278
}
234279

235280
// -----------------------------------------------------------------------
@@ -330,13 +375,16 @@ public void testExtendsEmittedAsFQNWhenSuperInDifferentPackage() {
330375
}
331376

332377
// -----------------------------------------------------------------------
333-
// Test 12 — extends emitted as short name when super is in the same package
378+
// Test 12 — no authored ref → serializer falls back to the resolved FQN
334379
//
335-
// Both Base and Derived are in "test::pkg". The existing Test 7 covers
336-
// same-package extends; this test makes the intent explicit.
380+
// Both Base and Derived are in "test::pkg", super set programmatically with
381+
// NO authored ref. Under the echo-verbatim contract (#37) the serializer no
382+
// longer recomputes a short name for same-package supers — it emits the
383+
// resolved FQN fallback. The short-vs-FQN choice is the AUTHOR's (echoed
384+
// verbatim; see Test 7b), not the serializer's to recompute.
337385
// -----------------------------------------------------------------------
338386
@Test
339-
public void testExtendsEmittedAsShortNameWhenSamePackage() {
387+
public void testExtendsFallsBackToFqnWhenNoAuthoredRef() {
340388
MetaRoot root = new MetaRoot("test::pkg");
341389

342390
ValueMetaObject base = ValueMetaObject.create("test::pkg::Base");
@@ -349,11 +397,8 @@ public void testExtendsEmittedAsShortNameWhenSamePackage() {
349397

350398
String json = CanonicalJsonSerializer.canonicalSerialize(root);
351399

352-
// Same package — short name is unambiguous and preferred.
353-
assertTrue("extends must be the short name when super is in the same package, got: " + json,
354-
json.contains("\"extends\": \"Base\""));
355-
// Must NOT use the FQN — that would be unnecessarily verbose.
356-
assertFalse("extends must NOT be the FQN when super is in the same package",
400+
// No authored ref → resolved FQN fallback (no short-name recompute).
401+
assertTrue("extends must be the resolved FQN when no ref was authored, got: " + json,
357402
json.contains("\"extends\": \"test::pkg::Base\""));
358403
}
359404

0 commit comments

Comments
 (0)