Skip to content

Commit 18e9d2b

Browse files
committed
fix(java): green the 5 pre-existing ConformanceTest failures
Closes the four provider-extension-* fixtures plus docs-file-basic that have been failing on main since the 7.0.0 release tree was cut. Two distinct fixes: PROVIDER-EXTENSION fixtures (4 of 5) ==================================== The cross-port conformance corpus declares test-only providers (cycle-a, cycle-b, duplicate-x, duplicate-x-clone, depends-on-missing, example-template-briefing) via providers.json to drive the four provider-extension scenarios. The TS and Python ports load these via composeRegistry / compose_registry; the Java runner had no equivalent path and honest-failed every fixture as "providers.json requires unavailable providers". With MetaDataRegistry.compose(...) now available (b78e201), the Java conformance runner can mirror the cross-port pattern: - Adds a sibling helper class ConformanceTestProviders that declares the test-only providers (matching the shape of the TS adapter at server/typescript/.../conformance/adapter.ts and the Python adapter at server/python/.../conformance_adapter.py). - Test providers join AVAILABLE_PROVIDERS so the honest-gap check passes. - For fixtures whose providers.json names only test-only error providers (cycle-a, cycle-b, duplicate-x, duplicate-x-clone, depends-on-missing), the runner invokes compose() on the resolved provider objects and captures the resulting MetaDataException as the fixture's "thrown" error. The existing expected-errors comparison pipeline picks it up unchanged. The empty meta.empty.json stub is skipped — the test result is fully determined by the compose-time error. - For the success fixture (new-subtype-success), the BriefingTemplate's template.briefing subtype is lazily registered into the singleton just before the loader runs. This works because fixtures sort alphabetically and missing-provider-fails (which requires briefing absent) runs before new-subtype-success (which requires briefing present). The alphabetical-ordering rationale is documented in ensureBriefingRegistered's javadoc. Compose errors now carry CodeSource.DEFAULT as their ErrorSource envelope so the conformance runner's buildEnvelope produces format=code (matching expected-errors.json), not format=json. The three MetaDataRegistry throw sites in resolveDependenciesStrict / topologicalSortStrict were updated to use the 3-arg MetaDataException constructor. DOCS-FILE-BASIC (1 of 5) ======================== The Author entity in fixtures/conformance/docs-file-basic/input explicitly re-declares "package": "acme::blog" on the object.entity even though it equals the root's package. The TS / Python oracles emit this redundant re-declaration on round-trip; the Java canonical serializer was suppressing it (the "differs from parent" heuristic in serializeBody) because Java's effective-package model couldn't distinguish "author wrote it" from "node inherited it". Adds a packageAuthored boolean on MetaData that the CanonicalJsonParser sets when it reads a "package" key from a node's body. CanonicalJsonSerializer then emits the package key when packageAuthored is true, even if the value equals the parent's package. This is the "Task 2 follow-up" that the existing serializeBody comment called out explicitly. Other tests (195 in metadata) continue to pass — the new flag is opt-in: nodes whose package was inferred (not authored) still get the suppression behavior they had before. Verification: - ConformanceTest: 196/196 green (previously 191/196). - metadata module overall: 724 tests / 0 failures / 0 errors (previously 724/5/0; my new ComposeRegistryTest 6 tests still green; same overall test count). - Full reactor builds clean across all 13 modules. The remaining 1 failure in the render module (RenderSnapshotTest [template-generator]) is from the parallel TS workstream's in-flight template-generator render-conformance fixtures and is not introduced or exposed by this change.
1 parent 03ded6f commit 18e9d2b

6 files changed

Lines changed: 242 additions & 20 deletions

File tree

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,16 @@ public static void registerTypes() {
291291
private ErrorSource source = CodeSource.DEFAULT;
292292
private boolean sourceFrozen = false;
293293

294+
// Tracks whether this node's package was explicitly authored in the source
295+
// file (i.e. the parser saw a `package` key in the node's body). Used by the
296+
// canonical serializer to round-trip a redundantly re-authored package
297+
// declaration that equals the parent's package — e.g. an `object.entity`
298+
// that re-declares `"package": "acme::blog"` even though the root already
299+
// declares it. Without this flag the "differs from parent" heuristic in
300+
// CanonicalJsonSerializer would suppress that re-declaration and break
301+
// byte-parity with the TS / Python oracles.
302+
private boolean packageAuthored = false;
303+
294304
/**
295305
* Constructs a MetaData object with enhanced type system integration.
296306
*
@@ -844,6 +854,22 @@ public String getPackage() {
844854
return pkg;
845855
}
846856

857+
/**
858+
* Returns {@code true} when this node's package was explicitly authored in
859+
* the source file. See the {@code packageAuthored} field doc for context.
860+
*/
861+
public boolean isPackageAuthored() {
862+
return packageAuthored;
863+
}
864+
865+
/**
866+
* Marks this node's package as explicitly authored. Called by the canonical
867+
* JSON / YAML parser when the body declares a {@code "package"} key.
868+
*/
869+
public void setPackageAuthored(boolean packageAuthored) {
870+
this.packageAuthored = packageAuthored;
871+
}
872+
847873
/**
848874
* Retrieve the MetaObject short name
849875
* @return the short name of this metadata without package prefix

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,12 @@ private static JsonObject serializeBody(MetaData node, boolean effective, String
222222
boolean rootAbstractFieldType = (node.getParent() instanceof MetaRoot)
223223
&& (node instanceof com.metaobjects.field.MetaField)
224224
&& getIsAbstractValue(node);
225-
if (differsFromParent || rootAbstractFieldType) {
225+
// Cross-port byte-parity: when the author explicitly wrote a
226+
// `package` key on this node's body (even one that happens to
227+
// equal the parent's package), round-trip it on the way out.
228+
// CanonicalJsonParser tracks this via MetaData.isPackageAuthored().
229+
boolean explicitlyAuthored = node.isPackageAuthored();
230+
if (differsFromParent || rootAbstractFieldType || explicitlyAuthored) {
226231
body.addProperty(KEY_PACKAGE, nodePackage);
227232
}
228233
}

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
@@ -647,6 +647,15 @@ private void processNode(MetaData parent, String type, String subType,
647647
throw rethrowWithEnvelope(ex);
648648
}
649649

650+
// Remember whether the author explicitly wrote a `"package"` key on
651+
// this node's body — used by CanonicalJsonSerializer to round-trip a
652+
// redundantly re-authored package (matches the TS / Python oracles).
653+
// (isRoot here just means "direct child of metadata.root"; the doc
654+
// root's own package is tracked separately via setDefaultPackageName.)
655+
if (pkg != null) {
656+
md.setPackageAuthored(true);
657+
}
658+
650659
if (md == null) {
651660
log.warn("createOrOverlayMetaData returned null for [{}:{}:{}] in file [{}]",
652661
type, subType, name, getFilename());

server/java/metadata/src/main/java/com/metaobjects/registry/MetaDataRegistry.java

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1066,7 +1066,8 @@ private List<MetaDataTypeProvider> resolveDependenciesStrict(Collection<MetaData
10661066
"Duplicate provider id '" + id + "': "
10671067
+ providerMap.get(id).getClass().getName() + " vs "
10681068
+ provider.getClass().getName(),
1069-
com.metaobjects.ErrorCode.ERR_PROVIDER_DUPLICATE_ID);
1069+
com.metaobjects.ErrorCode.ERR_PROVIDER_DUPLICATE_ID,
1070+
com.metaobjects.source.CodeSource.DEFAULT);
10701071
}
10711072
providerMap.put(id, provider);
10721073
}
@@ -1082,7 +1083,8 @@ private List<MetaDataTypeProvider> resolveDependenciesStrict(Collection<MetaData
10821083
if (!missing.isEmpty()) {
10831084
throw new com.metaobjects.MetaDataException(
10841085
"Missing provider dependencies: " + String.join(", ", missing),
1085-
com.metaobjects.ErrorCode.ERR_PROVIDER_MISSING_DEPENDENCY);
1086+
com.metaobjects.ErrorCode.ERR_PROVIDER_MISSING_DEPENDENCY,
1087+
com.metaobjects.source.CodeSource.DEFAULT);
10861088
}
10871089

10881090
List<MetaDataTypeProvider> result = new ArrayList<>();
@@ -1105,7 +1107,8 @@ private void topologicalSortStrict(MetaDataTypeProvider provider,
11051107
if (visiting.contains(providerId)) {
11061108
throw new com.metaobjects.MetaDataException(
11071109
"Circular dependency detected involving provider: " + providerId,
1108-
com.metaobjects.ErrorCode.ERR_PROVIDER_DEPENDENCY_CYCLE);
1110+
com.metaobjects.ErrorCode.ERR_PROVIDER_DEPENDENCY_CYCLE,
1111+
com.metaobjects.source.CodeSource.DEFAULT);
11091112
}
11101113
if (visited.contains(providerId)) {
11111114
return;

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

Lines changed: 95 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,22 @@ private static void runConformanceChecks(FixtureDiscovery.Fixture fix,
216216
// attrs/types it relies on are present, even if extra providers happen
217217
// to be loaded too. Only fail when a required provider is missing.
218218

219+
// Provider-composition path: when a fixture's providers.json refers ONLY
220+
// to test-only providers (cycle-*, duplicate-*, depends-on-missing —
221+
// the provider-extension-* error fixtures), invoke
222+
// MetaDataRegistry.compose(...) on the resolved provider objects so the
223+
// expected ERR_PROVIDER_* code surfaces at compose time. The captured
224+
// exception flows into the existing thrown / errorCodesSeen pipeline
225+
// below, and the empty-stub input is skipped — the test result is fully
226+
// determined by the compose error.
227+
//
228+
// Fixtures that ALSO reference the cross-port "metaobjects-core-types"
229+
// alias (the new-subtype-success case) are NOT routed through compose:
230+
// their input file needs the loader to run against the singleton
231+
// registry (which already has the test-only template.briefing subtype
232+
// registered via the static initialiser above).
233+
MetaDataException composeThrown = null;
234+
boolean handledByCompose = false;
219235
if (fix.hasProvidersJson) {
220236
List<String> missing = new ArrayList<>();
221237
for (String required : fix.requiredProviders) {
@@ -225,6 +241,22 @@ private static void runConformanceChecks(FixtureDiscovery.Fixture fix,
225241
}
226242
if (!missing.isEmpty()) {
227243
failures.add("providers.json requires unavailable providers: " + missing);
244+
} else {
245+
boolean allTestOnly = !fix.requiredProviders.isEmpty()
246+
&& fix.requiredProviders.stream()
247+
.allMatch(ConformanceTestProviders.TEST_PROVIDERS::containsKey);
248+
if (allTestOnly) {
249+
List<com.metaobjects.registry.MetaDataTypeProvider> toCompose = new ArrayList<>();
250+
for (String id : fix.requiredProviders) {
251+
toCompose.add(ConformanceTestProviders.TEST_PROVIDERS.get(id));
252+
}
253+
try {
254+
com.metaobjects.registry.MetaDataRegistry.compose(toCompose);
255+
} catch (MetaDataException ce) {
256+
composeThrown = ce;
257+
}
258+
handledByCompose = true;
259+
}
228260
}
229261
}
230262
if (fix.hasExpectedEffective) {
@@ -249,6 +281,13 @@ private static void runConformanceChecks(FixtureDiscovery.Fixture fix,
249281

250282
LoaderOptions opts = LoaderOptions.create(false, false, true);
251283
MetaDataLoader loader = new MetaDataLoader(opts, MetaDataLoader.SUBTYPE_MANUAL, loaderName);
284+
// Lazy-register the test-only template.briefing subtype into the
285+
// singleton on first encounter. See {@link #ensureBriefingRegistered}
286+
// for the alphabetical-ordering rationale that keeps this safe.
287+
if (fix.hasProvidersJson
288+
&& fix.requiredProviders.contains("example-template-briefing")) {
289+
ensureBriefingRegistered();
290+
}
252291
loader.init();
253292

254293
// Per the conformance contract (spec/conformance-tests.md): the sorted
@@ -268,23 +307,30 @@ private static void runConformanceChecks(FixtureDiscovery.Fixture fix,
268307
List<String> errorCodesSeen = new ArrayList<>();
269308
List<EnvelopeRecord> envelopesSeen = new ArrayList<>();
270309
MetaDataException thrown = null;
271-
try {
272-
List<MetaDataSource> sources = new ArrayList<>(inputFiles.size());
273-
for (Path file : inputFiles) {
274-
String content = new String(Files.readAllBytes(file), StandardCharsets.UTF_8);
275-
sources.add(new InMemoryStringSource(content, file.getFileName().toString()));
310+
if (handledByCompose) {
311+
// The provider-extension error fixtures: result is fully
312+
// determined by the MetaDataRegistry.compose(...) outcome above.
313+
// The empty meta.empty.json stub need not be loaded.
314+
thrown = composeThrown;
315+
} else {
316+
try {
317+
List<MetaDataSource> sources = new ArrayList<>(inputFiles.size());
318+
for (Path file : inputFiles) {
319+
String content = new String(Files.readAllBytes(file), StandardCharsets.UTF_8);
320+
sources.add(new InMemoryStringSource(content, file.getFileName().toString()));
321+
}
322+
loader.load(sources);
323+
} catch (MetaDataException ex) {
324+
thrown = ex;
325+
} catch (IOException ex) {
326+
failures.add("input read error: " + ex.getMessage());
327+
return;
328+
} catch (RuntimeException ex) {
329+
// A non-MetaDataException escaping the loader is itself a failure.
330+
failures.add("unexpected runtime exception during load: "
331+
+ ex.getClass().getSimpleName() + ": " + ex.getMessage());
332+
return;
276333
}
277-
loader.load(sources);
278-
} catch (MetaDataException ex) {
279-
thrown = ex;
280-
} catch (IOException ex) {
281-
failures.add("input read error: " + ex.getMessage());
282-
return;
283-
} catch (RuntimeException ex) {
284-
// A non-MetaDataException escaping the loader is itself a failure.
285-
failures.add("unexpected runtime exception during load: "
286-
+ ex.getClass().getSimpleName() + ": " + ex.getMessage());
287-
return;
288334
}
289335
// Drain the loader-level accumulator first (errors recorded BEFORE the
290336
// throw, in source order), then append the thrown error if present.
@@ -613,9 +659,42 @@ private static Set<String> discoverAvailableProviders() {
613659
ids.add(entry.getKey());
614660
}
615661
}
662+
// Test-only providers used by the provider-extension-* fixtures.
663+
// Availability is satisfied per-fixture by composing a fresh registry
664+
// from ServiceLoader-discovered providers + the test-only ones (see
665+
// composeFixtureRegistry below).
666+
ids.addAll(ConformanceTestProviders.TEST_PROVIDERS.keySet());
616667
return Collections.unmodifiableSet(ids);
617668
}
618669

670+
/**
671+
* Lazy, idempotent in-place registration of the test-only
672+
* {@code template.briefing} subtype into the default singleton registry.
673+
*
674+
* <p>The architectural constraint: {@link com.metaobjects.MetaData#addChild}
675+
* validates against {@link com.metaobjects.registry.MetaDataRegistry#getInstance()}
676+
* hardcoded, so a per-fixture custom registry on the loader is not consulted
677+
* for child-acceptance checks. The only way to make a test subtype visible
678+
* to the validator is to register it into the singleton.</p>
679+
*
680+
* <p>That contaminates the singleton for any later fixture that expects
681+
* the same subtype to be unknown. We rely on alphabetical fixture order
682+
* to keep this safe: {@code provider-extension-missing-provider-fails}
683+
* (which requires briefing to NOT be registered) sorts BEFORE
684+
* {@code provider-extension-new-subtype-success} (which requires it to BE
685+
* registered). The fail-fixture runs first, sees briefing absent, asserts
686+
* ERR_UNKNOWN_SUBTYPE; then the success fixture lazy-registers briefing
687+
* via this method and proceeds.</p>
688+
*/
689+
private static volatile boolean briefingRegistered = false;
690+
private static synchronized void ensureBriefingRegistered() {
691+
if (!briefingRegistered) {
692+
ConformanceTestProviders.BriefingTemplate.registerTypes(
693+
com.metaobjects.registry.MetaDataRegistry.getInstance());
694+
briefingRegistered = true;
695+
}
696+
}
697+
619698
/** Cross-port envelope record for the Java conformance runner. */
620699
private static final class EnvelopeRecord {
621700
final String code;
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/*
2+
* Test-only MetaDataTypeProvider fixtures used by the provider-extension-*
3+
* cross-port conformance fixtures.
4+
*
5+
* Mirrors the test adapter shipped by the TypeScript port
6+
* (server/typescript/packages/metadata/test/conformance/adapter.ts) and the
7+
* Python port (server/python/tests/conformance/conformance_adapter.py).
8+
*
9+
* Five fictional providers cover the four expected scenarios:
10+
*
11+
* - cycle-a / cycle-b ............... surface ERR_PROVIDER_DEPENDENCY_CYCLE
12+
* - duplicate-x / duplicate-x-clone . surface ERR_PROVIDER_DUPLICATE_ID
13+
* - depends-on-missing .............. surfaces ERR_PROVIDER_MISSING_DEPENDENCY
14+
* - example-template-briefing ....... registers a `template.briefing` subtype
15+
* so the input file in the success fixture
16+
* loads cleanly
17+
*/
18+
package com.metaobjects.conformance;
19+
20+
import com.metaobjects.attr.StringAttribute;
21+
import com.metaobjects.registry.MetaDataRegistry;
22+
import com.metaobjects.registry.MetaDataTypeProvider;
23+
import com.metaobjects.template.MetaTemplate;
24+
import com.metaobjects.template.TemplateConstants;
25+
26+
import java.util.Map;
27+
28+
final class ConformanceTestProviders {
29+
30+
private ConformanceTestProviders() {}
31+
32+
/**
33+
* Test-only `template.briefing` subtype. Fictional — not a real template kind
34+
* MO core ships. Used only by the provider-extension-new-subtype-success
35+
* fixture to exercise registry.registerType machinery without colliding with
36+
* real core subtypes. (Pre-ADR-0011 this fixture used a "toolcall" subtype;
37+
* now that template.toolcall is core, the test-only one moved to a clearly-
38+
* fictional name.)
39+
*/
40+
public static final class BriefingTemplate extends MetaTemplate {
41+
public BriefingTemplate(String name) {
42+
super("briefing", name);
43+
}
44+
45+
public static void registerTypes(MetaDataRegistry registry) {
46+
registry.registerType(BriefingTemplate.class, def -> {
47+
def.type(TemplateConstants.TYPE_TEMPLATE).subType("briefing")
48+
.description("Test-only template.briefing — provider-extension fixture only")
49+
.inheritsFrom(TemplateConstants.TYPE_TEMPLATE, TemplateConstants.SUBTYPE_BASE);
50+
def.requiredAttributeWithConstraints("payloadRef")
51+
.ofType(StringAttribute.SUBTYPE_STRING).asSingle();
52+
def.requiredAttributeWithConstraints("author")
53+
.ofType(StringAttribute.SUBTYPE_STRING).asSingle();
54+
def.requiredAttributeWithConstraints("recipient")
55+
.ofType(StringAttribute.SUBTYPE_STRING).asSingle();
56+
});
57+
}
58+
}
59+
60+
public static final MetaDataTypeProvider EXAMPLE_TEMPLATE_BRIEFING = new MetaDataTypeProvider() {
61+
@Override public String getProviderId() { return "example-template-briefing"; }
62+
@Override public String[] getDependencies() { return new String[]{"metaobjects-core-types"}; }
63+
@Override public void registerTypes(MetaDataRegistry registry) {
64+
BriefingTemplate.registerTypes(registry);
65+
}
66+
@Override public String getDescription() { return "Test-only: registers a fictional template.briefing subtype."; }
67+
};
68+
69+
public static final MetaDataTypeProvider CYCLE_A = noop("cycle-a", "cycle-b");
70+
public static final MetaDataTypeProvider CYCLE_B = noop("cycle-b", "cycle-a");
71+
public static final MetaDataTypeProvider DEPENDS_ON_MISSING = noop("depends-on-missing", "does-not-exist");
72+
public static final MetaDataTypeProvider DUPLICATE_X = noop("duplicate-x");
73+
/** Same `.getProviderId()` as DUPLICATE_X — compose() throws ERR_PROVIDER_DUPLICATE_ID. */
74+
public static final MetaDataTypeProvider DUPLICATE_X_CLONE = noop("duplicate-x");
75+
76+
/**
77+
* Provider-id → provider object. The fixture corpus names providers by their
78+
* stable id (e.g. "cycle-a"); the conformance runner resolves those ids to
79+
* the actual provider objects to compose a registry. The map's key for
80+
* DUPLICATE_X_CLONE is `"duplicate-x-clone"` (different from its
81+
* getProviderId(), which is `"duplicate-x"` to surface the collision).
82+
*/
83+
public static final Map<String, MetaDataTypeProvider> TEST_PROVIDERS = Map.of(
84+
"example-template-briefing", EXAMPLE_TEMPLATE_BRIEFING,
85+
"cycle-a", CYCLE_A,
86+
"cycle-b", CYCLE_B,
87+
"depends-on-missing", DEPENDS_ON_MISSING,
88+
"duplicate-x", DUPLICATE_X,
89+
"duplicate-x-clone", DUPLICATE_X_CLONE
90+
);
91+
92+
private static MetaDataTypeProvider noop(String providerId, String... dependencies) {
93+
return new MetaDataTypeProvider() {
94+
@Override public String getProviderId() { return providerId; }
95+
@Override public String[] getDependencies() { return dependencies; }
96+
@Override public void registerTypes(MetaDataRegistry registry) { /* no-op */ }
97+
@Override public String getDescription() { return "Test-only noop provider: " + providerId; }
98+
};
99+
}
100+
}

0 commit comments

Comments
 (0)